changeset 1706:1e7b7a62d373 single-point

Merge from default branch
author Chris Cannam
date Thu, 16 May 2019 11:07:47 +0100
parents 28f9ff7864c6 (diff) 5d4831c2e8aa (current diff)
children 29c6b3caefa9
files
diffstat 73 files changed, 5385 insertions(+), 3306 deletions(-) [+]
line wrap: on
line diff
--- a/base/BaseTypes.h	Tue May 07 15:54:15 2019 +0100
+++ b/base/BaseTypes.h	Thu May 16 11:07:47 2019 +0100
@@ -55,5 +55,11 @@
 typedef std::vector<std::complex<float>,
                     breakfastquay::StlAllocator<std::complex<float>>> complexvec_t;
 
+typedef uint64_t sv_id_t;
+
+enum {
+    ID_NOTHING = 0
+};
+
 #endif
 
--- a/base/Clipboard.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/base/Clipboard.cpp	Thu May 16 11:07:47 2019 +0100
@@ -15,235 +15,6 @@
 
 #include "Clipboard.h"
 
-Clipboard::Point::Point(sv_frame_t frame, QString label) :
-    m_haveFrame(true),
-    m_frame(frame),
-    m_haveValue(false),
-    m_value(0),
-    m_haveDuration(false),
-    m_duration(0),
-    m_haveLabel(true),
-    m_label(label),
-    m_haveLevel(false),
-    m_level(0.f),
-    m_haveReferenceFrame(false),
-    m_referenceFrame(frame)
-{
-}
-
-Clipboard::Point::Point(sv_frame_t frame, float value, QString label) :
-    m_haveFrame(true),
-    m_frame(frame),
-    m_haveValue(true),
-    m_value(value),
-    m_haveDuration(false),
-    m_duration(0),
-    m_haveLabel(true),
-    m_label(label),
-    m_haveLevel(false),
-    m_level(0.f),
-    m_haveReferenceFrame(false),
-    m_referenceFrame(frame)
-{
-}
-
-Clipboard::Point::Point(sv_frame_t frame, float value, sv_frame_t duration, QString label) :
-    m_haveFrame(true),
-    m_frame(frame),
-    m_haveValue(true),
-    m_value(value),
-    m_haveDuration(true),
-    m_duration(duration),
-    m_haveLabel(true),
-    m_label(label),
-    m_haveLevel(false),
-    m_level(0.f),
-    m_haveReferenceFrame(false),
-    m_referenceFrame(frame)
-{
-}
-
-Clipboard::Point::Point(sv_frame_t frame, float value, sv_frame_t duration, float level, QString label) :
-    m_haveFrame(true),
-    m_frame(frame),
-    m_haveValue(true),
-    m_value(value),
-    m_haveDuration(true),
-    m_duration(duration),
-    m_haveLabel(true),
-    m_label(label),
-    m_haveLevel(true),
-    m_level(level),
-    m_haveReferenceFrame(false),
-    m_referenceFrame(frame)
-{
-}
-
-Clipboard::Point::Point(const Point &point) :
-    m_haveFrame(point.m_haveFrame),
-    m_frame(point.m_frame),
-    m_haveValue(point.m_haveValue),
-    m_value(point.m_value),
-    m_haveDuration(point.m_haveDuration),
-    m_duration(point.m_duration),
-    m_haveLabel(point.m_haveLabel),
-    m_label(point.m_label),
-    m_haveLevel(point.m_haveLevel),
-    m_level(point.m_level),
-    m_haveReferenceFrame(point.m_haveReferenceFrame),
-    m_referenceFrame(point.m_referenceFrame)
-{
-}
-
-Clipboard::Point &
-Clipboard::Point::operator=(const Point &point)
-{
-    if (this == &point) return *this;
-    m_haveFrame = point.m_haveFrame;
-    m_frame = point.m_frame;
-    m_haveValue = point.m_haveValue;
-    m_value = point.m_value;
-    m_haveDuration = point.m_haveDuration;
-    m_duration = point.m_duration;
-    m_haveLabel = point.m_haveLabel;
-    m_label = point.m_label;
-    m_haveLevel = point.m_haveLevel;
-    m_level = point.m_level;
-    m_haveReferenceFrame = point.m_haveReferenceFrame;
-    m_referenceFrame = point.m_referenceFrame;
-    return *this;
-}
-
-bool
-Clipboard::Point::haveFrame() const
-{
-    return m_haveFrame;
-}
-
-sv_frame_t
-Clipboard::Point::getFrame() const
-{
-    return m_frame;
-}
-
-Clipboard::Point
-Clipboard::Point::withFrame(sv_frame_t frame) const
-{
-    Point p(*this);
-    p.m_haveFrame = true;
-    p.m_frame = frame;
-    return p;
-}
-
-bool
-Clipboard::Point::haveValue() const
-{
-    return m_haveValue;
-}
-
-float
-Clipboard::Point::getValue() const
-{
-    return m_value;
-}
-
-Clipboard::Point
-Clipboard::Point::withValue(float value) const
-{
-    Point p(*this);
-    p.m_haveValue = true;
-    p.m_value = value;
-    return p;
-}
-
-bool
-Clipboard::Point::haveDuration() const
-{
-    return m_haveDuration;
-}
-
-sv_frame_t
-Clipboard::Point::getDuration() const
-{
-    return m_duration;
-}
-
-Clipboard::Point
-Clipboard::Point::withDuration(sv_frame_t duration) const
-{
-    Point p(*this);
-    p.m_haveDuration = true;
-    p.m_duration = duration;
-    return p;
-}
-
-bool
-Clipboard::Point::haveLabel() const
-{
-    return m_haveLabel;
-}
-
-QString
-Clipboard::Point::getLabel() const
-{
-    return m_label;
-}
-
-Clipboard::Point
-Clipboard::Point::withLabel(QString label) const
-{
-    Point p(*this);
-    p.m_haveLabel = true;
-    p.m_label = label;
-    return p;
-}
-
-bool
-Clipboard::Point::haveLevel() const
-{
-    return m_haveLevel;
-}
-
-float
-Clipboard::Point::getLevel() const
-{
-    return m_level;
-}
-
-Clipboard::Point
-Clipboard::Point::withLevel(float level) const
-{
-    Point p(*this);
-    p.m_haveLevel = true;
-    p.m_level = level;
-    return p;
-}
-
-bool
-Clipboard::Point::haveReferenceFrame() const
-{
-    return m_haveReferenceFrame;
-}
-
-bool
-Clipboard::Point::referenceFrameDiffers() const
-{
-    return m_haveReferenceFrame && (m_referenceFrame != m_frame);
-}
-
-sv_frame_t
-Clipboard::Point::getReferenceFrame() const
-{
-    return m_referenceFrame;
-}
-
-void
-Clipboard::Point::setReferenceFrame(sv_frame_t f) 
-{
-    m_haveReferenceFrame = true;
-    m_referenceFrame = f;
-}
-
 Clipboard::Clipboard() { }
 Clipboard::~Clipboard() { }
 
@@ -259,20 +30,20 @@
     return m_points.empty();
 }
 
-const Clipboard::PointList &
+const EventVector &
 Clipboard::getPoints() const
 {
     return m_points;
 }
 
 void
-Clipboard::setPoints(const PointList &pl)
+Clipboard::setPoints(const EventVector &pl)
 {
     m_points = pl;
 }
 
 void
-Clipboard::addPoint(const Point &point)
+Clipboard::addPoint(const Event &point)
 {
     m_points.push_back(point);
 }
@@ -280,9 +51,9 @@
 bool
 Clipboard::haveReferenceFrames() const
 {
-    for (PointList::const_iterator i = m_points.begin();
+    for (EventVector::const_iterator i = m_points.begin();
          i != m_points.end(); ++i) {
-        if (i->haveReferenceFrame()) return true;
+        if (i->hasReferenceFrame()) return true;
     } 
     return false;
 }
@@ -290,7 +61,7 @@
 bool
 Clipboard::referenceFramesDiffer() const
 {
-    for (PointList::const_iterator i = m_points.begin();
+    for (EventVector::const_iterator i = m_points.begin();
          i != m_points.end(); ++i) {
         if (i->referenceFrameDiffers()) return true;
     } 
--- a/base/Clipboard.h	Tue May 07 15:54:15 2019 +0100
+++ b/base/Clipboard.h	Thu May 16 11:07:47 2019 +0100
@@ -16,81 +16,27 @@
 #ifndef SV_CLIPBOARD_H
 #define SV_CLIPBOARD_H
 
-#include <QString>
 #include <vector>
 
-#include "BaseTypes.h"
+#include "Event.h"
 
 class Clipboard
 {
 public:
-    class Point
-    {
-    public:
-        Point(sv_frame_t frame, QString label);
-        Point(sv_frame_t frame, float value, QString label);
-        Point(sv_frame_t frame, float value, sv_frame_t duration, QString label);
-        Point(sv_frame_t frame, float value, sv_frame_t duration, float level, QString label);
-        Point(const Point &point);
-        Point &operator=(const Point &point);
-
-        bool haveFrame() const;
-        sv_frame_t getFrame() const;
-        Point withFrame(sv_frame_t frame) const;
-
-        bool haveValue() const;
-        float getValue() const;
-        Point withValue(float value) const;
-        
-        bool haveDuration() const;
-        sv_frame_t getDuration() const;
-        Point withDuration(sv_frame_t duration) const;
-        
-        bool haveLabel() const;
-        QString getLabel() const;
-        Point withLabel(QString label) const;
-
-        bool haveLevel() const;
-        float getLevel() const;
-        Point withLevel(float level) const;
-
-        bool haveReferenceFrame() const;
-        bool referenceFrameDiffers() const; // from point frame
-
-        sv_frame_t getReferenceFrame() const;
-        void setReferenceFrame(sv_frame_t);
-
-    private:
-        bool m_haveFrame;
-        sv_frame_t m_frame;
-        bool m_haveValue;
-        float m_value;
-        bool m_haveDuration;
-        sv_frame_t m_duration;
-        bool m_haveLabel;
-        QString m_label;
-        bool m_haveLevel;
-        float m_level;
-        bool m_haveReferenceFrame;
-        sv_frame_t m_referenceFrame;
-    };
-
     Clipboard();
     ~Clipboard();
 
-    typedef std::vector<Point> PointList;
-
     void clear();
     bool empty() const;
-    const PointList &getPoints() const;
-    void setPoints(const PointList &points);
-    void addPoint(const Point &point);
+    const EventVector &getPoints() const;
+    void setPoints(const EventVector &points);
+    void addPoint(const Event &point);
 
     bool haveReferenceFrames() const;
     bool referenceFramesDiffer() const;
 
 protected:
-    PointList m_points;
+     EventVector m_points;
 };
 
 #endif
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/base/Event.h	Thu May 16 11:07:47 2019 +0100
@@ -0,0 +1,414 @@
+/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
+
+/*
+    Sonic Visualiser
+    An audio file viewer and annotation editor.
+    Centre for Digital Music, Queen Mary, University of London.
+    This file copyright 2006 Chris Cannam.
+    
+    This program is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public License as
+    published by the Free Software Foundation; either version 2 of the
+    License, or (at your option) any later version.  See the file
+    COPYING included with this distribution for more information.
+*/
+
+#ifndef SV_EVENT_H
+#define SV_EVENT_H
+
+#include "BaseTypes.h"
+#include "NoteData.h"
+#include "XmlExportable.h"
+#include "DataExportOptions.h"
+
+#include <vector>
+#include <stdexcept>
+
+#include <QString>
+
+/**
+ * An immutable(-ish) type used for point and event representation in
+ * sparse models, as well as for interchange within the clipboard. An
+ * event always has a frame and (possibly empty) label, and optionally
+ * has numerical value, level, duration in frames, and a mapped
+ * reference frame. Event has an operator< defining a total ordering,
+ * by frame first and then by the other properties.
+ * 
+ * Event is based on the Clipboard::Point type up to SV v3.2.1 and is
+ * intended also to replace the custom point types previously found in
+ * sparse models.
+ */
+class Event
+{
+public:
+    Event() :
+        m_haveValue(false), m_haveLevel(false),
+        m_haveDuration(false), m_haveReferenceFrame(false),
+        m_value(0.f), m_level(0.f), m_frame(0),
+        m_duration(0), m_referenceFrame(0), m_label() { }
+    
+    Event(sv_frame_t frame) :
+        m_haveValue(false), m_haveLevel(false),
+        m_haveDuration(false), m_haveReferenceFrame(false),
+        m_value(0.f), m_level(0.f), m_frame(frame),
+        m_duration(0), m_referenceFrame(0), m_label() { }
+        
+    Event(sv_frame_t frame, QString label) :
+        m_haveValue(false), m_haveLevel(false), 
+        m_haveDuration(false), m_haveReferenceFrame(false),
+        m_value(0.f), m_level(0.f), m_frame(frame),
+        m_duration(0), m_referenceFrame(0), m_label(label) { }
+        
+    Event(sv_frame_t frame, float value, QString label) :
+        m_haveValue(true), m_haveLevel(false), 
+        m_haveDuration(false), m_haveReferenceFrame(false),
+        m_value(value), m_level(0.f), m_frame(frame),
+        m_duration(0), m_referenceFrame(0), m_label(label) { }
+        
+    Event(sv_frame_t frame, float value, sv_frame_t duration, QString label) :
+        m_haveValue(true), m_haveLevel(false), 
+        m_haveDuration(true), m_haveReferenceFrame(false),
+        m_value(value), m_level(0.f), m_frame(frame),
+        m_duration(duration), m_referenceFrame(0), m_label(label) {
+        if (m_duration < 0) throw std::logic_error("duration must be >= 0");
+    }
+        
+    Event(sv_frame_t frame, float value, sv_frame_t duration,
+          float level, QString label) :
+        m_haveValue(true), m_haveLevel(true), 
+        m_haveDuration(true), m_haveReferenceFrame(false),
+        m_value(value), m_level(level), m_frame(frame),
+        m_duration(duration), m_referenceFrame(0), m_label(label) {
+        if (m_duration < 0) throw std::logic_error("duration must be >= 0");
+    }
+
+    Event(const Event &event) =default;
+
+    // We would ideally like Event to be immutable - but we have to
+    // have these because otherwise we can't put Events in vectors
+    // etc. Let's call it conceptually immutable
+    Event &operator=(const Event &event) =default;
+    Event &operator=(Event &&event) =default;
+    
+    sv_frame_t getFrame() const { return m_frame; }
+
+    Event withFrame(sv_frame_t frame) const {
+        Event p(*this);
+        p.m_frame = frame;
+        return p;
+    }
+    
+    bool hasValue() const { return m_haveValue; }
+    float getValue() const { return m_haveValue ? m_value : 0.f; }
+    
+    Event withValue(float value) const {
+        Event p(*this);
+        p.m_haveValue = true;
+        p.m_value = value;
+        return p;
+    }
+    Event withoutValue() const {
+        Event p(*this);
+        p.m_haveValue = false;
+        p.m_value = 0.f;
+        return p;
+    }
+    
+    bool hasDuration() const { return m_haveDuration; }
+    sv_frame_t getDuration() const { return m_haveDuration ? m_duration : 0; }
+
+    Event withDuration(sv_frame_t duration) const {
+        Event p(*this);
+        p.m_duration = duration;
+        p.m_haveDuration = true;
+        if (duration < 0) throw std::logic_error("duration must be >= 0");
+        return p;
+    }
+    Event withoutDuration() const {
+        Event p(*this);
+        p.m_haveDuration = false;
+        p.m_duration = 0;
+        return p;
+    }
+
+    bool hasLabel() const { return m_label != QString(); }
+    QString getLabel() const { return m_label; }
+
+    Event withLabel(QString label) const {
+        Event p(*this);
+        p.m_label = label;
+        return p;
+    }
+
+    bool hasUri() const { return m_uri != QString(); }
+    QString getURI() const { return m_uri; }
+
+    Event withURI(QString uri) const {
+        Event p(*this);
+        p.m_uri = uri;
+        return p;
+    }
+    
+    bool hasLevel() const { return m_haveLevel; }
+    float getLevel() const { return m_haveLevel ? m_level : 0.f; }
+
+    Event withLevel(float level) const {
+        Event p(*this);
+        p.m_haveLevel = true;
+        p.m_level = level;
+        return p;
+    }
+    Event withoutLevel() const {
+        Event p(*this);
+        p.m_haveLevel = false;
+        p.m_level = 0.f;
+        return p;
+    }
+    
+    bool hasReferenceFrame() const { return m_haveReferenceFrame; }
+    sv_frame_t getReferenceFrame() const {
+        return m_haveReferenceFrame ? m_referenceFrame : m_frame;
+    }
+        
+    bool referenceFrameDiffers() const { // from event frame
+        return m_haveReferenceFrame && (m_referenceFrame != m_frame);
+    }
+    
+    Event withReferenceFrame(sv_frame_t frame) const {
+        Event p(*this);
+        p.m_haveReferenceFrame = true;
+        p.m_referenceFrame = frame;
+        return p;
+    }
+    Event withoutReferenceFrame() const {
+        Event p(*this);
+        p.m_haveReferenceFrame = false;
+        p.m_referenceFrame = 0;
+        return p;
+    }
+
+    bool operator==(const Event &p) const {
+
+        if (m_frame != p.m_frame) return false;
+
+        if (m_haveDuration != p.m_haveDuration) return false;
+        if (m_haveDuration && (m_duration != p.m_duration)) return false;
+
+        if (m_haveValue != p.m_haveValue) return false;
+        if (m_haveValue && (m_value != p.m_value)) return false;
+
+        if (m_haveLevel != p.m_haveLevel) return false;
+        if (m_haveLevel && (m_level != p.m_level)) return false;
+
+        if (m_haveReferenceFrame != p.m_haveReferenceFrame) return false;
+        if (m_haveReferenceFrame &&
+            (m_referenceFrame != p.m_referenceFrame)) return false;
+        
+        if (m_label != p.m_label) return false;
+        if (m_uri != p.m_uri) return false;
+        
+        return true;
+    }
+
+    bool operator!=(const Event &p) const {
+        return !operator==(p);
+    }
+
+    bool operator<(const Event &p) const {
+
+        if (m_frame != p.m_frame) {
+            return m_frame < p.m_frame;
+        }
+
+        // events without a property sort before events with that property
+
+        if (m_haveDuration != p.m_haveDuration) {
+            return !m_haveDuration;
+        }
+        if (m_haveDuration && (m_duration != p.m_duration)) {
+            return m_duration < p.m_duration;
+        }
+
+        if (m_haveValue != p.m_haveValue) {
+            return !m_haveValue;
+        }
+        if (m_haveValue && (m_value != p.m_value)) {
+            return m_value < p.m_value;
+        }
+        
+        if (m_haveLevel != p.m_haveLevel) {
+            return !m_haveLevel;
+        }
+        if (m_haveLevel && (m_level != p.m_level)) {
+            return m_level < p.m_level;
+        }
+
+        if (m_haveReferenceFrame != p.m_haveReferenceFrame) {
+            return !m_haveReferenceFrame;
+        }
+        if (m_haveReferenceFrame && (m_referenceFrame != p.m_referenceFrame)) {
+            return m_referenceFrame < p.m_referenceFrame;
+        }
+        
+        if (m_label != p.m_label) {
+            return m_label < p.m_label;
+        }
+        return m_uri < p.m_uri;
+    }
+
+    struct ExportNameOptions {
+
+        ExportNameOptions() :
+            valueAtttributeName("value"),
+            uriAttributeName("uri") { }
+
+        QString valueAtttributeName;
+        QString uriAttributeName;
+    };
+    
+    void toXml(QTextStream &stream,
+               QString indent = "",
+               QString extraAttributes = "",
+               ExportNameOptions opts = ExportNameOptions()) const {
+
+        // For I/O purposes these are points, not events
+        stream << indent << QString("<point frame=\"%1\" ").arg(m_frame);
+        if (m_haveValue) {
+            stream << QString("%1=\"%2\" ")
+                .arg(opts.valueAtttributeName).arg(m_value);
+        }
+        if (m_haveDuration) {
+            stream << QString("duration=\"%1\" ").arg(m_duration);
+        }
+        if (m_haveLevel) {
+            stream << QString("level=\"%1\" ").arg(m_level);
+        }
+        if (m_haveReferenceFrame) {
+            stream << QString("referenceFrame=\"%1\" ")
+                .arg(m_referenceFrame);
+        }
+
+        stream << QString("label=\"%1\" ")
+            .arg(XmlExportable::encodeEntities(m_label));
+        
+        if (m_uri != QString()) {
+            stream << QString("%1=\"%2\" ")
+                .arg(opts.uriAttributeName)
+                .arg(XmlExportable::encodeEntities(m_uri));
+        }
+        stream << extraAttributes << "/>\n";
+    }
+
+    QString toXmlString(QString indent = "",
+                        QString extraAttributes = "") const {
+        QString s;
+        QTextStream out(&s);
+        toXml(out, indent, extraAttributes);
+        out.flush();
+        return s;
+    }
+
+    NoteData toNoteData(sv_samplerate_t sampleRate,
+                        bool valueIsMidiPitch) const {
+
+        sv_frame_t duration;
+        if (m_haveDuration && m_duration > 0) {
+            duration = m_duration;
+        } else {
+            duration = sv_frame_t(sampleRate / 6); // arbitrary short duration
+        }
+
+        int midiPitch;
+        float frequency = 0.f;
+        if (m_haveValue) {
+            if (valueIsMidiPitch) {
+                midiPitch = int(roundf(m_value));
+            } else {
+                frequency = m_value;
+                midiPitch = Pitch::getPitchForFrequency(frequency);
+            }
+        } else {
+            midiPitch = 64;
+            valueIsMidiPitch = true;
+        }
+
+        int velocity = 100;
+        if (m_haveLevel) {
+            if (m_level > 0.f && m_level <= 1.f) {
+                velocity = int(roundf(m_level * 127.f));
+            }
+        }
+
+        NoteData n(m_frame, duration, midiPitch, velocity);
+        n.isMidiPitchQuantized = valueIsMidiPitch;
+        if (!valueIsMidiPitch) {
+            n.frequency = frequency;
+        }
+
+        return n;
+    }
+
+    QString toDelimitedDataString(QString delimiter,
+                                  DataExportOptions opts,
+                                  sv_samplerate_t sampleRate) const {
+        QStringList list;
+
+        list << RealTime::frame2RealTime(m_frame, sampleRate)
+            .toString().c_str();
+        
+        if (m_haveValue) {
+            list << QString("%1").arg(m_value);
+        }
+        
+        if (m_haveDuration) {
+            list << RealTime::frame2RealTime(m_duration, sampleRate)
+                .toString().c_str();
+        }
+        
+        if (m_haveLevel) {
+            if (!(opts & DataExportOmitLevels)) {
+                list << QString("%1").arg(m_level);
+            }
+        }
+        
+        if (m_label != "") list << m_label;
+        if (m_uri != "") list << m_uri;
+        
+        return list.join(delimiter);
+    }
+
+    uint hash(uint seed = 0) const {
+        uint h = qHash(m_label, seed);
+        if (m_haveValue) h ^= qHash(m_value);
+        if (m_haveLevel) h ^= qHash(m_level);
+        h ^= qHash(m_frame);
+        if (m_haveDuration) h ^= qHash(m_duration);
+        if (m_haveReferenceFrame) h ^= qHash(m_referenceFrame);
+        h ^= qHash(m_uri);
+        return h;
+    }
+    
+private:
+    // The order of fields here is chosen to minimise overall size of struct.
+    // We potentially store very many of these objects.
+    // If you change something, check what difference it makes to packing.
+    bool m_haveValue : 1;
+    bool m_haveLevel : 1;
+    bool m_haveDuration : 1;
+    bool m_haveReferenceFrame : 1;
+    float m_value;
+    float m_level;
+    sv_frame_t m_frame;
+    sv_frame_t m_duration;
+    sv_frame_t m_referenceFrame;
+    QString m_label;
+    QString m_uri;
+};
+
+inline uint qHash(const Event &e, uint seed = 0) {
+    return e.hash(seed);
+}
+
+typedef std::vector<Event> EventVector;
+
+#endif
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/base/EventSeries.cpp	Thu May 16 11:07:47 2019 +0100
@@ -0,0 +1,582 @@
+/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
+
+/*
+    Sonic Visualiser
+    An audio file viewer and annotation editor.
+    Centre for Digital Music, Queen Mary, University of London.
+    
+    This program is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public License as
+    published by the Free Software Foundation; either version 2 of the
+    License, or (at your option) any later version.  See the file
+    COPYING included with this distribution for more information.
+*/
+
+#include "EventSeries.h"
+
+EventSeries
+EventSeries::fromEvents(const EventVector &v)
+{
+    EventSeries s;
+    for (const auto &e: v) {
+        s.add(e);
+    }
+    return s;
+}
+
+bool
+EventSeries::isEmpty() const
+{
+    return m_events.empty();
+}
+
+int
+EventSeries::count() const
+{
+    if (m_events.size() > INT_MAX) {
+        throw std::logic_error("too many events");
+    }
+    return int(m_events.size());
+}
+
+void
+EventSeries::add(const Event &p)
+{
+    bool isUnique = true;
+
+    auto pitr = lower_bound(m_events.begin(), m_events.end(), p);
+    if (pitr != m_events.end() && *pitr == p) {
+        isUnique = false;
+    }
+    m_events.insert(pitr, p);
+
+    if (!p.hasDuration() && p.getFrame() > m_finalDurationlessEventFrame) {
+        m_finalDurationlessEventFrame = p.getFrame();
+    }
+    
+    if (p.hasDuration() && isUnique) {
+
+        const sv_frame_t frame = p.getFrame();
+        const sv_frame_t endFrame = p.getFrame() + p.getDuration();
+
+        createSeam(frame);
+        createSeam(endFrame);
+
+        // These calls must both succeed after calling createSeam above
+        const auto i0 = m_seams.find(frame);
+        const auto i1 = m_seams.find(endFrame);
+
+        for (auto i = i0; i != i1; ++i) {
+            if (i == m_seams.end()) {
+                SVCERR << "ERROR: EventSeries::add: "
+                       << "reached end of seam map"
+                       << endl;
+                break;
+            }
+            i->second.push_back(p);
+        }
+    }
+
+#ifdef DEBUG_EVENT_SERIES
+    std::cerr << "after add:" << std::endl;
+    dumpEvents();
+    dumpSeams();
+#endif
+}
+
+void
+EventSeries::remove(const Event &p)
+{
+    // If we are removing the last (unique) example of an event,
+    // then we also need to remove it from the seam map. If this
+    // is only one of multiple identical events, then we don't.
+    bool isUnique = true;
+        
+    auto pitr = lower_bound(m_events.begin(), m_events.end(), p);
+    if (pitr == m_events.end() || *pitr != p) {
+        // we don't know this event
+        return;
+    } else {
+        auto nitr = pitr;
+        ++nitr;
+        if (nitr != m_events.end() && *nitr == p) {
+            isUnique = false;
+        }
+    }
+
+    m_events.erase(pitr);
+
+    if (!p.hasDuration() && isUnique &&
+        p.getFrame() == m_finalDurationlessEventFrame) {
+        m_finalDurationlessEventFrame = 0;
+        for (auto ritr = m_events.rbegin(); ritr != m_events.rend(); ++ritr) {
+            if (!ritr->hasDuration()) {
+                m_finalDurationlessEventFrame = ritr->getFrame();
+                break;
+            }
+        }
+    }
+    
+    if (p.hasDuration() && isUnique) {
+            
+        const sv_frame_t frame = p.getFrame();
+        const sv_frame_t endFrame = p.getFrame() + p.getDuration();
+
+        const auto i0 = m_seams.find(frame);
+        const auto i1 = m_seams.find(endFrame);
+
+#ifdef DEBUG_EVENT_SERIES
+        // This should be impossible if we found p in m_events above
+        if (i0 == m_seams.end() || i1 == m_seams.end()) {
+            SVCERR << "ERROR: EventSeries::remove: either frame " << frame
+                   << " or endFrame " << endFrame
+                   << " for event not found in seam map: event is "
+                   << p.toXmlString() << endl;
+        }
+#endif
+
+        // Remove any and all instances of p from the seam map; we
+        // are only supposed to get here if we are removing the
+        // last instance of p from the series anyway
+            
+        for (auto i = i0; i != i1; ++i) {
+            if (i == m_seams.end()) {
+                // This can happen only if we have a negative
+                // duration, which Event forbids
+                throw std::logic_error("unexpectedly reached end of map");
+            }
+            for (size_t j = 0; j < i->second.size(); ) {
+                if (i->second[j] == p) {
+                    i->second.erase(i->second.begin() + j);
+                } else {
+                    ++j;
+                }
+            }
+        }
+
+        // Tidy up by removing any entries that are now identical
+        // to their predecessors
+            
+        std::vector<sv_frame_t> redundant;
+
+        auto pitr = m_seams.end();
+        if (i0 != m_seams.begin()) {
+            pitr = i0;
+            --pitr;
+        }
+
+        for (auto i = i0; i != m_seams.end(); ++i) {
+            if (pitr != m_seams.end() &&
+                seamsEqual(i->second, pitr->second)) {
+                redundant.push_back(i->first);
+            }
+            pitr = i;
+            if (i == i1) {
+                break;
+            }
+        }
+
+        for (sv_frame_t f: redundant) {
+            m_seams.erase(f);
+        }
+
+        // And remove any empty seams from the start of the map
+            
+        while (m_seams.begin() != m_seams.end() &&
+               m_seams.begin()->second.empty()) {
+            m_seams.erase(m_seams.begin());
+        }
+    }
+
+#ifdef DEBUG_EVENT_SERIES
+    std::cerr << "after remove:" << std::endl;
+    dumpEvents();
+    dumpSeams();
+#endif
+}
+
+bool
+EventSeries::contains(const Event &p) const
+{
+    return binary_search(m_events.begin(), m_events.end(), p);
+}
+
+void
+EventSeries::clear()
+{
+    m_events.clear();
+    m_seams.clear();
+    m_finalDurationlessEventFrame = 0;
+}
+
+sv_frame_t
+EventSeries::getStartFrame() const
+{
+    if (m_events.empty()) return 0;
+    return m_events.begin()->getFrame();
+}
+
+sv_frame_t
+EventSeries::getEndFrame() const
+{
+    sv_frame_t latest = 0;
+
+    if (m_events.empty()) return latest;
+    
+    latest = m_finalDurationlessEventFrame;
+
+    if (m_seams.empty()) return latest;
+    
+    sv_frame_t lastSeam = m_seams.rbegin()->first;
+    if (lastSeam > latest) {
+        latest = lastSeam;
+    }
+
+    return latest;
+}
+
+EventVector
+EventSeries::getEventsSpanning(sv_frame_t frame,
+                               sv_frame_t duration) const
+{
+    EventVector span;
+    
+    const sv_frame_t start = frame;
+    const sv_frame_t end = frame + duration;
+        
+    // first find any zero-duration events
+
+    auto pitr = lower_bound(m_events.begin(), m_events.end(),
+                            Event(start));
+    while (pitr != m_events.end() && pitr->getFrame() < end) {
+        if (!pitr->hasDuration()) {
+            span.push_back(*pitr);
+        }
+        ++pitr;
+    }
+
+    // now any non-zero-duration ones from the seam map
+
+    std::set<Event> found;
+    auto sitr = m_seams.lower_bound(start);
+    if (sitr == m_seams.end() || sitr->first > start) {
+        if (sitr != m_seams.begin()) {
+            --sitr;
+        }                
+    }
+    while (sitr != m_seams.end() && sitr->first < end) {
+        for (const auto &p: sitr->second) {
+            found.insert(p);
+        }
+        ++sitr;
+    }
+    for (const auto &p: found) {
+        auto pitr = lower_bound(m_events.begin(), m_events.end(), p);
+        while (pitr != m_events.end() && *pitr == p) {
+            span.push_back(p);
+            ++pitr;
+        }
+    }
+            
+    return span;
+}
+
+EventVector
+EventSeries::getEventsWithin(sv_frame_t frame,
+                             sv_frame_t duration,
+                             int overspill) const
+{
+    EventVector span;
+    
+    const sv_frame_t start = frame;
+    const sv_frame_t end = frame + duration;
+
+    // because we don't need to "look back" at events that end within
+    // but started without, we can do this entirely from m_events.
+    // The core operation is very simple, it's just overspill that
+    // complicates it.
+
+    Events::const_iterator reference = 
+        lower_bound(m_events.begin(), m_events.end(), Event(start));
+
+    Events::const_iterator first = reference;
+    for (int i = 0; i < overspill; ++i) {
+        if (first == m_events.begin()) break;
+        --first;
+    }
+    for (int i = 0; i < overspill; ++i) {
+        if (first == reference) break;
+        span.push_back(*first);
+        ++first;
+    }
+
+    Events::const_iterator pitr = reference;
+    Events::const_iterator last = reference;
+
+    while (pitr != m_events.end() && pitr->getFrame() < end) {
+        if (!pitr->hasDuration() ||
+            (pitr->getFrame() + pitr->getDuration() <= end)) {
+            span.push_back(*pitr);
+            last = pitr;
+            ++last;
+        }
+        ++pitr;
+    }
+
+    for (int i = 0; i < overspill; ++i) {
+        if (last == m_events.end()) break;
+        span.push_back(*last);
+        ++last;
+    }
+    
+    return span;
+}
+
+EventVector
+EventSeries::getEventsStartingWithin(sv_frame_t frame,
+                                     sv_frame_t duration) const
+{
+    EventVector span;
+    
+    const sv_frame_t start = frame;
+    const sv_frame_t end = frame + duration;
+
+    // because we don't need to "look back" at events that started
+    // earlier than the start of the given range, we can do this
+    // entirely from m_events
+
+    auto pitr = lower_bound(m_events.begin(), m_events.end(),
+                            Event(start));
+    while (pitr != m_events.end() && pitr->getFrame() < end) {
+        span.push_back(*pitr);
+        ++pitr;
+    }
+            
+    return span;
+}
+
+EventVector
+EventSeries::getEventsCovering(sv_frame_t frame) const
+{
+    EventVector cover;
+
+    // first find any zero-duration events
+
+    auto pitr = lower_bound(m_events.begin(), m_events.end(),
+                            Event(frame));
+    while (pitr != m_events.end() && pitr->getFrame() == frame) {
+        if (!pitr->hasDuration()) {
+            cover.push_back(*pitr);
+        }
+        ++pitr;
+    }
+        
+    // now any non-zero-duration ones from the seam map
+        
+    std::set<Event> found;
+    auto sitr = m_seams.lower_bound(frame);
+    if (sitr == m_seams.end() || sitr->first > frame) {
+        if (sitr != m_seams.begin()) {
+            --sitr;
+        }                
+    }
+    if (sitr != m_seams.end() && sitr->first <= frame) {
+        for (const auto &p: sitr->second) {
+            found.insert(p);
+        }
+        ++sitr;
+    }
+    for (const auto &p: found) {
+        auto pitr = lower_bound(m_events.begin(), m_events.end(), p);
+        while (pitr != m_events.end() && *pitr == p) {
+            cover.push_back(p);
+            ++pitr;
+        }
+    }
+        
+    return cover;
+}
+
+EventVector
+EventSeries::getAllEvents() const
+{
+    return m_events;
+}
+
+bool
+EventSeries::getEventPreceding(const Event &e, Event &preceding) const
+{
+    auto pitr = lower_bound(m_events.begin(), m_events.end(), e);
+    if (pitr == m_events.end() || *pitr != e) {
+        return false;
+    }
+    if (pitr == m_events.begin()) {
+        return false;
+    }
+    --pitr;
+    preceding = *pitr;
+    return true;
+}
+
+bool
+EventSeries::getEventFollowing(const Event &e, Event &following) const
+{
+    auto pitr = lower_bound(m_events.begin(), m_events.end(), e);
+    if (pitr == m_events.end() || *pitr != e) {
+        return false;
+    }
+    while (*pitr == e) {
+        ++pitr;
+        if (pitr == m_events.end()) {
+            return false;
+        }
+    }
+    following = *pitr;
+    return true;
+}
+
+bool
+EventSeries::getNearestEventMatching(sv_frame_t startSearchAt,
+                                     std::function<bool(const Event &)> predicate,
+                                     Direction direction,
+                                     Event &found) const
+{
+    auto pitr = lower_bound(m_events.begin(), m_events.end(),
+                            Event(startSearchAt));
+
+    while (true) {
+
+        if (direction == Backward) {
+            if (pitr == m_events.begin()) {
+                break;
+            } else {
+                --pitr;
+            }
+        } else {
+            if (pitr == m_events.end()) {
+                break;
+            }
+        }
+
+        const Event &e = *pitr;
+        if (predicate(e)) {
+            found = e;
+            return true;
+        }
+
+        if (direction == Forward) {
+            ++pitr;
+        }
+    }
+
+    return false;
+}
+
+Event
+EventSeries::getEventByIndex(int index) const
+{
+    if (index < 0 || index >= count()) {
+        throw std::logic_error("index out of range");
+    }
+    return m_events[index];
+}
+
+int
+EventSeries::getIndexForEvent(const Event &e) const
+{
+    auto pitr = lower_bound(m_events.begin(), m_events.end(), e);
+    auto d = distance(m_events.begin(), pitr);
+    if (d < 0 || d > INT_MAX) return 0;
+    return int(d);
+}
+
+void
+EventSeries::toXml(QTextStream &out,
+                   QString indent,
+                   QString extraAttributes) const
+{
+    toXml(out, indent, extraAttributes, Event::ExportNameOptions());
+}
+
+void
+EventSeries::toXml(QTextStream &out,
+                   QString indent,
+                   QString extraAttributes,
+                   Event::ExportNameOptions options) const
+{
+    out << indent << QString("<dataset id=\"%1\" %2>\n")
+        .arg(getExportId())
+        .arg(extraAttributes);
+    
+    for (const auto &p: m_events) {
+        p.toXml(out, indent + "  ", "", options);
+    }
+    
+    out << indent << "</dataset>\n";
+}
+
+QString
+EventSeries::toDelimitedDataString(QString delimiter,
+                                   DataExportOptions options,
+                                   sv_frame_t startFrame,
+                                   sv_frame_t duration,
+                                   sv_samplerate_t sampleRate,
+                                   sv_frame_t resolution,
+                                   Event fillEvent) const
+{
+    QString s;
+
+    const sv_frame_t end = startFrame + duration;
+
+    auto pitr = lower_bound(m_events.begin(), m_events.end(),
+                            Event(startFrame));
+            
+    if (!(options & DataExportFillGaps)) {
+        
+        while (pitr != m_events.end() && pitr->getFrame() < end) {
+            s += pitr->toDelimitedDataString(delimiter,
+                                             options,
+                                             sampleRate);
+            s += "\n";
+            ++pitr;
+        }
+
+    } else {
+        
+        // find frame time of first point in range (if any)
+        sv_frame_t first = startFrame;
+        if (pitr != m_events.end()) {
+            first = pitr->getFrame();
+        }
+
+        // project back to first frame time in range according to
+        // resolution.  e.g. if f0 = 2, first = 9, resolution = 4 then
+        // we start at 5 (because 1 is too early and we need to arrive
+        // at 9 to match the first actual point). This method is
+        // stupid but easy to understand:
+        sv_frame_t f = first;
+        while (f >= startFrame + resolution) f -= resolution;
+        
+        // now progress, either writing the next point (if within
+        // distance) or a default fill point
+        while (f < end) {
+            if (pitr != m_events.end() && pitr->getFrame() <= f) {
+                s += pitr->toDelimitedDataString
+                    (delimiter,
+                     options & ~DataExportFillGaps,
+                     sampleRate);
+                ++pitr;
+            } else {
+                s += fillEvent.withFrame(f).toDelimitedDataString
+                    (delimiter,
+                     options & ~DataExportFillGaps,
+                     sampleRate);
+            }
+            s += "\n";
+            f += resolution;
+        }
+    }
+    
+    return s;
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/base/EventSeries.h	Thu May 16 11:07:47 2019 +0100
@@ -0,0 +1,354 @@
+/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
+
+/*
+    Sonic Visualiser
+    An audio file viewer and annotation editor.
+    Centre for Digital Music, Queen Mary, University of London.
+    
+    This program is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public License as
+    published by the Free Software Foundation; either version 2 of the
+    License, or (at your option) any later version.  See the file
+    COPYING included with this distribution for more information.
+*/
+
+#ifndef SV_EVENT_SERIES_H
+#define SV_EVENT_SERIES_H
+
+#include "Event.h"
+#include "XmlExportable.h"
+
+#include <set>
+#include <functional>
+
+//#define DEBUG_EVENT_SERIES 1
+
+/**
+ * Container storing a series of events, with or without durations,
+ * and supporting the ability to query which events are active at a
+ * given frame or within a span of frames.
+ *
+ * To that end, in addition to the series of events, it stores a
+ * series of "seams", which are frame positions at which the set of
+ * simultaneous events changes (i.e. an event of non-zero duration
+ * starts or ends) associated with a set of the events that are active
+ * at or from that position. These are updated when an event is added
+ * or removed.
+ *
+ * This class is highly optimised for inserting events in increasing
+ * order of start frame. Inserting (or deleting) events in the middle
+ * does work, and should be acceptable in interactive use, but it is
+ * very slow in bulk.
+ */
+class EventSeries : public XmlExportable
+{
+public:
+    EventSeries() : m_finalDurationlessEventFrame(0) { }
+    ~EventSeries() =default;
+
+    EventSeries(const EventSeries &) =default;
+    EventSeries &operator=(const EventSeries &) =default;
+    EventSeries &operator=(EventSeries &&) =default;
+    
+    bool operator==(const EventSeries &other) const {
+        return m_events == other.m_events;
+    }
+
+    static EventSeries fromEvents(const EventVector &ee);
+    
+    void clear();
+    void add(const Event &e);
+    void remove(const Event &e);
+    bool contains(const Event &e) const;
+    bool isEmpty() const;
+    int count() const;
+
+    /**
+     * Return the frame of the first event in the series. If there are
+     * no events, return 0.
+     */
+    sv_frame_t getStartFrame() const;
+
+    /**
+     * Return the frame plus duration of the event in the series that
+     * ends last. If there are no events, return 0.
+     */
+    sv_frame_t getEndFrame() const;
+    
+    /**
+     * Retrieve all events any part of which falls within the range in
+     * frames defined by the given frame f and duration d.
+     *
+     * - An event without duration is spanned by the range if its own
+     * frame is greater than or equal to f and less than f + d.
+     * 
+     * - An event with duration is spanned by the range if its start
+     * frame is less than f + d and its start frame plus its duration
+     * is greater than f.
+     * 
+     * Note: Passing a duration of zero is seldom useful here; you
+     * probably want getEventsCovering instead. getEventsSpanning(f,
+     * 0) is not equivalent to getEventsCovering(f). The latter
+     * includes durationless events at f and events starting at f,
+     * both of which are excluded from the former.
+     */
+    EventVector getEventsSpanning(sv_frame_t frame,
+                                  sv_frame_t duration) const;
+
+    /**
+     * Retrieve all events that cover the given frame. An event without
+     * duration covers a frame if its own frame is equal to it. An event
+     * with duration covers a frame if its start frame is less than or
+     * equal to it and its end frame (start + duration) is greater
+     * than it.
+     */
+    EventVector getEventsCovering(sv_frame_t frame) const;
+
+    /**
+     * Retrieve all events falling wholly within the range in frames
+     * defined by the given frame f and duration d.
+     *
+     * - An event without duration is within the range if its own
+     * frame is greater than or equal to f and less than f + d.
+     * 
+     * - An event with duration is within the range if its start frame
+     * is greater than or equal to f and its start frame plus its
+     * duration is less than or equal to f + d.
+     *
+     * If overspill is greater than zero, also include that number of
+     * additional events (where they exist) both before and after the
+     * edges of the range.
+     */
+    EventVector getEventsWithin(sv_frame_t frame,
+                                sv_frame_t duration,
+                                int overspill = 0) const;
+
+    /**
+     * Retrieve all events starting within the range in frames defined
+     * by the given frame f and duration d. An event (regardless of
+     * whether it has duration or not) starts within the range if its
+     * start frame is greater than or equal to f and less than f + d.
+     */
+    EventVector getEventsStartingWithin(sv_frame_t frame,
+                                        sv_frame_t duration) const;
+
+    /**
+     * Retrieve all events starting at exactly the given frame.
+     */
+    EventVector getEventsStartingAt(sv_frame_t frame) const {
+        return getEventsStartingWithin(frame, 1);
+    }
+
+    /**
+     * Retrieve all events, in their natural order.
+     */
+    EventVector getAllEvents() const;
+    
+    /**
+     * If e is in the series and is not the first event in it, set
+     * preceding to the event immediate preceding it according to the
+     * standard event ordering and return true. Otherwise leave
+     * preceding unchanged and return false.
+     *
+     * If there are multiple events identical to e in the series,
+     * assume that the event passed in is the first one (i.e. never
+     * set preceding equal to e).
+     *
+     * It is acceptable for preceding to alias e when this is called.
+     */
+    bool getEventPreceding(const Event &e, Event &preceding) const;
+
+    /**
+     * If e is in the series and is not the final event in it, set
+     * following to the event immediate following it according to the
+     * standard event ordering and return true. Otherwise leave
+     * following unchanged and return false.
+     *
+     * If there are multiple events identical to e in the series,
+     * assume that the event passed in is the last one (i.e. never set
+     * following equal to e).
+     *
+     * It is acceptable for following to alias e when this is called.
+     */
+    bool getEventFollowing(const Event &e, Event &following) const;
+
+    enum Direction {
+        Forward,
+        Backward
+    };
+
+    /**
+     * Return the first event for which the given predicate returns
+     * true, searching events with start frames increasingly far from
+     * the given frame in the given direction. If the direction is
+     * Forward then the search includes events starting at the given
+     * frame, otherwise it does not.
+     */
+    bool getNearestEventMatching(sv_frame_t startSearchAt,
+                                 std::function<bool(const Event &)> predicate,
+                                 Direction direction,
+                                 Event &found) const;
+    
+    /**
+     * Return the event at the given numerical index in the series,
+     * where 0 = the first event and count()-1 = the last.
+     */
+    Event getEventByIndex(int index) const;
+
+    /**
+     * Return the index of the first event in the series that does not
+     * compare inferior to the given event. If there is no such event,
+     * return count().
+     */
+    int getIndexForEvent(const Event &e) const;
+
+    /**
+     * Emit to XML as a dataset element.
+     */
+    void toXml(QTextStream &out,
+               QString indent,
+               QString extraAttributes) const override;
+
+    /**
+     * Emit to XML as a dataset element.
+     */
+    void toXml(QTextStream &out,
+               QString indent,
+               QString extraAttributes,
+               Event::ExportNameOptions) const;
+
+    /**
+     * Emit events starting within the given range to a delimited
+     * (e.g. comma-separated) data format.
+     */
+    QString toDelimitedDataString(QString delimiter,
+                                  DataExportOptions options,
+                                  sv_frame_t startFrame,
+                                  sv_frame_t duration,
+                                  sv_samplerate_t sampleRate,
+                                  sv_frame_t resolution,
+                                  Event fillEvent) const;
+    
+private:
+    /**
+     * This vector contains all events in the series, in the normal
+     * sort order. For backward compatibility we must support series
+     * containing multiple instances of identical events, so
+     * consecutive events in this vector will not always be distinct.
+     * The vector is used in preference to a multiset or map<Event,
+     * int> in order to allow indexing by "row number" as well as by
+     * properties such as frame.
+     * 
+     * Because events are immutable, we do not have to worry about the
+     * order changing once an event is inserted - we only add or
+     * delete them.
+     */
+    typedef std::vector<Event> Events;
+    Events m_events;
+    
+    /**
+     * The FrameEventMap maps from frame number to a set of events. In
+     * the seam map this is used to represent the events that are
+     * active at that frame, either because they begin at that frame
+     * or because they are continuing from an earlier frame. There is
+     * an entry here for each frame at which an event starts or ends,
+     * with the event appearing in all entries from its start time
+     * onward and disappearing again at its end frame.
+     *
+     * Only events with duration appear in this map; point events
+     * appear only in m_events. Note that unlike m_events, we only
+     * store one instance of each event here, even if we hold many -
+     * we refer back to m_events when we need to know how many
+     * identical copies of a given event we have.
+     */
+    typedef std::map<sv_frame_t, std::vector<Event>> FrameEventMap;
+    FrameEventMap m_seams;
+
+    /**
+     * The frame of the last durationless event we have in the series.
+     * This is to support a fast-ish getEndFrame(): we can easily keep
+     * this up-to-date when events are added or removed, and we can
+     * easily find the end frame of the last with-duration event from
+     * the seam map, but it's not so easy to continuously update an
+     * overall end frame or to find the last frame of all events
+     * without this.
+     */
+    sv_frame_t m_finalDurationlessEventFrame;
+    
+    /** Create a seam at the given frame, copying from the prior seam
+     *  if there is one. If a seam already exists at the given frame,
+     *  leave it untouched.
+     */
+    void createSeam(sv_frame_t frame) {
+        auto itr = m_seams.lower_bound(frame);
+        if (itr == m_seams.end() || itr->first > frame) {
+            if (itr != m_seams.begin()) {
+                --itr;
+            }
+        }
+        if (itr == m_seams.end()) {
+            m_seams[frame] = {};
+        } else if (itr->first < frame) {
+            m_seams[frame] = itr->second;
+        } else if (itr->first > frame) { // itr must be begin()
+            m_seams[frame] = {};
+        }
+    }
+
+    bool seamsEqual(const std::vector<Event> &s1,
+                    const std::vector<Event> &s2) const {
+        
+        if (s1.size() != s2.size()) {
+            return false;
+        }
+
+        // precondition: no event appears more than once in s1 or more
+        // than once in s2
+
+#ifdef DEBUG_EVENT_SERIES
+        for (int i = 0; in_range_for(s1, i); ++i) {
+            for (int j = i + 1; in_range_for(s1, j); ++j) {
+                if (s1[i] == s1[j] || s2[i] == s2[j]) {
+                    throw std::runtime_error
+                        ("debug error: duplicate event in s1 or s2");
+                }
+            }
+        }
+#endif
+
+        std::set<Event> ee;
+        for (const auto &e: s1) {
+            ee.insert(e);
+        }
+        for (const auto &e: s2) {
+            if (ee.find(e) == ee.end()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+#ifdef DEBUG_EVENT_SERIES
+    void dumpEvents() const {
+        std::cerr << "EVENTS (" << m_events.size() << ") [" << std::endl;
+        for (const auto &i: m_events) {
+            std::cerr << "  " << i.toXmlString();
+        }
+        std::cerr << "]" << std::endl;
+    }
+    
+    void dumpSeams() const {
+        std::cerr << "SEAMS (" << m_seams.size() << ") [" << std::endl;
+        for (const auto &s: m_seams) {
+            std::cerr << "  " << s.first << " -> {" << std::endl;
+            for (const auto &p: s.second) {
+                std::cerr << p.toXmlString("    ");
+            }
+            std::cerr << "  }" << std::endl;
+        }
+        std::cerr << "]" << std::endl;
+    }
+#endif
+};
+
+#endif
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/base/Extents.h	Thu May 16 11:07:47 2019 +0100
@@ -0,0 +1,92 @@
+/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
+
+/*
+    Sonic Visualiser
+    An audio file viewer and annotation editor.
+    Centre for Digital Music, Queen Mary, University of London.
+    
+    This program is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public License as
+    published by the Free Software Foundation; either version 2 of the
+    License, or (at your option) any later version.  See the file
+    COPYING included with this distribution for more information.
+*/
+
+#ifndef SV_EXTENTS_H
+#define SV_EXTENTS_H
+
+#include <vector>
+
+/**
+ * Maintain a min and max value, and update them when supplied a new
+ * data point.
+ */
+template <typename T>
+class Extents
+{
+public:
+    Extents() : m_min(T()), m_max(T()) { }
+    Extents(T min, T max) : m_min(min), m_max(max) { }
+    
+    bool operator==(const Extents &r) {
+        return r.m_min == m_min && r.m_max == m_max;
+    }
+    bool operator!=(const Extents &r) {
+        return !(*this == r);
+    }
+    
+    bool isSet() const {
+        return (m_min != T() || m_max != T());
+    }
+    void set(T min, T max) {
+        m_min = min;
+        m_max = max;
+        if (m_max < m_min) m_max = m_min;
+    }
+    void reset() {
+        m_min = T();
+        m_max = T();
+    }
+
+    bool sample(T f) {
+        bool changed = false;
+        if (isSet()) {
+            if (f < m_min) { m_min = f; changed = true; }
+            if (f > m_max) { m_max = f; changed = true; }
+        } else {
+            m_max = m_min = f;
+            changed = true;
+        }
+        return changed;
+    }
+    bool sample(const std::vector<T> &ff) {
+        bool changed = false;
+        for (auto f: ff) {
+            if (sample(f)) {
+                changed = true;
+            }
+        }
+        return changed;
+    }
+    bool sample(const Extents &r) {
+        bool changed = false;
+        if (isSet()) {
+            if (r.m_min < m_min) { m_min = r.m_min; changed = true; }
+            if (r.m_max > m_max) { m_max = r.m_max; changed = true; }
+        } else {
+            m_min = r.m_min;
+            m_max = r.m_max;
+            changed = true;
+        }
+        return changed;
+    }            
+
+    T getMin() const { return m_min; }
+    T getMax() const { return m_max; }
+
+private:
+    T m_min;
+    T m_max;
+};
+
+#endif
--- a/base/HelperExecPath.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/base/HelperExecPath.cpp	Thu May 16 11:07:47 2019 +0100
@@ -58,9 +58,17 @@
 QStringList
 HelperExecPath::getHelperDirPaths()
 {
+    // Helpers are expected to exist either in the same directory as
+    // this executable was found, or in either a subdirectory called
+    // helpers, or on the Mac only, a sibling called Resources.
+
     QStringList dirs;
     QString myDir = QCoreApplication::applicationDirPath();
+#ifdef Q_OS_MAC
+    dirs.push_back(myDir + "/../Resources");
+#else
     dirs.push_back(myDir + "/helpers");
+#endif
     dirs.push_back(myDir);
     return dirs;
 }
@@ -76,9 +84,6 @@
 QList<HelperExecPath::HelperExec>
 HelperExecPath::search(QString basename, QStringList &candidates)
 {
-    // Helpers are expected to exist either in the same directory as
-    // this executable was found, or in a subdirectory called helpers.
-
     QString extension = "";
 #ifdef _WIN32
     extension = ".exe";
--- a/base/MagnitudeRange.h	Tue May 07 15:54:15 2019 +0100
+++ b/base/MagnitudeRange.h	Thu May 16 11:07:47 2019 +0100
@@ -16,68 +16,8 @@
 #ifndef MAGNITUDE_RANGE_H
 #define MAGNITUDE_RANGE_H
 
-#include <vector>
+#include "Extents.h"
 
-/**
- * Maintain a min and max value, and update them when supplied a new
- * data point.
- */
-class MagnitudeRange
-{
-public:
-    MagnitudeRange() : m_min(0), m_max(0) { }
-    MagnitudeRange(float min, float max) : m_min(min), m_max(max) { }
-    
-    bool operator==(const MagnitudeRange &r) {
-        return r.m_min == m_min && r.m_max == m_max;
-    }
-    bool operator!=(const MagnitudeRange &r) {
-        return !(*this == r);
-    }
-    
-    bool isSet() const { return (m_min != 0.f || m_max != 0.f); }
-    void set(float min, float max) {
-        m_min = min;
-        m_max = max;
-        if (m_max < m_min) m_max = m_min;
-    }
-    bool sample(float f) {
-        bool changed = false;
-        if (isSet()) {
-            if (f < m_min) { m_min = f; changed = true; }
-            if (f > m_max) { m_max = f; changed = true; }
-        } else {
-            m_max = m_min = f;
-            changed = true;
-        }
-        return changed;
-    }
-    bool sample(const std::vector<float> &ff) {
-        bool changed = false;
-        for (auto f: ff) {
-            if (sample(f)) {
-                changed = true;
-            }
-        }
-        return changed;
-    }
-    bool sample(const MagnitudeRange &r) {
-        bool changed = false;
-        if (isSet()) {
-            if (r.m_min < m_min) { m_min = r.m_min; changed = true; }
-            if (r.m_max > m_max) { m_max = r.m_max; changed = true; }
-        } else {
-            m_min = r.m_min;
-            m_max = r.m_max;
-            changed = true;
-        }
-        return changed;
-    }            
-    float getMin() const { return m_min; }
-    float getMax() const { return m_max; }
-private:
-    float m_min;
-    float m_max;
-};
+typedef Extents<float> MagnitudeRange;
 
 #endif
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/base/NoteData.h	Thu May 16 11:07:47 2019 +0100
@@ -0,0 +1,50 @@
+/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
+
+/*
+    Sonic Visualiser
+    An audio file viewer and annotation editor.
+    Centre for Digital Music, Queen Mary, University of London.
+    
+    This program is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public License as
+    published by the Free Software Foundation; either version 2 of the
+    License, or (at your option) any later version.  See the file
+    COPYING included with this distribution for more information.
+*/
+
+#ifndef SV_NOTE_DATA_H
+#define SV_NOTE_DATA_H
+
+#include <vector>
+
+#include "Pitch.h"
+
+/**
+ * Note record used when constructing synthetic events for sonification.
+ */
+struct NoteData
+{
+    NoteData(sv_frame_t _start, sv_frame_t _dur, int _mp, int _vel) :
+        start(_start), duration(_dur), midiPitch(_mp), frequency(0),
+        isMidiPitchQuantized(true), velocity(_vel), channel(0) { };
+            
+    sv_frame_t start;       // audio sample frame
+    sv_frame_t duration;    // in audio sample frames
+    int midiPitch;   // 0-127
+    float frequency; // Hz, to be used if isMidiPitchQuantized false
+    bool isMidiPitchQuantized;
+    int velocity;    // MIDI-style 0-127
+    int channel;     // MIDI 0-15
+
+    float getFrequency() const {
+        if (isMidiPitchQuantized) {
+            return float(Pitch::getFrequencyForPitch(midiPitch));
+        } else {
+            return frequency;
+        }
+    }
+};
+
+typedef std::vector<NoteData> NoteList;
+
+#endif
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/base/NoteExportable.h	Thu May 16 11:07:47 2019 +0100
@@ -0,0 +1,42 @@
+/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
+
+/*
+    Sonic Visualiser
+    An audio file viewer and annotation editor.
+    Centre for Digital Music, Queen Mary, University of London.
+    
+    This program is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public License as
+    published by the Free Software Foundation; either version 2 of the
+    License, or (at your option) any later version.  See the file
+    COPYING included with this distribution for more information.
+*/
+
+#ifndef SV_NOTE_EXPORTABLE_H
+#define SV_NOTE_EXPORTABLE_H
+
+#include "NoteData.h"
+
+class NoteExportable
+{
+public:
+    /**
+     * Get all notes in the exportable object.
+     */
+    virtual NoteList getNotes() const = 0;
+
+    /**
+     * Get notes that are active at the given frame, i.e. that start
+     * before or at this frame and have not ended by it.
+     */
+    virtual NoteList getNotesActiveAt(sv_frame_t frame) const = 0;
+
+    /**
+     * Get notes that start within the range in frames defined by the
+     * given start frame and duration.
+     */
+    virtual NoteList getNotesStartingWithin(sv_frame_t startFrame,
+                                            sv_frame_t duration) const = 0;
+};
+
+#endif
--- a/base/RecentFiles.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/base/RecentFiles.cpp	Thu May 16 11:07:47 2019 +0100
@@ -20,6 +20,7 @@
 #include <QFileInfo>
 #include <QSettings>
 #include <QRegExp>
+#include <QMutexLocker>
 
 RecentFiles::RecentFiles(QString settingsGroup, int maxCount) :
     m_settingsGroup(settingsGroup),
@@ -36,16 +37,26 @@
 void
 RecentFiles::read()
 {
-    m_names.clear();
+    // Private method - called only from constructor - no mutex lock required
+    
+    m_entries.clear();
     QSettings settings;
     settings.beginGroup(m_settingsGroup);
 
     for (int i = 0; i < 100; ++i) {
-        QString key = QString("recent-%1").arg(i);
-        QString name = settings.value(key, "").toString();
-        if (name == "") break;
-        if (i < m_maxCount) m_names.push_back(name);
-        else settings.setValue(key, "");
+
+        QString idKey = QString("recent-%1").arg(i);
+        QString identifier = settings.value(idKey, "").toString();
+        if (identifier == "") break;
+
+        QString labelKey = QString("recent-%1-label").arg(i);
+        QString label = settings.value(labelKey, "").toString();
+        
+        if (i < m_maxCount) m_entries.push_back({ identifier, label });
+        else {
+            settings.setValue(idKey, "");
+            settings.setValue(labelKey, "");
+        }
     }
 
     settings.endGroup();
@@ -54,14 +65,22 @@
 void
 RecentFiles::write()
 {
+    // Private method - must be serialised at call site
+    
     QSettings settings;
     settings.beginGroup(m_settingsGroup);
 
     for (int i = 0; i < m_maxCount; ++i) {
-        QString key = QString("recent-%1").arg(i);
-        QString name = "";
-        if (i < (int)m_names.size()) name = m_names[i];
-        settings.setValue(key, name);
+        QString idKey = QString("recent-%1").arg(i);
+        QString labelKey = QString("recent-%1-label").arg(i);
+        QString identifier;
+        QString label;
+        if (in_range_for(m_entries, i)) {
+            identifier = m_entries[i].first;
+            label = m_entries[i].second;
+        }
+        settings.setValue(idKey, identifier);
+        settings.setValue(labelKey, label);
     }
 
     settings.endGroup();
@@ -70,67 +89,92 @@
 void
 RecentFiles::truncateAndWrite()
 {
-    while (int(m_names.size()) > m_maxCount) {
-        m_names.pop_back();
+    // Private method - must be serialised at call site
+    
+    while (int(m_entries.size()) > m_maxCount) {
+        m_entries.pop_back();
     }
     write();
 }
 
 std::vector<QString>
-RecentFiles::getRecent() const
+RecentFiles::getRecentIdentifiers() const
 {
-    std::vector<QString> names;
+    QMutexLocker locker(&m_mutex);
+
+    std::vector<QString> identifiers;
     for (int i = 0; i < m_maxCount; ++i) {
-        if (i < (int)m_names.size()) {
-            names.push_back(m_names[i]);
+        if (i < (int)m_entries.size()) {
+            identifiers.push_back(m_entries[i].first);
         }
     }
-    return names;
+    
+    return identifiers;
+}
+
+std::vector<std::pair<QString, QString>>
+RecentFiles::getRecentEntries() const
+{
+    QMutexLocker locker(&m_mutex);
+
+    std::vector<std::pair<QString, QString>> entries;
+    for (int i = 0; i < m_maxCount; ++i) {
+        if (i < (int)m_entries.size()) {
+            entries.push_back(m_entries[i]);
+        }
+    }
+    
+    return entries;
 }
 
 void
-RecentFiles::add(QString name)
+RecentFiles::add(QString identifier, QString label)
 {
-    bool have = false;
-    for (int i = 0; i < int(m_names.size()); ++i) {
-        if (m_names[i] == name) {
-            have = true;
-            break;
+    {
+        QMutexLocker locker(&m_mutex);
+
+        bool have = false;
+        for (int i = 0; i < int(m_entries.size()); ++i) {
+            if (m_entries[i].first == identifier) {
+                have = true;
+                break;
+            }
         }
+    
+        if (!have) {
+            m_entries.push_front({ identifier, label });
+        } else {
+            std::deque<std::pair<QString, QString>> newEntries;
+            newEntries.push_back({ identifier, label });
+            for (int i = 0; in_range_for(m_entries, i); ++i) {
+                if (m_entries[i].first == identifier) continue;
+                newEntries.push_back(m_entries[i]);
+            }
+            m_entries = newEntries;
+        }
+
+        truncateAndWrite();
     }
     
-    if (!have) {
-        m_names.push_front(name);
-    } else {
-        std::deque<QString> newnames;
-        newnames.push_back(name);
-        for (int i = 0; i < int(m_names.size()); ++i) {
-            if (m_names[i] == name) continue;
-            newnames.push_back(m_names[i]);
-        }
-        m_names = newnames;
-    }
-
-    truncateAndWrite();
     emit recentChanged();
 }
 
 void
-RecentFiles::addFile(QString name)
+RecentFiles::addFile(QString filepath, QString label)
 {
     static QRegExp schemeRE("^[a-zA-Z]{2,5}://");
     static QRegExp tempRE("[\\/][Tt]e?mp[\\/]");
-    if (schemeRE.indexIn(name) == 0) {
-        add(name);
+    if (schemeRE.indexIn(filepath) == 0) {
+        add(filepath, label);
     } else {
-        QString absPath = QFileInfo(name).absoluteFilePath();
+        QString absPath = QFileInfo(filepath).absoluteFilePath();
         if (tempRE.indexIn(absPath) != -1) {
             Preferences *prefs = Preferences::getInstance();
             if (prefs && !prefs->getOmitTempsFromRecentFiles()) {
-                add(absPath);
+                add(absPath, label);
             }
         } else {
-            add(absPath);
+            add(absPath, label);
         }
     }
 }
--- a/base/RecentFiles.h	Tue May 07 15:54:15 2019 +0100
+++ b/base/RecentFiles.h	Thu May 16 11:07:47 2019 +0100
@@ -18,15 +18,21 @@
 
 #include <QObject>
 #include <QString>
+#include <QMutex>
 #include <vector>
 #include <deque>
 
 /**
- * RecentFiles manages a list of the names of recently-used objects,
- * saving and restoring that list via QSettings.  The names do not
- * actually have to refer to files.
+ * RecentFiles manages a list of recently-used identifier strings,
+ * saving and restoring that list via QSettings.  The identifiers do
+ * not actually have to refer to files.
+ *
+ * Each entry must have a non-empty identifier, which is typically a
+ * filename, path, URI, or internal id, and may optionally also have a
+ * label, which is typically a user-visible convenience.
+ *
+ * RecentFiles is thread-safe - all access is serialised.
  */
-
 class RecentFiles : public QObject
 {
     Q_OBJECT
@@ -35,42 +41,81 @@
     /**
      * Construct a RecentFiles object that saves and restores in the
      * given QSettings group and truncates when the given count of
-     * strings is reached.
+     * identifiers is reached.
      */
-    RecentFiles(QString settingsGroup = "RecentFiles", int maxCount = 10);
+    RecentFiles(QString settingsGroup = "RecentFiles",
+                int maxCount = 10);
 
     virtual ~RecentFiles();
 
-    QString getSettingsGroup() const { return m_settingsGroup; }
-
-    int getMaxCount() const { return m_maxCount; }
-
-    std::vector<QString> getRecent() const;
+    /**
+     * Return the settingsGroup as passed to the constructor.
+     */
+    QString getSettingsGroup() const {
+        return m_settingsGroup;
+    }
 
     /**
-     * Add a name that should be treated as a literal string.
+     * Return the maxCount as passed to the constructor.
      */
-    void add(QString name);
+    int getMaxCount() const {
+        return m_maxCount;
+    }
+
+    /**
+     * Return the list of recent identifiers, without labels.
+     */
+    std::vector<QString> getRecentIdentifiers() const;
+
+    /**
+     * Return the list of recent identifiers, without labels. This is
+     * an alias for getRecentIdentifiers included for backward
+     * compatibility.
+     */
+    std::vector<QString> getRecent() const {
+        return getRecentIdentifiers();
+    }
+
+    /**
+     * Return the list of recent identifiers, with labels. Each
+     * returned entry is a pair of identifier and label in that order.
+     */
+    std::vector<std::pair<QString, QString>> getRecentEntries() const;
     
     /**
-     * Add a name that is known to be either a file path or a URL.  If
-     * it looks like a URL, add it literally; otherwise treat it as a
-     * file path and canonicalise it appropriately.  Also takes into
-     * account the user preference for whether to include temporary
-     * files in the recent files menu: the file will not be added if
-     * the preference is set and the file appears to be a temporary
-     * one.
+     * Add a literal identifier, optionally with a label.
+     *
+     * If the identifier already exists in the recent entries list, it
+     * is moved to the front of the list and its label is replaced
+     * with the given one.
      */
-    void addFile(QString name);
+    void add(QString identifier, QString label = "");
+    
+    /**
+     * Add a name that is known to be either a file path or a URL,
+     * optionally with a label.  If it looks like a URL, add it
+     * literally; otherwise treat it as a file path and canonicalise
+     * it appropriately.  Also take into account the user preference
+     * for whether to include temporary files in the recent files
+     * menu: the file will not be added if the preference is set and
+     * the file appears to be a temporary one.
+     *
+     * If the identifier derived from the file path already exists in
+     * the recent entries list, it is moved to the front of the list
+     * and its label is replaced with the given one.
+     */
+    void addFile(QString filepath, QString label = "");
 
 signals:
     void recentChanged();
 
-protected:
-    QString m_settingsGroup;
-    int m_maxCount;
+private:
+    mutable QMutex m_mutex;
 
-    std::deque<QString> m_names;
+    const QString m_settingsGroup;
+    const int m_maxCount;
+
+    std::deque<std::pair<QString, QString>> m_entries; // identifier, label
 
     void read();
     void write();
--- a/base/Selection.h	Tue May 07 15:54:15 2019 +0100
+++ b/base/Selection.h	Thu May 16 11:07:47 2019 +0100
@@ -49,6 +49,7 @@
     bool isEmpty() const;
     sv_frame_t getStartFrame() const;
     sv_frame_t getEndFrame() const;
+    sv_frame_t getDuration() const { return getEndFrame() - getStartFrame(); }
     bool contains(sv_frame_t frame) const;
 
     bool operator<(const Selection &) const;
--- a/base/XmlExportable.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/base/XmlExportable.cpp	Thu May 16 11:07:47 2019 +0100
@@ -68,19 +68,18 @@
 }
 
 int
-XmlExportable::getObjectExportId(const void * object)
+XmlExportable::getExportId() const
 {
-    static QMutex mutex;
-    QMutexLocker locker(&mutex);
-
-    static std::map<const void *, int> idMap;
-    static int maxId = 0;
-    
-    if (idMap.find(object) == idMap.end()) {
-        idMap[object] = maxId++;
+    if (m_exportId == -1) {
+        static QMutex mutex;
+        static int nextId = 0;
+        QMutexLocker locker(&mutex);
+        if (m_exportId == -1) {
+            m_exportId = nextId;
+            ++nextId;
+        }
     }
-
-    return idMap[object];
+    return m_exportId;
 }
 
 
--- a/base/XmlExportable.h	Tue May 07 15:54:15 2019 +0100
+++ b/base/XmlExportable.h	Thu May 16 11:07:47 2019 +0100
@@ -25,9 +25,17 @@
 class XmlExportable
 {
 public:
+    XmlExportable() : m_exportId(-1) { }
     virtual ~XmlExportable() { }
 
     /**
+     * Return the numerical export identifier for this object.  It's
+     * allocated the first time this is called, so objects on which
+     * this is never called do not get allocated one.
+     */
+    int getExportId() const;
+
+    /**
      * Stream this exportable object out to XML on a text stream.
      */
     virtual void toXml(QTextStream &stream,
@@ -46,7 +54,8 @@
 
     static QString encodeColour(int r, int g, int b); 
 
-    static int getObjectExportId(const void *); // thread-safe
+private:
+    mutable int m_exportId;
 };
 
 #endif
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/base/test/StressEventSeries.h	Thu May 16 11:07:47 2019 +0100
@@ -0,0 +1,84 @@
+/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
+
+/*
+    Sonic Visualiser
+    An audio file viewer and annotation editor.
+    Centre for Digital Music, Queen Mary, University of London.
+    
+    This program is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public License as
+    published by the Free Software Foundation; either version 2 of the
+    License, or (at your option) any later version.  See the file
+    COPYING included with this distribution for more information.
+*/
+
+#ifndef STRESS_EVENT_SERIES_H
+#define STRESS_EVENT_SERIES_H
+
+#include "../EventSeries.h"
+
+#include <QObject>
+#include <QtTest>
+
+#include <iostream>
+
+using namespace std;
+
+class StressEventSeries : public QObject
+{
+    Q_OBJECT
+
+private:
+    void report(int n, QString sort, clock_t start, clock_t end) {
+        QString message = QString("Time for %1 %2 events = ").arg(n).arg(sort);
+        cerr << "                 " << message;
+        for (int i = 0; i < 34 - message.size(); ++i) cerr << " ";
+        cerr << double(end - start) * 1000.0 / double(CLOCKS_PER_SEC)
+             << "ms" << std::endl;
+    }        
+    
+    void short_n(int n) {
+        clock_t start = clock();
+        std::set<Event> ee;
+        EventSeries s;
+        for (int i = 0; i < n; ++i) {
+            float value = float(rand()) / float(RAND_MAX);
+            Event e(rand(), value, 1000, QString("event %1").arg(i));
+            ee.insert(e);
+        }
+        for (const Event &e: ee) {
+            s.add(e);
+        }
+        QCOMPARE(s.count(), n);
+        clock_t end = clock();
+        report(n, "short", start, end);
+    }
+
+    void longish_n(int n) {
+        clock_t start = clock();
+        std::set<Event> ee;
+        EventSeries s;
+        for (int i = 0; i < n; ++i) {
+            float value = float(rand()) / float(RAND_MAX);
+            Event e(rand(), value, rand() / 1000, QString("event %1").arg(i));
+            ee.insert(e);
+        }
+        for (const Event &e: ee) {
+            s.add(e);
+        }
+        QCOMPARE(s.count(), n);
+        clock_t end = clock();
+        report(n, "longish", start, end);
+    }
+
+private slots:
+    void short_3() { short_n(1000); }
+    void short_4() { short_n(10000); }
+    void short_5() { short_n(100000); }
+    void short_6() { short_n(1000000); }
+    void longish_3() { longish_n(1000); }
+    void longish_4() { longish_n(10000); }
+    void longish_5() { longish_n(100000); }
+};
+
+#endif
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/base/test/TestEventSeries.h	Thu May 16 11:07:47 2019 +0100
@@ -0,0 +1,747 @@
+/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
+
+/*
+    Sonic Visualiser
+    An audio file viewer and annotation editor.
+    Centre for Digital Music, Queen Mary, University of London.
+    
+    This program is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public License as
+    published by the Free Software Foundation; either version 2 of the
+    License, or (at your option) any later version.  See the file
+    COPYING included with this distribution for more information.
+*/
+
+#ifndef TEST_EVENT_SERIES_H
+#define TEST_EVENT_SERIES_H
+
+#include "../EventSeries.h"
+
+#include <QObject>
+#include <QtTest>
+
+#include <iostream>
+
+using namespace std;
+
+class TestEventSeries : public QObject
+{
+    Q_OBJECT
+
+private slots:
+    void empty() {
+
+        EventSeries s;
+        QCOMPARE(s.isEmpty(), true);
+        QCOMPARE(s.count(), 0);
+
+        Event p(10, QString());
+        QCOMPARE(s.contains(p), false);
+        QCOMPARE(s.getEventsCovering(400), EventVector());
+    }
+
+    void singleEvent() {
+
+        EventSeries s;
+        Event p(10, QString());
+        s.add(p);
+        QCOMPARE(s.isEmpty(), false);
+        QCOMPARE(s.count(), 1);
+        QCOMPARE(s.contains(p), true);
+
+        s.remove(p);
+        QCOMPARE(s.isEmpty(), true);
+        QCOMPARE(s.count(), 0);
+        QCOMPARE(s.contains(p), false);
+    }
+
+    void duplicateEvents() {
+
+        EventSeries s;
+        Event p(10, QString());
+        s.add(p);
+        s.add(p);
+        QCOMPARE(s.isEmpty(), false);
+        QCOMPARE(s.count(), 2);
+        QCOMPARE(s.contains(p), true);
+
+        s.remove(p);
+        QCOMPARE(s.isEmpty(), false);
+        QCOMPARE(s.count(), 1);
+        QCOMPARE(s.contains(p), true);
+
+        s.remove(p);
+        QCOMPARE(s.isEmpty(), true);
+        QCOMPARE(s.count(), 0);
+        QCOMPARE(s.contains(p), false);
+    }
+
+    void singleEventCover() {
+
+        EventSeries s;
+        Event p(10, QString());
+        s.add(p);
+        EventVector cover;
+        cover.push_back(p);
+        QCOMPARE(s.getEventsCovering(10), cover);
+        QCOMPARE(s.getEventsCovering(11), EventVector());
+        QCOMPARE(s.getEventsCovering(9), EventVector());
+    }
+
+    void singleEventSpan() {
+
+        EventSeries s;
+        Event p(10, QString());
+        s.add(p);
+        EventVector span;
+        span.push_back(p);
+        QCOMPARE(s.getEventsSpanning(10, 2), span);
+        QCOMPARE(s.getEventsSpanning(9, 2), span);
+        QCOMPARE(s.getEventsSpanning(8, 2), EventVector());
+        QCOMPARE(s.getEventsSpanning(7, 2), EventVector());
+        QCOMPARE(s.getEventsSpanning(11, 2), EventVector());
+    }
+    
+    void identicalEventsCover() {
+
+        EventSeries s;
+        Event p(10, QString());
+        s.add(p);
+        s.add(p);
+
+        EventVector cover;
+        cover.push_back(p);
+        cover.push_back(p);
+        QCOMPARE(s.getEventsCovering(10), cover);
+        QCOMPARE(s.getEventsCovering(11), EventVector());
+        QCOMPARE(s.getEventsCovering(9), EventVector());
+
+        s.remove(p);
+        cover.clear();
+        cover.push_back(p);
+        QCOMPARE(s.getEventsCovering(10), cover);
+        QCOMPARE(s.getEventsCovering(11), EventVector());
+        QCOMPARE(s.getEventsCovering(9), EventVector());
+    }
+    
+    void identicalEventsSpan() {
+
+        EventSeries s;
+        Event p(10, QString());
+        s.add(p);
+        s.add(p);
+
+        EventVector span;
+        span.push_back(p);
+        span.push_back(p);
+        QCOMPARE(s.getEventsSpanning(10, 2), span);
+        QCOMPARE(s.getEventsSpanning(9, 2), span);
+        QCOMPARE(s.getEventsSpanning(8, 2), EventVector());
+        QCOMPARE(s.getEventsSpanning(11, 2), EventVector());
+    }
+
+    void similarEventsCover() {
+
+        EventSeries s;
+        Event a(10, QString("a"));
+        Event b(10, QString("b"));
+        s.add(a);
+        s.add(b);
+        EventVector cover;
+        cover.push_back(a);
+        cover.push_back(b);
+        QCOMPARE(s.getEventsCovering(10), cover);
+        QCOMPARE(s.getEventsCovering(11), EventVector());
+        QCOMPARE(s.getEventsCovering(9), EventVector());
+    }
+
+    void similarEventsSpan() {
+
+        EventSeries s;
+        Event a(10, QString("a"));
+        Event b(10, QString("b"));
+        s.add(a);
+        s.add(b);
+        EventVector span;
+        span.push_back(a);
+        span.push_back(b);
+        QCOMPARE(s.getEventsSpanning(10, 2), span);
+        QCOMPARE(s.getEventsSpanning(9, 2), span);
+        QCOMPARE(s.getEventsSpanning(11, 2), EventVector());
+        QCOMPARE(s.getEventsSpanning(8, 2), EventVector());
+    }
+
+    void singleEventWithDurationCover() {
+
+        EventSeries s;
+        Event p(10, 1.0, 20, QString());
+        s.add(p);
+        EventVector cover;
+        cover.push_back(p);
+        QCOMPARE(s.getEventsCovering(10), cover);
+        QCOMPARE(s.getEventsCovering(11), cover);
+        QCOMPARE(s.getEventsCovering(29), cover);
+        QCOMPARE(s.getEventsCovering(30), EventVector());
+        QCOMPARE(s.getEventsCovering(9), EventVector());
+    }
+
+    void singleEventWithDurationSpan() {
+
+        EventSeries s;
+        Event p(10, 1.0, 20, QString());
+        s.add(p);
+        EventVector span;
+        span.push_back(p);
+        QCOMPARE(s.getEventsSpanning(9, 2), span);
+        QCOMPARE(s.getEventsSpanning(8, 2), EventVector());
+        QCOMPARE(s.getEventsSpanning(19, 4), span);
+        QCOMPARE(s.getEventsSpanning(29, 2), span);
+        QCOMPARE(s.getEventsSpanning(30, 2), EventVector());
+    }
+
+    void identicalEventsWithDurationCover() {
+
+        EventSeries s;
+        Event p(10, 1.0, 20, QString());
+        s.add(p);
+        s.add(p);
+        EventVector cover;
+        cover.push_back(p);
+        cover.push_back(p);
+        QCOMPARE(s.getEventsCovering(10), cover);
+        QCOMPARE(s.getEventsCovering(11), cover);
+        QCOMPARE(s.getEventsCovering(29), cover);
+        QCOMPARE(s.getEventsCovering(30), EventVector());
+        QCOMPARE(s.getEventsCovering(9), EventVector());
+
+        s.remove(p);
+        cover.clear();
+        cover.push_back(p);
+        QCOMPARE(s.getEventsCovering(10), cover);
+        QCOMPARE(s.getEventsCovering(11), cover);
+        QCOMPARE(s.getEventsCovering(29), cover);
+        QCOMPARE(s.getEventsCovering(30), EventVector());
+        QCOMPARE(s.getEventsCovering(9), EventVector());
+    }
+
+    void identicalEventsWithDurationSpan() {
+
+        EventSeries s;
+        Event p(10, 1.0, 20, QString());
+        s.add(p);
+        s.add(p);
+        EventVector span;
+        span.push_back(p);
+        span.push_back(p);
+        QCOMPARE(s.getEventsSpanning(9, 2), span);
+        QCOMPARE(s.getEventsSpanning(10, 2), span);
+        QCOMPARE(s.getEventsSpanning(11, 2), span);
+        QCOMPARE(s.getEventsSpanning(29, 2), span);
+        QCOMPARE(s.getEventsSpanning(30, 2), EventVector());
+        QCOMPARE(s.getEventsSpanning(8, 2), EventVector());
+    }
+
+    void multipleEventsCover() {
+
+        EventSeries s;
+        Event a(10, QString("a"));
+        Event b(11, QString("b"));
+        Event c(40, QString("c"));
+        s.add(c);
+        s.add(a);
+        s.add(b);
+        s.remove(a);
+        s.add(a);
+        s.add(c);
+        s.remove(c);
+        QCOMPARE(s.count(), 3);
+        EventVector cover;
+        cover.push_back(a);
+        QCOMPARE(s.getEventsCovering(10), cover);
+        cover.clear();
+        cover.push_back(c);
+        QCOMPARE(s.getEventsCovering(40), cover);
+        QCOMPARE(s.getEventsCovering(9), EventVector());
+    }
+
+    void multipleEventsSpan() {
+
+        EventSeries s;
+        Event a(10, QString("a"));
+        Event b(11, QString("b"));
+        Event c(40, QString("c"));
+        s.add(c);
+        s.add(a);
+        s.add(b);
+        EventVector span;
+        span.push_back(a);
+        span.push_back(b);
+        QCOMPARE(s.getEventsSpanning(10, 2), span);
+        span.clear();
+        span.push_back(c);
+        QCOMPARE(s.getEventsSpanning(39, 3), span);
+        QCOMPARE(s.getEventsSpanning(9, 1), EventVector());
+        QCOMPARE(s.getEventsSpanning(10, 0), EventVector());
+    }
+
+    void multipleEventsEndFrame() {
+
+        EventSeries s;
+        Event a(10, QString("a"));
+        Event b(11, QString("b"));
+        Event c(40, QString("c"));
+        s.add(c);
+        s.add(a);
+        s.add(b);
+        s.add(b);
+        QCOMPARE(s.getEndFrame(), 40);
+        s.remove(c);
+        QCOMPARE(s.getEndFrame(), 11);
+        s.remove(b);
+        QCOMPARE(s.getEndFrame(), 11);
+        s.remove(a);
+        QCOMPARE(s.getEndFrame(), 11);
+        s.remove(b);
+        QCOMPARE(s.getEndFrame(), 0);
+    }
+
+    void disjointEventsWithDurationCover() {
+
+        EventSeries s;
+        Event a(10, 1.0f, 20, QString("a"));
+        Event b(100, 1.2f, 30, QString("b"));
+        s.add(a);
+        s.add(b);
+        QCOMPARE(s.getEventsCovering(0), EventVector());
+        QCOMPARE(s.getEventsCovering(10), EventVector({ a }));
+        QCOMPARE(s.getEventsCovering(15), EventVector({ a }));
+        QCOMPARE(s.getEventsCovering(30), EventVector());
+        QCOMPARE(s.getEventsCovering(99), EventVector());
+        QCOMPARE(s.getEventsCovering(100), EventVector({ b }));
+        QCOMPARE(s.getEventsCovering(120), EventVector({ b }));
+        QCOMPARE(s.getEventsCovering(130), EventVector());
+    }
+    
+    void disjointEventsWithDurationSpan() {
+
+        EventSeries s;
+        Event a(10, 1.0f, 20, QString("a"));
+        Event b(100, 1.2f, 30, QString("b"));
+        s.add(a);
+        s.add(b);
+        QCOMPARE(s.getEventsSpanning(0, 10), EventVector());
+        QCOMPARE(s.getEventsSpanning(10, 10), EventVector({ a }));
+        QCOMPARE(s.getEventsSpanning(15, 85), EventVector({ a }));
+        QCOMPARE(s.getEventsSpanning(30, 5), EventVector());
+        QCOMPARE(s.getEventsSpanning(99, 1), EventVector());
+        QCOMPARE(s.getEventsSpanning(100, 1), EventVector({ b }));
+        QCOMPARE(s.getEventsSpanning(120, 20), EventVector({ b }));
+        QCOMPARE(s.getEventsSpanning(130, 109), EventVector());
+    }
+    
+    void overlappingEventsWithAndWithoutDurationCover() {
+
+        EventSeries s;
+        Event p(20, QString("p"));
+        Event a(10, 1.0f, 20, QString("a"));
+        s.add(p);
+        s.add(a);
+        EventVector cover;
+        cover.push_back(a);
+        QCOMPARE(s.getEventsCovering(15), cover);
+        QCOMPARE(s.getEventsCovering(25), cover);
+        cover.clear();
+        cover.push_back(p);
+        cover.push_back(a);
+        QCOMPARE(s.getEventsCovering(20), cover);
+    }
+    
+    void overlappingEventsWithAndWithoutDurationSpan() {
+
+        EventSeries s;
+        Event p(20, QString("p"));
+        Event a(10, 1.0f, 20, QString("a"));
+        s.add(p);
+        s.add(a);
+        EventVector span;
+        span.push_back(a);
+        QCOMPARE(s.getEventsSpanning(5, 10), span);
+        QCOMPARE(s.getEventsSpanning(25, 5), span);
+        span.clear();
+        span.push_back(p);
+        span.push_back(a);
+        QCOMPARE(s.getEventsSpanning(20, 1), span);
+    }
+
+    void overlappingEventsWithDurationCover() {
+
+        EventSeries s;
+        Event a(20, 1.0f, 10, QString("a"));
+        Event b(10, 1.0f, 20, QString("b"));
+        Event c(10, 1.0f, 40, QString("c"));
+        s.add(a);
+        s.add(b);
+        s.add(c);
+        QCOMPARE(s.getEventsCovering(10), EventVector({ b, c }));
+        QCOMPARE(s.getEventsCovering(20), EventVector({ b, c, a }));
+        QCOMPARE(s.getEventsCovering(25), EventVector({ b, c, a }));
+        QCOMPARE(s.getEventsCovering(30), EventVector({ c }));
+        QCOMPARE(s.getEventsCovering(40), EventVector({ c }));
+        QCOMPARE(s.getEventsCovering(50), EventVector());
+    }
+
+    void overlappingEventsWithDurationSpan() {
+
+        EventSeries s;
+        Event a(20, 1.0f, 10, QString("a"));
+        Event b(10, 1.0f, 20, QString("b"));
+        Event c(10, 1.0f, 40, QString("c"));
+        s.add(a);
+        s.add(b);
+        s.add(c);
+        QCOMPARE(s.getEventsSpanning(10, 5), EventVector({ b, c }));
+        QCOMPARE(s.getEventsSpanning(20, 15), EventVector({ b, c, a }));
+        QCOMPARE(s.getEventsSpanning(0, 100), EventVector({ b, c, a }));
+        QCOMPARE(s.getEventsSpanning(25, 4), EventVector({ b, c, a }));
+        QCOMPARE(s.getEventsSpanning(30, 4), EventVector({ c }));
+        QCOMPARE(s.getEventsSpanning(40, 15), EventVector({ c }));
+        QCOMPARE(s.getEventsSpanning(50, 10), EventVector());
+    }
+
+    void eventPatternCover() {
+
+        EventSeries s;
+        Event a(0, 1.0f, 18, QString("a"));
+        Event b(3, 2.0f, 6, QString("b"));
+        Event c(5, 3.0f, 2, QString("c"));
+        Event cc(5, 3.1f, 2, QString("cc"));
+        Event d(6, 4.0f, 10, QString("d"));
+        Event dd(6, 4.5f, 10, QString("dd"));
+        Event e(14, 5.0f, 3, QString("e"));
+        s.add(b);
+        s.add(c);
+        s.add(d);
+        s.add(a);
+        s.add(cc);
+        s.add(dd);
+        s.add(e);
+        QCOMPARE(s.getEventsCovering(8), EventVector({ a, b, d, dd }));
+    }
+
+    void eventPatternSpan() {
+
+        EventSeries s;
+        Event a(0, 1.0f, 18, QString("a"));
+        Event b(3, 2.0f, 6, QString("b"));
+        Event c(5, 3.0f, 2, QString("c"));
+        Event cc(5, 3.1f, 2, QString("cc"));
+        Event d(6, 4.0f, 10, QString("d"));
+        Event dd(6, 4.5f, 10, QString("dd"));
+        Event e(14, 5.0f, 3, QString("e"));
+        s.add(b);
+        s.add(c);
+        s.add(d);
+        s.add(a);
+        s.add(cc);
+        s.add(dd);
+        s.add(e);
+        QCOMPARE(s.getEventsSpanning(6, 2), EventVector({ a, b, c, cc, d, dd }));
+    }
+
+    void eventPatternWithin() {
+
+        EventSeries s;
+        Event a(0, 1.0f, 18, QString("a"));
+        Event b(3, 2.0f, 6, QString("b"));
+        Event c(5, 3.0f, 2, QString("c"));
+        Event cc(5, 3.1f, 2, QString("cc"));
+        Event d(6, 4.0f, 10, QString("d"));
+        Event dd(6, 4.5f, 10, QString("dd"));
+        Event e(14, 5.0f, 3, QString("e"));
+        s.add(b);
+        s.add(c);
+        s.add(d);
+        s.add(a);
+        s.add(cc);
+        s.add(dd);
+        s.add(e);
+        QCOMPARE(s.getEventsWithin(2, 7), EventVector({ b, c, cc }));
+    }
+
+    void eventPatternWithinWithOverspill() {
+
+        EventSeries s;
+        Event a(0, 1.0f, 18, QString("a"));
+        Event b(3, 2.0f, 6, QString("b"));
+        Event c(5, 3.0f, 2, QString("c"));
+        Event cc(5, 3.1f, 2, QString("cc"));
+        Event d(6, 4.0f, 10, QString("d"));
+        Event dd(6, 4.5f, 10, QString("dd"));
+        Event e(14, 5.0f, 3, QString("e"));
+        s.add(b);
+        s.add(c);
+        s.add(d);
+        s.add(a);
+        s.add(cc);
+        s.add(dd);
+        s.add(e);
+        QCOMPARE(s.getEventsWithin(0, 0, 0), EventVector());
+        QCOMPARE(s.getEventsWithin(0, 0, 1), EventVector({ a }));
+        QCOMPARE(s.getEventsWithin(0, 0, 2), EventVector({ a, b }));
+        QCOMPARE(s.getEventsWithin(20, 1, 0), EventVector());
+        QCOMPARE(s.getEventsWithin(20, 1, 1), EventVector({ e }));
+        QCOMPARE(s.getEventsWithin(20, 1, 2), EventVector({ dd, e }));
+        QCOMPARE(s.getEventsWithin(2, 7, 0), EventVector({ b, c, cc }));
+        QCOMPARE(s.getEventsWithin(2, 7, 1), EventVector({ a, b, c, cc, d }));
+        QCOMPARE(s.getEventsWithin(2, 7, 2), EventVector({ a, b, c, cc, d, dd }));
+        QCOMPARE(s.getEventsWithin(2, 7, 3), EventVector({ a, b, c, cc, d, dd, e }));
+        QCOMPARE(s.getEventsWithin(2, 7, 4), EventVector({ a, b, c, cc, d, dd, e }));
+    }
+
+    void eventPatternStartingWithin() {
+
+        EventSeries s;
+        Event a(0, 1.0f, 18, QString("a"));
+        Event b(3, 2.0f, 6, QString("b"));
+        Event c(5, 3.0f, 2, QString("c"));
+        Event cc(5, 3.1f, 2, QString("cc"));
+        Event d(6, 4.0f, 10, QString("d"));
+        Event dd(6, 4.5f, 10, QString("dd"));
+        Event e(14, 5.0f, 3, QString("e"));
+        s.add(b);
+        s.add(c);
+        s.add(d);
+        s.add(a);
+        s.add(cc);
+        s.add(dd);
+        s.add(e);
+        QCOMPARE(s.getEventsStartingWithin(2, 7),
+                 EventVector({ b, c, cc, d, dd }));
+    }
+
+    void eventPatternStartingAt() {
+
+        EventSeries s;
+        Event a(0, 1.0f, 18, QString("a"));
+        Event b(3, 2.0f, 6, QString("b"));
+        Event c(5, 3.0f, 2, QString("c"));
+        Event cc(5, 3.1f, 2, QString("cc"));
+        Event d(6, 4.0f, 10, QString("d"));
+        Event dd(6, 4.5f, 10, QString("dd"));
+        Event e(14, 5.0f, 3, QString("e"));
+        s.add(b);
+        s.add(c);
+        s.add(d);
+        s.add(a);
+        s.add(cc);
+        s.add(dd);
+        s.add(e);
+        QCOMPARE(s.getEventsStartingAt(2), EventVector());
+        QCOMPARE(s.getEventsStartingAt(5), EventVector({ c, cc }));
+    }
+
+    void eventPatternEndFrame() {
+
+        EventSeries s;
+        Event a(0, 1.0f, 18, QString("a"));
+        Event b(3, 2.0f, 6, QString("b"));
+        Event c(5, 3.0f, 2, QString("c"));
+        Event cc(5, 3.1f, 2, QString("cc"));
+        Event d(6, 4.0f, 10, QString("d"));
+        Event dd(6, 4.5f, 10, QString("dd"));
+        Event e(14, 5.0f, 3, QString("e"));
+        s.add(b);
+        s.add(c);
+        s.add(d);
+        s.add(a);
+        s.add(cc);
+        s.add(dd);
+        s.add(e);
+        QCOMPARE(s.getEndFrame(), 18);
+    }
+
+    void eventPatternAddRemove() {
+
+        // This is mostly here to exercise the innards of EventSeries
+        // and check it doesn't crash out with any internal
+        // consistency problems
+        
+        EventSeries s;
+        Event a(0, 1.0f, 18, QString("a"));
+        Event b(3, 2.0f, 6, QString("b"));
+        Event c(5, 3.0f, 2, QString("c"));
+        Event cc(5, 3.1f, 2, QString("cc"));
+        Event d(6, 4.0f, 10, QString("d"));
+        Event dd(6, 4.5f, 10, QString("dd"));
+        Event e(14, 5.0f, 3, QString("e"));
+        s.add(b);
+        s.add(c);
+        s.add(d);
+        s.add(a);
+        s.add(cc);
+        s.add(dd);
+        s.add(e);
+        QCOMPARE(s.count(), 7);
+        s.remove(d);
+        QCOMPARE(s.getEventsCovering(8), EventVector({ a, b, dd }));
+        QCOMPARE(s.getEndFrame(), 18);
+        s.remove(e);
+        s.remove(a);
+        QCOMPARE(s.getEventsCovering(8), EventVector({ b, dd }));
+        QCOMPARE(s.getEndFrame(), 16);
+        s.remove(cc);
+        s.remove(c);
+        s.remove(dd);
+        QCOMPARE(s.getEventsCovering(8), EventVector({ b }));
+        QCOMPARE(s.getEndFrame(), 9);
+        s.remove(b);
+        QCOMPARE(s.getEventsCovering(8), EventVector());
+        QCOMPARE(s.count(), 0);
+        QCOMPARE(s.isEmpty(), true);
+        QCOMPARE(s.getEndFrame(), 0);
+    }
+
+    void preceding() {
+        
+        EventSeries s;
+        Event a(0, 1.0f, 18, QString("a"));
+        Event b(3, 2.0f, 6, QString("b"));
+        Event c(5, 3.0f, 2, QString("c"));
+        Event cc(5, 3.1f, 2, QString("cc"));
+        Event d(6, 4.0f, 10, QString("d"));
+        Event dd(6, 4.5f, 10, QString("dd"));
+        Event e(14, 5.0f, 3, QString("e"));
+        s.add(b);
+        s.add(c);
+        s.add(d);
+        s.add(d); // again
+        s.add(a);
+        s.add(cc);
+        s.add(dd);
+        s.add(e);
+        Event p;
+        QCOMPARE(s.getEventPreceding(e, p), true);
+        QCOMPARE(p, dd);
+        QCOMPARE(s.getEventPreceding(p, p), true);
+        QCOMPARE(p, d);
+        QCOMPARE(s.getEventPreceding(p, p), true);
+        QCOMPARE(p, cc);
+        QCOMPARE(s.getEventPreceding(p, p), true);
+        QCOMPARE(p, c);
+        QCOMPARE(s.getEventPreceding(p, p), true);
+        QCOMPARE(p, b);
+        QCOMPARE(s.getEventPreceding(p, p), true);
+        QCOMPARE(p, a);
+        QCOMPARE(s.getEventPreceding(p, p), false);
+    }
+    
+    void following() {
+        
+        EventSeries s;
+        Event a(0, 1.0f, 18, QString("a"));
+        Event b(3, 2.0f, 6, QString("b"));
+        Event c(5, 3.0f, 2, QString("c"));
+        Event cc(5, 3.1f, 2, QString("cc"));
+        Event d(6, 4.0f, 10, QString("d"));
+        Event dd(6, 4.5f, 10, QString("dd"));
+        Event e(14, 5.0f, 3, QString("e"));
+        s.add(b);
+        s.add(c);
+        s.add(d);
+        s.add(d); // again
+        s.add(a);
+        s.add(cc);
+        s.add(dd);
+        s.add(e);
+        Event p;
+        QCOMPARE(s.getEventFollowing(a, p), true);
+        QCOMPARE(p, b);
+        QCOMPARE(s.getEventFollowing(p, p), true);
+        QCOMPARE(p, c);
+        QCOMPARE(s.getEventFollowing(p, p), true);
+        QCOMPARE(p, cc);
+        QCOMPARE(s.getEventFollowing(p, p), true);
+        QCOMPARE(p, d);
+        QCOMPARE(s.getEventFollowing(p, p), true);
+        QCOMPARE(p, dd);
+        QCOMPARE(s.getEventFollowing(p, p), true);
+        QCOMPARE(p, e);
+        QCOMPARE(s.getEventFollowing(p, p), false);
+    }
+    
+    void matchingForward() {
+        
+        EventSeries s;
+        Event p;
+        QCOMPARE(s.getNearestEventMatching
+                 (6, [](Event e) { return e.getDuration() < 4; },
+                  EventSeries::Forward, p), false);
+        Event a(0, 1.0f, 18, QString("a"));
+        Event b(3, 2.0f, 6, QString("b"));
+        Event c(5, 3.0f, 2, QString("c"));
+        Event cc(5, 3.1f, 2, QString("cc"));
+        Event d(6, 4.0f, 10, QString("d"));
+        Event dd(6, 4.5f, 10, QString("dd"));
+        Event e(14, 5.0f, 3, QString("e"));
+        s.add(b);
+        s.add(c);
+        s.add(d);
+        s.add(d); // again
+        s.add(a);
+        s.add(cc);
+        s.add(dd);
+        s.add(e);
+        QCOMPARE(s.getNearestEventMatching
+                 (0, [](Event e) { return e.getDuration() < 4; },
+                  EventSeries::Forward, p), true);
+        QCOMPARE(p, c);
+        QCOMPARE(s.getNearestEventMatching
+                 (6, [](Event e) { return e.getDuration() < 4; },
+                  EventSeries::Forward, p), true);
+        QCOMPARE(p, e);
+        QCOMPARE(s.getNearestEventMatching
+                 (6, [](Event e) { return e.getDuration() > 4; },
+                  EventSeries::Forward, p), true);
+        QCOMPARE(p, d);
+        QCOMPARE(s.getNearestEventMatching
+                 (20, [](Event e) { return e.getDuration() > 4; },
+                  EventSeries::Forward, p), false);
+    }
+    
+    void matchingBackward() {
+        
+        EventSeries s;
+        Event p;
+        QCOMPARE(s.getNearestEventMatching
+                 (6, [](Event e) { return e.getDuration() < 4; },
+                  EventSeries::Backward, p), false);
+        Event a(0, 1.0f, 18, QString("a"));
+        Event b(3, 2.0f, 6, QString("b"));
+        Event c(5, 3.0f, 2, QString("c"));
+        Event cc(5, 3.1f, 2, QString("cc"));
+        Event d(6, 4.0f, 10, QString("d"));
+        Event dd(6, 4.5f, 10, QString("dd"));
+        Event e(14, 5.0f, 3, QString("e"));
+        s.add(b);
+        s.add(c);
+        s.add(d);
+        s.add(d); // again
+        s.add(a);
+        s.add(cc);
+        s.add(dd);
+        s.add(e);
+        QCOMPARE(s.getNearestEventMatching
+                 (0, [](Event e) { return e.getDuration() < 4; },
+                  EventSeries::Backward, p), false);
+        QCOMPARE(s.getNearestEventMatching
+                 (6, [](Event e) { return e.getDuration() > 4; },
+                  EventSeries::Backward, p), true);
+        QCOMPARE(p, b);
+        QCOMPARE(s.getNearestEventMatching
+                 (20, [](Event e) { return e.getDuration() > 4; },
+                  EventSeries::Backward, p), true);
+        QCOMPARE(p, dd);
+    }
+};
+
+#endif
--- a/base/test/files.pri	Tue May 07 15:54:15 2019 +0100
+++ b/base/test/files.pri	Thu May 16 11:07:47 2019 +0100
@@ -4,10 +4,12 @@
 	     TestMovingMedian.h \
 	     TestOurRealTime.h \
 	     TestPitch.h \
+	     TestEventSeries.h \
 	     TestRangeMapper.h \
 	     TestScaleTickIntervals.h \
 	     TestStringBits.h \
-	     TestVampRealTime.h
+	     TestVampRealTime.h \
+	     StressEventSeries.h
 	     
 TEST_SOURCES += \
 	     svcore-base-test.cpp
--- a/base/test/svcore-base-test.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/base/test/svcore-base-test.cpp	Thu May 16 11:07:47 2019 +0100
@@ -20,6 +20,8 @@
 #include "TestVampRealTime.h"
 #include "TestColumnOp.h"
 #include "TestMovingMedian.h"
+#include "TestEventSeries.h"
+#include "StressEventSeries.h"
 
 #include "system/Init.h"
 
@@ -84,7 +86,19 @@
         if (QTest::qExec(&t, argc, argv) == 0) ++good;
         else ++bad;
     }
-
+    {
+        TestEventSeries t;
+        if (QTest::qExec(&t, argc, argv) == 0) ++good;
+        else ++bad;
+    }
+/*
+    {
+        StressEventSeries t;
+        if (QTest::qExec(&t, argc, argv) == 0) ++good;
+        else ++bad;
+    }
+*/
+    
     if (bad > 0) {
         SVCERR << "\n********* " << bad << " test suite(s) failed!\n" << endl;
         return 1;
--- a/data/fileio/CSVFileReader.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/data/fileio/CSVFileReader.cpp	Thu May 16 11:07:47 2019 +0100
@@ -437,24 +437,24 @@
 
             if (modelType == CSVFormat::OneDimensionalModel) {
             
-                SparseOneDimensionalModel::Point point(frameNo, label);
-                model1->addPoint(point);
+                Event point(frameNo, label);
+                model1->add(point);
 
             } else if (modelType == CSVFormat::TwoDimensionalModel) {
 
-                SparseTimeValueModel::Point point(frameNo, value, label);
-                model2->addPoint(point);
+                Event point(frameNo, value, label);
+                model2->add(point);
 
             } else if (modelType == CSVFormat::TwoDimensionalModelWithDuration) {
 
-                RegionModel::Point point(frameNo, value, duration, label);
-                model2a->addPoint(point);
+                Event region(frameNo, value, duration, label);
+                model2a->add(region);
 
             } else if (modelType == CSVFormat::TwoDimensionalModelWithDurationAndPitch) {
 
                 float level = ((value >= 0.f && value <= 1.f) ? value : 1.f);
-                NoteModel::Point point(frameNo, pitch, duration, level, label);
-                model2b->addPoint(point);
+                Event note(frameNo, pitch, duration, level, label);
+                model2b->add(note);
 
             } else if (modelType == CSVFormat::ThreeDimensionalModel) {
 
@@ -581,31 +581,30 @@
                 }
             }
 
-            map<RegionModel::Point, RegionModel::Point,
-                RegionModel::Point::Comparator> pointMap;
-            for (RegionModel::PointList::const_iterator i =
-                     model2a->getPoints().begin();
-                 i != model2a->getPoints().end(); ++i) {
-                RegionModel::Point p(*i);
-                int count = labelCountMap[p.label];
-                v = countLabelValueMap[count][p.label];
-              //  SVCERR << "mapping from label \"" << p.label << "\" (count " << count << ") to value " << v << endl;
-                RegionModel::Point pp(p.frame, v, p.duration, p.label);
-                pointMap[p] = pp;
+            map<Event, Event> eventMap;
+
+            EventVector allEvents = model2a->getAllEvents();
+            for (const Event &e: allEvents) {
+                int count = labelCountMap[e.getLabel()];
+                v = countLabelValueMap[count][e.getLabel()];
+                // SVCERR << "mapping from label \"" << p.label
+                //       << "\" (count " << count
+                //       << ") to value " << v << endl;
+                eventMap[e] = Event(e.getFrame(), v,
+                                    e.getDuration(), e.getLabel());
             }
 
-            for (map<RegionModel::Point, RegionModel::Point>::iterator i = 
-                     pointMap.begin(); i != pointMap.end(); ++i) {
+            for (const auto &i: eventMap) {
                 // There could be duplicate regions; if so replace
                 // them all -- but we need to check we're not
                 // replacing a region by itself (or else this will
                 // never terminate)
-                if (i->first.value == i->second.value) {
+                if (i.first.getValue() == i.second.getValue()) {
                     continue;
                 }
-                while (model2a->containsPoint(i->first)) {
-                    model2a->deletePoint(i->first);
-                    model2a->addPoint(i->second);
+                while (model2a->containsEvent(i.first)) {
+                    model2a->remove(i.first);
+                    model2a->add(i.second);
                 }
             }
         }
--- a/data/fileio/CSVStreamWriter.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/fileio/CSVStreamWriter.h	Thu May 16 11:07:47 2019 +0100
@@ -50,7 +50,6 @@
             return acc + (current.getEndFrame() - current.getStartFrame());
         }
     );
-    const auto finalFrameOfLastRegion = (*selections.crbegin()).getEndFrame();
 
     const auto wasCancelled = [&reporter]() { 
         return reporter && reporter->wasCancelled(); 
@@ -58,6 +57,7 @@
 
     sv_frame_t nFramesWritten = 0;
     int previousProgress = 0;
+    bool started = false;
 
     for (const auto& extents : selections) {
         const auto startFrame = extents.getStartFrame();
@@ -68,15 +68,20 @@
 
             const auto start = readPtr;
             const auto end = std::min(start + blockSize, endFrame);
-            const auto data = model.toDelimitedDataStringSubsetWithOptions(
+            const auto data = model.toDelimitedDataString(
                 delimiter,
                 options,
                 start,
-                end
+                end - start
             ).trimmed();
 
             if ( data != "" ) {
-                oss << data << (end < finalFrameOfLastRegion ? "\n" : "");
+                if (started) {
+                    oss << "\n";
+                } else {
+                    started = true;
+                }
+                oss << data;
             }
 
             nFramesWritten += end - start;
--- a/data/fileio/MIDIFileReader.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/data/fileio/MIDIFileReader.cpp	Thu May 16 11:07:47 2019 +0100
@@ -1027,12 +1027,12 @@
 
                     float level = float((*i)->getVelocity()) / 128.f;
 
-                    Note note(startFrame, (*i)->getPitch(),
-                              endFrame - startFrame, level, noteLabel);
+                    Event note(startFrame, (*i)->getPitch(),
+                               endFrame - startFrame, level, noteLabel);
 
 //                    SVDEBUG << "Adding note " << startFrame << "," << (endFrame-startFrame) << " : " << int((*i)->getPitch()) << endl;
 
-                    model->addPoint(note);
+                    model->add(note);
                     break;
                 }
 
--- a/data/fileio/MIDIFileWriter.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/data/fileio/MIDIFileWriter.cpp	Thu May 16 11:07:47 2019 +0100
@@ -23,8 +23,9 @@
 #include "MIDIFileWriter.h"
 
 #include "data/midi/MIDIEvent.h"
-#include "model/NoteData.h"
 
+#include "base/NoteData.h"
+#include "base/NoteExportable.h"
 #include "base/Pitch.h"
 
 #include <QCoreApplication>
--- a/data/fileio/test/CSVStreamWriterTest.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/fileio/test/CSVStreamWriterTest.h	Thu May 16 11:07:47 2019 +0100
@@ -297,13 +297,15 @@
         NoteModel notes(8 /* sampleRate */, 4 /* resolution */);
         sv_frame_t startFrame = 0;
         for (const auto& note : cMajorPentatonic) {
-            notes.addPoint({startFrame, note, 4, 1.f, ""});
+            notes.add({startFrame, note, 4, 1.f, ""});
             startFrame += 8;
         }
 //        qDebug("Create Expected Output\n");
 
         // NB. removed end line break
-        const auto expectedOutput = notes.toDelimitedDataString(",").trimmed();
+        const auto expectedOutput =
+            notes.toDelimitedDataString(",", {}, 0, notes.getEndFrame())
+            .trimmed();
 
         StubReporter reporter { []() -> bool { return false; } };
         std::ostringstream oss;
@@ -318,9 +320,10 @@
             2
         );
 
-//        qDebug("\n%s\n", expectedOutput.toLocal8Bit().data());
-//        qDebug("\n%s\n", oss.str().c_str());
+//        qDebug("\n->>%s<<-\n", expectedOutput.toLocal8Bit().data());
+//        qDebug("\n->>%s<<-\n", oss.str().c_str());
         QVERIFY( wroteSparseModel == true );
+        QVERIFY( oss.str() != std::string() );
         QVERIFY( oss.str() == expectedOutput.toStdString() );
     }
 };
--- a/data/model/AggregateWaveModel.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/AggregateWaveModel.cpp	Thu May 16 11:07:47 2019 +0100
@@ -236,7 +236,7 @@
 {
     QStringList componentStrings;
     for (const auto &c: m_components) {
-        componentStrings.push_back(QString("%1").arg(getObjectExportId(c.model)));
+        componentStrings.push_back(QString("%1").arg(c.model->getExportId()));
     }
     Model::toXml(out, indent,
                  QString("type=\"aggregatewave\" components=\"%1\" %2")
--- a/data/model/AggregateWaveModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/AggregateWaveModel.h	Thu May 16 11:07:47 2019 +0100
@@ -41,6 +41,11 @@
 
     bool isOK() const override;
     bool isReady(int *) const override;
+    int getCompletion() const override {
+        int c = 0;
+        (void)isReady(&c);
+        return c;
+    }
 
     QString getTypeName() const override { return tr("Aggregate Wave"); }
 
--- a/data/model/AlignmentModel.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/AlignmentModel.cpp	Thu May 16 11:07:47 2019 +0100
@@ -48,10 +48,21 @@
     if (m_rawPath && m_rawPath->isReady()) {
         pathCompletionChanged();
     }
+
+    if (m_reference == m_aligned) {
+        // Trivial alignment, e.g. of main model to itself, which we
+        // record so that we can distinguish the reference model for
+        // alignments from an unaligned model. No path required
+        m_pathComplete = true;
+    }
 }
 
 AlignmentModel::~AlignmentModel()
 {
+#ifdef DEBUG_ALIGNMENT_MODEL
+    SVCERR << "AlignmentModel(" << this << ")::~AlignmentModel()" << endl;
+#endif
+
     if (m_rawPath) m_rawPath->aboutToDelete();
     delete m_rawPath;
 
@@ -65,8 +76,9 @@
 bool
 AlignmentModel::isOK() const
 {
+    if (m_error != "") return false;
     if (m_rawPath) return m_rawPath->isOK();
-    else return true;
+    return true;
 }
 
 sv_frame_t
@@ -97,14 +109,14 @@
     if (!m_pathBegun && m_rawPath) {
         if (completion) *completion = 0;
 #ifdef DEBUG_ALIGNMENT_MODEL
-        SVDEBUG << "AlignmentModel::isReady: path not begun" << endl;
+        SVCERR << "AlignmentModel::isReady: path not begun" << endl;
 #endif
         return false;
     }
     if (m_pathComplete) {
         if (completion) *completion = 100;
 #ifdef DEBUG_ALIGNMENT_MODEL
-        SVDEBUG << "AlignmentModel::isReady: path complete" << endl;
+        SVCERR << "AlignmentModel::isReady: path complete" << endl;
 #endif
         return true;
     }
@@ -114,7 +126,7 @@
         // set at all yet (this case)
         if (completion) *completion = 0;
 #ifdef DEBUG_ALIGNMENT_MODEL
-        SVDEBUG << "AlignmentModel::isReady: no raw path" << endl;
+        SVCERR << "AlignmentModel::isReady: no raw path" << endl;
 #endif
         return false;
     }
@@ -196,8 +208,8 @@
         m_rawPath->isReady(&completion);
 
 #ifdef DEBUG_ALIGNMENT_MODEL
-        cerr << "AlignmentModel::pathCompletionChanged: completion = "
-                  << completion << endl;
+        SVCERR << "AlignmentModel::pathCompletionChanged: completion = "
+               << completion << endl;
 #endif
 
         m_pathComplete = (completion == 100);
@@ -206,7 +218,10 @@
 
             constructPath();
             constructReversePath();
-            
+
+#ifdef DEBUG_ALIGNMENT_MODEL
+            SVCERR << "AlignmentModel: path complete" << endl;
+#endif
         }
     }
 
@@ -230,14 +245,13 @@
         
     m_path->clear();
 
-    SparseTimeValueModel::PointList points = m_rawPath->getPoints();
-        
-    for (SparseTimeValueModel::PointList::const_iterator i = points.begin();
-         i != points.end(); ++i) {
-        sv_frame_t frame = i->frame;
-        double value = i->value;
+    EventVector points = m_rawPath->getAllEvents();
+
+    for (const auto &p: points) {
+        sv_frame_t frame = p.getFrame();
+        double value = p.getValue();
         sv_frame_t rframe = lrint(value * m_aligned->getSampleRate());
-        m_path->addPoint(PathPoint(frame, rframe));
+        m_path->add(PathPoint(frame, rframe));
     }
 
 #ifdef DEBUG_ALIGNMENT_MODEL
@@ -268,7 +282,7 @@
          i != points.end(); ++i) {
         sv_frame_t frame = i->frame;
         sv_frame_t rframe = i->mapframe;
-        m_reversePath->addPoint(PathPoint(rframe, frame));
+        m_reversePath->add(PathPoint(rframe, frame));
     }
 
 #ifdef DEBUG_ALIGNMENT_MODEL
@@ -299,7 +313,7 @@
     cerr << "AlignmentModel::align: frame " << frame << " requested" << endl;
 #endif
 
-    PathModel::Point point(frame);
+    PathPoint point(frame);
     PathModel::PointList::const_iterator i = points.lower_bound(point);
     if (i == points.end()) {
 #ifdef DEBUG_ALIGNMENT_MODEL
@@ -407,9 +421,9 @@
 
     Model::toXml(stream, indent,
                  QString("type=\"alignment\" reference=\"%1\" aligned=\"%2\" path=\"%3\" %4")
-                 .arg(getObjectExportId(m_reference))
-                 .arg(getObjectExportId(m_aligned))
-                 .arg(getObjectExportId(m_path))
+                 .arg(m_reference->getExportId())
+                 .arg(m_aligned->getExportId())
+                 .arg(m_path->getExportId())
                  .arg(extraAttributes));
 }
 
--- a/data/model/AlignmentModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/AlignmentModel.h	Thu May 16 11:07:47 2019 +0100
@@ -36,10 +36,19 @@
     ~AlignmentModel();
 
     bool isOK() const override;
+
+    void setError(QString error) { m_error = error; }
+    QString getError() const { return m_error; }
+
     sv_frame_t getStartFrame() const override;
     sv_frame_t getEndFrame() const override;
     sv_samplerate_t getSampleRate() const override;
     bool isReady(int *completion = 0) const override;
+    int getCompletion() const override {
+        int c = 0;
+        (void)isReady(&c);
+        return c;
+    }
     const ZoomConstraint *getZoomConstraint() const override;
 
     QString getTypeName() const override { return tr("Alignment"); }
@@ -57,6 +66,11 @@
                        QString indent = "",
                        QString extraAttributes = "") const override;
 
+    QString toDelimitedDataString(QString, DataExportOptions,
+                                  sv_frame_t, sv_frame_t) const override {
+        return "";
+    }
+
 signals:
     void modelChanged();
     void modelChangedWithin(sv_frame_t startFrame, sv_frame_t endFrame);
@@ -76,6 +90,7 @@
     mutable PathModel *m_reversePath; // I own this
     bool m_pathBegun;
     bool m_pathComplete;
+    QString m_error;
 
     void constructPath() const;
     void constructReversePath() const;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/data/model/DeferredNotifier.h	Thu May 16 11:07:47 2019 +0100
@@ -0,0 +1,76 @@
+/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
+
+/*
+    Sonic Visualiser
+    An audio file viewer and annotation editor.
+    Centre for Digital Music, Queen Mary, University of London.
+    
+    This program is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public License as
+    published by the Free Software Foundation; either version 2 of the
+    License, or (at your option) any later version.  See the file
+    COPYING included with this distribution for more information.
+*/
+
+#ifndef SV_DEFERRED_NOTIFIER_H
+#define SV_DEFERRED_NOTIFIER_H
+
+#include "Model.h"
+
+#include "base/Extents.h"
+
+#include <QMutex>
+#include <QMutexLocker>
+
+class DeferredNotifier
+{
+public:
+    enum Mode {
+        NOTIFY_ALWAYS,
+        NOTIFY_DEFERRED
+    };
+    
+    DeferredNotifier(Model *m, Mode mode) : m_model(m), m_mode(mode) { }
+
+    Mode getMode() const {
+        return m_mode;
+    }
+    void switchMode(Mode newMode) {
+        m_mode = newMode;
+    }
+    
+    void update(sv_frame_t frame, sv_frame_t duration) {
+        if (m_mode == NOTIFY_ALWAYS) {
+            m_model->modelChangedWithin(frame, frame + duration);
+        } else {
+            QMutexLocker locker(&m_mutex);
+            m_extents.sample(frame);
+            m_extents.sample(frame + duration);
+        }
+    }
+    
+    void makeDeferredNotifications() {
+        bool shouldEmit = false;
+        sv_frame_t from, to;
+        {   QMutexLocker locker(&m_mutex);
+            if (m_extents.isSet()) {
+                shouldEmit = true;
+                from = m_extents.getMin();
+                to = m_extents.getMax();
+            }
+        }
+        if (shouldEmit) {
+            m_model->modelChangedWithin(from, to);
+            QMutexLocker locker(&m_mutex);
+            m_extents.reset();
+        }
+    }
+
+private:
+    Model *m_model;
+    Mode m_mode;
+    QMutex m_mutex;
+    Extents<sv_frame_t> m_extents;
+};
+
+#endif
--- a/data/model/Dense3DModelPeakCache.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/Dense3DModelPeakCache.h	Thu May 16 11:07:47 2019 +0100
@@ -97,6 +97,11 @@
         return m_source->getCompletion();
     }
 
+    QString toDelimitedDataString(QString, DataExportOptions,
+                                  sv_frame_t, sv_frame_t) const override {
+        return "";
+    }
+
 protected slots:
     void sourceModelChanged();
     void sourceModelAboutToBeDeleted();
--- a/data/model/DenseThreeDimensionalModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/DenseThreeDimensionalModel.h	Thu May 16 11:07:47 2019 +0100
@@ -120,7 +120,7 @@
 
     QString getTypeName() const override { return tr("Dense 3-D"); }
 
-    virtual int getCompletion() const = 0;
+    virtual int getCompletion() const override = 0;
 
     /*
        TabularModel methods.
--- a/data/model/DenseTimeValueModel.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/DenseTimeValueModel.cpp	Thu May 16 11:07:47 2019 +0100
@@ -29,22 +29,23 @@
 }
         
 QString
-DenseTimeValueModel::toDelimitedDataStringSubset(QString delimiter, sv_frame_t f0, sv_frame_t f1) const
+DenseTimeValueModel::toDelimitedDataString(QString delimiter,
+                                           DataExportOptions,
+                                           sv_frame_t startFrame,
+                                           sv_frame_t duration) const
 {
     int ch = getChannelCount();
 
-//    cerr << "f0 = " << f0 << ", f1 = " << f1 << endl;
+    if (duration <= 0) return "";
 
-    if (f1 <= f0) return "";
-
-    auto data = getMultiChannelData(0, ch - 1, f0, f1 - f0);
+    auto data = getMultiChannelData(0, ch - 1, startFrame, duration);
 
     if (data.empty() || data[0].empty()) return "";
     
     QStringList list;
     for (sv_frame_t i = 0; in_range_for(data[0], i); ++i) {
         QStringList parts;
-        parts << QString("%1").arg(f0 + i);
+        parts << QString("%1").arg(startFrame + i);
         for (int c = 0; in_range_for(data, c); ++c) {
             parts << QString("%1").arg(data[c][i]);
         }
--- a/data/model/DenseTimeValueModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/DenseTimeValueModel.h	Thu May 16 11:07:47 2019 +0100
@@ -82,8 +82,10 @@
     bool canPlay() const override { return true; }
     QString getDefaultPlayClipId() const override { return ""; }
 
-    QString toDelimitedDataStringSubset(QString delimiter,
-                                        sv_frame_t f0, sv_frame_t f1) const override;
+    QString toDelimitedDataString(QString delimiter,
+                                  DataExportOptions options,
+                                  sv_frame_t startFrame,
+                                  sv_frame_t duration) const override;
 
     QString getTypeName() const override { return tr("Dense Time-Value"); }
 };
--- a/data/model/EditableDenseThreeDimensionalModel.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/EditableDenseThreeDimensionalModel.cpp	Thu May 16 11:07:47 2019 +0100
@@ -57,6 +57,13 @@
     return true;
 }
 
+bool
+EditableDenseThreeDimensionalModel::isReady(int *completion) const
+{
+    if (completion) *completion = getCompletion();
+    return true;
+}
+
 sv_samplerate_t
 EditableDenseThreeDimensionalModel::getSampleRate() const
 {
@@ -486,29 +493,23 @@
     }
 }
 
-QString
-EditableDenseThreeDimensionalModel::toDelimitedDataString(QString delimiter) const
+int
+EditableDenseThreeDimensionalModel::getCompletion() const
 {
-    QReadLocker locker(&m_lock);
-    QString s;
-    for (int i = 0; in_range_for(m_data, i); ++i) {
-        QStringList list;
-        for (int j = 0; in_range_for(m_data.at(i), j); ++j) {
-            list << QString("%1").arg(m_data.at(i).at(j));
-        }
-        s += list.join(delimiter) + "\n";
-    }
-    return s;
+    return m_completion;
 }
 
 QString
-EditableDenseThreeDimensionalModel::toDelimitedDataStringSubset(QString delimiter, sv_frame_t f0, sv_frame_t f1) const
+EditableDenseThreeDimensionalModel::toDelimitedDataString(QString delimiter,
+                                                          DataExportOptions,
+                                                          sv_frame_t startFrame,
+                                                          sv_frame_t duration) const
 {
     QReadLocker locker(&m_lock);
     QString s;
     for (int i = 0; in_range_for(m_data, i); ++i) {
         sv_frame_t fr = m_startFrame + i * m_resolution;
-        if (fr >= f0 && fr < f1) {
+        if (fr >= startFrame && fr < startFrame + duration) {
             QStringList list;
             for (int j = 0; in_range_for(m_data.at(i), j); ++j) {
                 list << QString("%1").arg(m_data.at(i).at(j));
@@ -526,7 +527,11 @@
 {
     QReadLocker locker(&m_lock);
 
-    // For historical reasons we read and write "resolution" as "windowSize"
+    // For historical reasons we read and write "resolution" as "windowSize".
+
+    // Our dataset doesn't have its own export ID, we just use
+    // ours. Actually any model could do that, since datasets aren't
+    // in the same id-space as models when re-read
 
     SVDEBUG << "EditableDenseThreeDimensionalModel::toXml" << endl;
 
@@ -537,13 +542,13 @@
          .arg(m_yBinCount)
          .arg(m_minimum)
          .arg(m_maximum)
-         .arg(getObjectExportId(&m_data))
+         .arg(getExportId())
          .arg(m_startFrame)
          .arg(extraAttributes));
 
     out << indent;
     out << QString("<dataset id=\"%1\" dimensions=\"3\" separator=\" \">\n")
-        .arg(getObjectExportId(&m_data));
+        .arg(getExportId());
 
     for (int i = 0; i < (int)m_binNames.size(); ++i) {
         if (m_binNames[i] != "") {
--- a/data/model/EditableDenseThreeDimensionalModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/EditableDenseThreeDimensionalModel.h	Thu May 16 11:07:47 2019 +0100
@@ -49,6 +49,9 @@
                                        bool notifyOnAdd = true);
 
     bool isOK() const override;
+    bool isReady(int *completion = 0) const override;
+    void setCompletion(int completion, bool update = true);
+    int getCompletion() const override;
 
     sv_samplerate_t getSampleRate() const override;
     sv_frame_t getStartFrame() const override;
@@ -183,13 +186,12 @@
      */
     bool shouldUseLogValueScale() const override;
 
-    virtual void setCompletion(int completion, bool update = true);
-    int getCompletion() const override { return m_completion; }
-
     QString getTypeName() const override { return tr("Editable Dense 3-D"); }
 
-    QString toDelimitedDataString(QString delimiter) const override;
-    QString toDelimitedDataStringSubset(QString delimiter, sv_frame_t f0, sv_frame_t f1) const override;
+    QString toDelimitedDataString(QString delimiter,
+                                  DataExportOptions options,
+                                  sv_frame_t startFrame,
+                                  sv_frame_t duration) const override;
 
     void toXml(QTextStream &out,
                        QString indent = "",
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/data/model/EventCommands.h	Thu May 16 11:07:47 2019 +0100
@@ -0,0 +1,140 @@
+/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
+
+/*
+    Sonic Visualiser
+    An audio file viewer and annotation editor.
+    Centre for Digital Music, Queen Mary, University of London.
+    This file copyright 2006 Chris Cannam.
+    
+    This program is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public License as
+    published by the Free Software Foundation; either version 2 of the
+    License, or (at your option) any later version.  See the file
+    COPYING included with this distribution for more information.
+*/
+
+#ifndef SV_EVENT_COMMANDS_H
+#define SV_EVENT_COMMANDS_H
+
+#include "base/Event.h"
+#include "base/Command.h"
+
+/**
+ * Interface for classes that can be modified through these commands
+ */
+class EventEditable
+{
+public:
+    virtual void add(Event e) = 0;
+    virtual void remove(Event e) = 0;
+};
+
+/**
+ * Command to add an event to an editable containing events, with undo.
+ */
+class AddEventCommand : public Command
+{
+public:
+    AddEventCommand(EventEditable *editable, const Event &e, QString name) :
+        m_editable(editable), m_event(e), m_name(name) { }
+
+    QString getName() const override { return m_name; }
+    Event getEvent() const { return m_event; }
+
+    void execute() override { m_editable->add(m_event); }
+    void unexecute() override { m_editable->remove(m_event); }
+
+private:
+    EventEditable *m_editable;
+    Event m_event;
+    QString m_name;
+};
+
+/**
+ * Command to remove an event from an editable containing events, with
+ * undo.
+ */
+class RemoveEventCommand : public Command
+{
+public:
+    RemoveEventCommand(EventEditable *editable, const Event &e, QString name) :
+        m_editable(editable), m_event(e), m_name(name) { }
+
+    QString getName() const override { return m_name; }
+    Event getEvent() const { return m_event; }
+
+    void execute() override { m_editable->remove(m_event); }
+    void unexecute() override { m_editable->add(m_event); }
+
+private:
+    EventEditable *m_editable;
+    Event m_event;
+    QString m_name;
+};
+
+/**
+ * Command to add or remove a series of events to or from an editable,
+ * with undo. Creates and immediately executes a sub-command for each
+ * add/remove requested. Consecutive add/remove pairs for the same
+ * point are collapsed.
+ */
+class ChangeEventsCommand : public MacroCommand
+{
+public:
+    ChangeEventsCommand(EventEditable *editable, QString name) :
+        MacroCommand(name), m_editable(editable) { }
+
+    void add(Event e) {
+        addCommand(new AddEventCommand(m_editable, e, getName()), true);
+    }
+    void remove(Event e) {
+        addCommand(new RemoveEventCommand(m_editable, e, getName()), true);
+    }
+
+    /**
+     * Stack an arbitrary other command in the same sequence.
+     */
+    void addCommand(Command *command) override { addCommand(command, true); }
+
+    /**
+     * If any points have been added or deleted, return this
+     * command (so the caller can add it to the command history).
+     * Otherwise delete the command and return NULL.
+     */
+    ChangeEventsCommand *finish() {
+        if (!m_commands.empty()) {
+            return this;
+        } else {
+            delete this;
+            return nullptr;
+        }
+    }
+
+protected:
+    virtual void addCommand(Command *command, bool executeFirst) {
+        
+        if (executeFirst) command->execute();
+
+        if (m_commands.empty()) {
+            MacroCommand::addCommand(command);
+            return;
+        }
+        
+        RemoveEventCommand *r =
+            dynamic_cast<RemoveEventCommand *>(command);
+        AddEventCommand *a =
+            dynamic_cast<AddEventCommand *>(*m_commands.rbegin());
+        if (r && a) {
+            if (a->getEvent() == r->getEvent()) {
+                deleteCommand(a);
+                return;
+            }
+        }
+        
+        MacroCommand::addCommand(command);
+    }
+
+    EventEditable *m_editable;
+};
+
+#endif
--- a/data/model/FFTModel.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/FFTModel.cpp	Thu May 16 11:07:47 2019 +0100
@@ -55,8 +55,8 @@
     
     if (m_windowSize > m_fftSize) {
         SVCERR << "ERROR: FFTModel::FFTModel: window size (" << m_windowSize
-               << ") must be at least FFT size (" << m_fftSize << ")" << endl;
-        throw invalid_argument("FFTModel window size must be at least FFT size");
+               << ") may not exceed FFT size (" << m_fftSize << ")" << endl;
+        throw invalid_argument("FFTModel window size may not exceed FFT size");
     }
 
     m_fft.initFloat();
--- a/data/model/FFTModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/FFTModel.h	Thu May 16 11:07:47 2019 +0100
@@ -88,6 +88,10 @@
     }
     virtual QString getError() const { return ""; } //!!!???
     virtual sv_frame_t getFillExtent() const { return getEndFrame(); }
+    QString toDelimitedDataString(QString, DataExportOptions,
+                                  sv_frame_t, sv_frame_t) const override {
+        return "";
+    }
 
     // FFTModel methods:
     //
--- a/data/model/FlexiNoteModel.h	Tue May 07 15:54:15 2019 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,268 +0,0 @@
-/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
-
-/*
-    Sonic Visualiser
-    An audio file viewer and annotation editor.
-    Centre for Digital Music, Queen Mary, University of London.
-    This file copyright 2006 Chris Cannam.
-    
-    This program is free software; you can redistribute it and/or
-    modify it under the terms of the GNU General Public License as
-    published by the Free Software Foundation; either version 2 of the
-    License, or (at your option) any later version.  See the file
-    COPYING included with this distribution for more information.
-*/
-
-#ifndef SV_FLEXINOTE_MODEL_H
-#define SV_FLEXINOTE_MODEL_H
-
-#include "IntervalModel.h"
-#include "NoteData.h"
-#include "base/RealTime.h"
-#include "base/Pitch.h"
-#include "base/PlayParameterRepository.h"
-
-/**
- * FlexiNoteModel -- a concrete IntervalModel for notes.
- */
-
-/**
- * Extension of the NoteModel for more flexible note interaction. 
- * The original NoteModel rationale is given below, will need to be
- * updated for FlexiNoteModel:
- *
- * Note type for use in a sparse model.  All we mean by a "note" is
- * something that has an onset time, a single value, a duration, and a
- * level.  Like other points, it can also have a label.  With this
- * point type, the model can be thought of as representing a simple
- * MIDI-type piano roll, except that the y coordinates (values) do not
- * have to be discrete integers.
- */
-
-struct FlexiNote
-{
-public:
-    FlexiNote(sv_frame_t _frame) : frame(_frame), value(0.0f), duration(0), level(1.f) { }
-    FlexiNote(sv_frame_t _frame, float _value, sv_frame_t _duration, float _level, QString _label) :
-        frame(_frame), value(_value), duration(_duration), level(_level), label(_label) { }
-
-    int getDimensions() const { return 3; }
-
-    sv_frame_t frame;
-    float value;
-    sv_frame_t duration;
-    float level;
-    QString label;
-
-    QString getLabel() const { return label; }
-    
-    void toXml(QTextStream &stream,
-               QString indent = "",
-               QString extraAttributes = "") const
-    {
-        stream <<
-            QString("%1<point frame=\"%2\" value=\"%3\" duration=\"%4\" level=\"%5\" label=\"%6\" %7/>\n")
-            .arg(indent).arg(frame).arg(value).arg(duration).arg(level)
-            .arg(XmlExportable::encodeEntities(label)).arg(extraAttributes);
-    }
-
-    QString toDelimitedDataString(QString delimiter, DataExportOptions opts, sv_samplerate_t sampleRate) const
-    {
-        QStringList list;
-        list << RealTime::frame2RealTime(frame, sampleRate).toString().c_str();
-        list << QString("%1").arg(value);
-        list << RealTime::frame2RealTime(duration, sampleRate).toString().c_str();
-        if (!(opts & DataExportOmitLevels)) {
-            list << QString("%1").arg(level);
-        }
-        if (label != "") list << label;
-        return list.join(delimiter);
-    }
-
-    struct Comparator {
-        bool operator()(const FlexiNote &p1,
-                        const FlexiNote &p2) const {
-            if (p1.frame != p2.frame) return p1.frame < p2.frame;
-            if (p1.value != p2.value) return p1.value < p2.value;
-            if (p1.duration != p2.duration) return p1.duration < p2.duration;
-            if (p1.level != p2.level) return p1.level < p2.level;
-            return p1.label < p2.label;
-        }
-    };
-    
-    struct OrderComparator {
-        bool operator()(const FlexiNote &p1,
-                        const FlexiNote &p2) const {
-            return p1.frame < p2.frame;
-        }
-    };
-};
-
-
-class FlexiNoteModel : public IntervalModel<FlexiNote>, public NoteExportable
-{
-    Q_OBJECT
-    
-public:
-    FlexiNoteModel(sv_samplerate_t sampleRate, int resolution,
-                   bool notifyOnAdd = true) :
-        IntervalModel<FlexiNote>(sampleRate, resolution, notifyOnAdd),
-        m_valueQuantization(0)
-    {
-        PlayParameterRepository::getInstance()->addPlayable(this);
-    }
-
-    FlexiNoteModel(sv_samplerate_t sampleRate, int resolution,
-              float valueMinimum, float valueMaximum,
-              bool notifyOnAdd = true) :
-        IntervalModel<FlexiNote>(sampleRate, resolution,
-                            valueMinimum, valueMaximum,
-                            notifyOnAdd),
-        m_valueQuantization(0)
-    {
-        PlayParameterRepository::getInstance()->addPlayable(this);
-    }
-
-    virtual ~FlexiNoteModel()
-    {
-        PlayParameterRepository::getInstance()->removePlayable(this);
-    }
-
-    float getValueQuantization() const { return m_valueQuantization; }
-    void setValueQuantization(float q) { m_valueQuantization = q; }
-    float getValueMinimum() const override { return 33; }
-    float getValueMaximum() const override { return 88; }
-
-    QString getTypeName() const override { return tr("FlexiNote"); }
-
-    bool canPlay() const override { return true; }
-
-    QString getDefaultPlayClipId() const override
-    {
-        return "elecpiano";
-    }
-
-    void toXml(QTextStream &out,
-                       QString indent = "",
-                       QString extraAttributes = "") const override
-    {
-        std::cerr << "FlexiNoteModel::toXml: extraAttributes = \"" 
-                  << extraAttributes.toStdString() << std::endl;
-
-        IntervalModel<FlexiNote>::toXml
-            (out,
-             indent,
-             QString("%1 subtype=\"flexinote\" valueQuantization=\"%2\"")
-             .arg(extraAttributes).arg(m_valueQuantization));
-    }
-
-    /**
-     * TabularModel methods.  
-     */
-    
-    int getColumnCount() const override
-    {
-        return 6;
-    }
-
-    QString getHeading(int column) const override
-    {
-        switch (column) {
-        case 0: return tr("Time");
-        case 1: return tr("Frame");
-        case 2: return tr("Pitch");
-        case 3: return tr("Duration");
-        case 4: return tr("Level");
-        case 5: return tr("Label");
-        default: return tr("Unknown");
-        }
-    }
-
-    QVariant getData(int row, int column, int role) const override
-    {
-        if (column < 4) {
-            return IntervalModel<FlexiNote>::getData(row, column, role);
-        }
-
-        PointListConstIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return QVariant();
-
-        switch (column) {
-        case 4: return i->level;
-        case 5: return i->label;
-        default: return QVariant();
-        }
-    }
-
-    Command *getSetDataCommand(int row, int column, const QVariant &value, int role) override
-    {
-        if (column < 4) {
-            return IntervalModel<FlexiNote>::getSetDataCommand
-                (row, column, value, role);
-        }
-
-        if (role != Qt::EditRole) return 0;
-        PointListConstIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return 0;
-        EditCommand *command = new EditCommand(this, tr("Edit Data"));
-
-        Point point(*i);
-        command->deletePoint(point);
-
-        switch (column) {
-        case 4: point.level = float(value.toDouble()); break;
-        case 5: point.label = value.toString(); break;
-        }
-
-        command->addPoint(point);
-        return command->finish();
-    }
-
-    SortType getSortType(int column) const override
-    {
-        if (column == 5) return SortAlphabetical;
-        return SortNumeric;
-    }
-
-    /**
-     * NoteExportable methods.
-     */
-
-    NoteList getNotes() const 
-    override {
-        return getNotesWithin(getStartFrame(), getEndFrame());
-    }
-
-    NoteList getNotesWithin(sv_frame_t startFrame, sv_frame_t endFrame) const 
-    override {    
-            PointList points = getPoints(startFrame, endFrame);
-        NoteList notes;
-        for (PointList::iterator pli = points.begin(); pli != points.end(); ++pli) {
-                sv_frame_t duration = pli->duration;
-            if (duration == 0 || duration == 1) {
-                duration = sv_frame_t(getSampleRate() / 20);
-            }
-            int pitch = int(lrintf(pli->value));
-
-            int velocity = 100;
-            if (pli->level > 0.f && pli->level <= 1.f) {
-                velocity = int(lrintf(pli->level * 127));
-            }
-
-            NoteData note(pli->frame, duration, pitch, velocity);
-
-            if (getScaleUnits() == "Hz") {
-                note.frequency = pli->value;
-                note.midiPitch = Pitch::getPitchForFrequency(note.frequency);
-                note.isMidiPitchQuantized = false;
-            }
-            notes.push_back(note);
-        }
-        return notes;
-    }
-
-protected:
-    float m_valueQuantization;
-};
-
-#endif
--- a/data/model/ImageModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/ImageModel.h	Thu May 16 11:07:47 2019 +0100
@@ -16,138 +16,174 @@
 #ifndef SV_IMAGE_MODEL_H
 #define SV_IMAGE_MODEL_H
 
-#include "SparseModel.h"
+#include "EventCommands.h"
+#include "TabularModel.h"
+#include "Model.h"
+#include "DeferredNotifier.h"
+
+#include "base/EventSeries.h"
 #include "base/XmlExportable.h"
 #include "base/RealTime.h"
 
+#include "system/System.h"
+
 #include <QStringList>
 
 /**
- * Image point type for use in a SparseModel.  This represents an
- * image, identified by filename, at a given time.  The filename can
- * be empty, in which case we instead have a space to put an image in.
+ * A model representing image annotations, identified by filename or
+ * URI, at a given time, with an optional label. The filename can be
+ * empty, in which case we instead have a space to put an image in.
  */
 
-struct ImagePoint : public XmlExportable
-{
-public:
-    ImagePoint(sv_frame_t _frame) : frame(_frame) { }
-    ImagePoint(sv_frame_t _frame, QString _image, QString _label) :
-        frame(_frame), image(_image), label(_label) { }
-
-    int getDimensions() const { return 1; }
-    
-    sv_frame_t frame;
-    QString image;
-    QString label;
-
-    QString getLabel() const { return label; }
-    
-    void toXml(QTextStream &stream,
-               QString indent = "",
-               QString extraAttributes = "") const override
-    {
-        stream <<
-            QString("%1<point frame=\"%2\" image=\"%3\" label=\"%4\" %5/>\n")
-            .arg(indent).arg(frame)
-            .arg(encodeEntities(image))
-            .arg(encodeEntities(label))
-            .arg(extraAttributes);
-    }
-
-    QString toDelimitedDataString(QString delimiter, DataExportOptions, sv_samplerate_t sampleRate) const
-    {
-        QStringList list;
-        list << RealTime::frame2RealTime(frame, sampleRate).toString().c_str();
-        list << image;
-        if (label != "") list << label;
-        return list.join(delimiter);
-    }
-
-    struct Comparator {
-        bool operator()(const ImagePoint &p1,
-                        const ImagePoint &p2) const {
-            if (p1.frame != p2.frame) return p1.frame < p2.frame;
-            if (p1.label != p2.label) return p1.label < p2.label;
-            return p1.image < p2.image;
-        }
-    };
-    
-    struct OrderComparator {
-        bool operator()(const ImagePoint &p1,
-                        const ImagePoint &p2) const {
-            return p1.frame < p2.frame;
-        }
-    };
-};
-
-
-// Make this a class rather than a typedef so it can be predeclared.
-
-class ImageModel : public SparseModel<ImagePoint>
+class ImageModel : public Model,
+                   public TabularModel,
+                   public EventEditable
 {
     Q_OBJECT
 
 public:
-    ImageModel(sv_samplerate_t sampleRate, int resolution, bool notifyOnAdd = true) :
-        SparseModel<ImagePoint>(sampleRate, resolution, notifyOnAdd)
-    { }
+    ImageModel(sv_samplerate_t sampleRate,
+               int resolution,
+               bool notifyOnAdd = true) :
+        m_sampleRate(sampleRate),
+        m_resolution(resolution),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
+        m_completion(100) {
+    }
 
     QString getTypeName() const override { return tr("Image"); }
+    bool isSparse() const override { return true; }
+    bool isOK() const override { return true; }
 
-    void toXml(QTextStream &out,
-                       QString indent = "",
-                       QString extraAttributes = "") const override
-    {
-        SparseModel<ImagePoint>::toXml
-            (out, 
-             indent,
-             QString("%1 subtype=\"image\"")
-             .arg(extraAttributes));
+    sv_frame_t getStartFrame() const override {
+        return m_events.getStartFrame();
+    }
+    sv_frame_t getEndFrame() const override {
+        if (m_events.isEmpty()) return 0;
+        sv_frame_t e = m_events.getEndFrame() + 1;
+        if (e % m_resolution == 0) return e;
+        else return (e / m_resolution + 1) * m_resolution;
+    }
+    
+    sv_samplerate_t getSampleRate() const override { return m_sampleRate; }
+    int getResolution() const { return m_resolution; }
+    
+    int getCompletion() const override { return m_completion; }
+
+    void setCompletion(int completion, bool update = true) {
+        
+        {   QMutexLocker locker(&m_mutex);
+            if (m_completion == completion) return;
+            m_completion = completion;
+        }
+
+        if (update) {
+            m_notifier.makeDeferredNotifications();
+        }
+        
+        emit completionChanged();
+
+        if (completion == 100) {
+            // henceforth:
+            m_notifier.switchMode(DeferredNotifier::NOTIFY_ALWAYS);
+            emit modelChanged();
+        }
+    }
+    
+    /**
+     * Query methods.
+     */
+
+    //!!! todo: go through all models, weeding out the methods here
+    //!!! that are not used
+    
+    int getEventCount() const {
+        return m_events.count();
+    }
+    bool isEmpty() const {
+        return m_events.isEmpty();
+    }
+    bool containsEvent(const Event &e) const {
+        return m_events.contains(e);
+    }
+    EventVector getAllEvents() const {
+        return m_events.getAllEvents();
+    }
+    EventVector getEventsSpanning(sv_frame_t f, sv_frame_t duration) const {
+        return m_events.getEventsSpanning(f, duration);
+    }
+    EventVector getEventsCovering(sv_frame_t f) const {
+        return m_events.getEventsCovering(f);
+    }
+    EventVector getEventsWithin(sv_frame_t f, sv_frame_t duration,
+                                int overspill = 0) const {
+        return m_events.getEventsWithin(f, duration, overspill);
+    }
+    EventVector getEventsStartingWithin(sv_frame_t f, sv_frame_t duration) const {
+        return m_events.getEventsStartingWithin(f, duration);
+    }
+    EventVector getEventsStartingAt(sv_frame_t f) const {
+        return m_events.getEventsStartingAt(f);
+    }
+    bool getNearestEventMatching(sv_frame_t startSearchAt,
+                                 std::function<bool(Event)> predicate,
+                                 EventSeries::Direction direction,
+                                 Event &found) const {
+        return m_events.getNearestEventMatching
+            (startSearchAt, predicate, direction, found);
     }
 
     /**
-     * Command to change the image for a point.
+     * Editing methods.
      */
-    class ChangeImageCommand : public Command
-    {
-    public:
-        ChangeImageCommand(ImageModel *model,
-                           const ImagePoint &point,
-                           QString newImage,
-                           QString newLabel) :
-            m_model(model), m_oldPoint(point), m_newPoint(point) {
-            m_newPoint.image = newImage;
-            m_newPoint.label = newLabel;
+    void add(Event e) override {
+
+        {   QMutexLocker locker(&m_mutex);
+            m_events.add(e.withoutDuration().withoutValue().withoutLevel());
         }
-
-        QString getName() const override { return tr("Edit Image"); }
-
-        void execute() override { 
-            m_model->deletePoint(m_oldPoint);
-            m_model->addPoint(m_newPoint);
-            std::swap(m_oldPoint, m_newPoint);
+        
+        m_notifier.update(e.getFrame(), m_resolution);
+    }
+    
+    void remove(Event e) override {
+        {   QMutexLocker locker(&m_mutex);
+            m_events.remove(e);
         }
-
-        void unexecute() override { execute(); }
-
-    private:
-        ImageModel *m_model;
-        ImagePoint m_oldPoint;
-        ImagePoint m_newPoint;
-    };
+        emit modelChangedWithin(e.getFrame(), e.getFrame() + m_resolution);
+    }
 
     /**
      * TabularModel methods.  
      */
     
-    int getColumnCount() const override
-    {
+    int getRowCount() const override {
+        return m_events.count();
+    }
+
+    int getColumnCount() const override {
         return 4;
     }
 
-    QString getHeading(int column) const override
-    {
+    bool isColumnTimeValue(int column) const override {
+        return (column < 2);
+    }
+
+    sv_frame_t getFrameForRow(int row) const override {
+        if (row < 0 || row >= m_events.count()) {
+            return 0;
+        }
+        Event e = m_events.getEventByIndex(row);
+        return e.getFrame();
+    }
+
+    int getRowForFrame(sv_frame_t frame) const override {
+        return m_events.getIndexForEvent(Event(frame));
+    }
+    
+    QString getHeading(int column) const override {
         switch (column) {
         case 0: return tr("Time");
         case 1: return tr("Frame");
@@ -157,61 +193,100 @@
         }
     }
 
-    QVariant getData(int row, int column, int role) const override
-    {
-        if (column < 2) {
-            return SparseModel<ImagePoint>::getData
-                (row, column, role);
+    SortType getSortType(int column) const override {
+        if (column >= 2) return SortAlphabetical;
+        return SortNumeric;
+    }
+
+    QVariant getData(int row, int column, int role) const override {
+        
+        if (row < 0 || row >= m_events.count()) {
+            return QVariant();
         }
 
-        PointListConstIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return QVariant();
+        Event e = m_events.getEventByIndex(row);
 
         switch (column) {
-        case 2: return i->image;
-        case 3: return i->label;
+        case 0: return adaptFrameForRole(e.getFrame(), getSampleRate(), role);
+        case 1: return int(e.getFrame());
+        case 2: return e.getURI();
+        case 3: return e.getLabel();
         default: return QVariant();
         }
     }
 
-    Command *getSetDataCommand(int row, int column, const QVariant &value, int role) override
-    {
-        if (column < 2) {
-            return SparseModel<ImagePoint>::getSetDataCommand
-                (row, column, value, role);
+    Command *getSetDataCommand(int row, int column, const QVariant &value, int role) override {
+        
+        if (row < 0 || row >= m_events.count()) return nullptr;
+        if (role != Qt::EditRole) return nullptr;
+
+        Event e0 = m_events.getEventByIndex(row);
+        Event e1;
+
+        switch (column) {
+        case 0: e1 = e0.withFrame(sv_frame_t(round(value.toDouble() *
+                                                   getSampleRate()))); break;
+        case 1: e1 = e0.withFrame(value.toInt()); break;
+        case 2: e1 = e0.withURI(value.toString()); break;
+        case 3: e1 = e0.withLabel(value.toString()); break;
         }
 
-        if (role != Qt::EditRole) return 0;
-        PointListIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return 0;
-        EditCommand *command = new EditCommand(this, tr("Edit Data"));
-
-        Point point(*i);
-        command->deletePoint(point);
-
-        switch (column) {
-        case 2: point.image = value.toString(); break;
-        case 3: point.label = value.toString(); break;
-        }
-
-        command->addPoint(point);
+        ChangeEventsCommand *command =
+            new ChangeEventsCommand(this, tr("Edit Data"));
+        command->remove(e0);
+        command->add(e1);
         return command->finish();
     }
+    
+    /**
+     * XmlExportable methods.
+     */
+    void toXml(QTextStream &out,
+               QString indent = "",
+               QString extraAttributes = "") const override {
 
-    bool isColumnTimeValue(int column) const override
-    {
-        return (column < 2); 
+        Model::toXml
+            (out,
+             indent,
+             QString("type=\"sparse\" dimensions=\"1\" resolution=\"%1\" "
+                     "notifyOnAdd=\"%2\" dataset=\"%3\" subtype=\"image\" %4")
+             .arg(m_resolution)
+             .arg("true") // always true after model reaches 100% -
+                          // subsequent events are always notified
+             .arg(m_events.getExportId())
+             .arg(extraAttributes));
+
+        Event::ExportNameOptions options;
+        options.uriAttributeName = "image";
+        
+        m_events.toXml(out, indent, QString("dimensions=\"1\""), options);
     }
 
-    SortType getSortType(int column) const override
-    {
-        if (column > 2) return SortAlphabetical;
-        return SortNumeric;
+    QString toDelimitedDataString(QString delimiter,
+                                  DataExportOptions options,
+                                  sv_frame_t startFrame,
+                                  sv_frame_t duration) const override {
+        return m_events.toDelimitedDataString(delimiter,
+                                              options,
+                                              startFrame,
+                                              duration,
+                                              m_sampleRate,
+                                              m_resolution,
+                                              Event().withValue(0.f));
     }
+    
+protected:
+    sv_samplerate_t m_sampleRate;
+    int m_resolution;
+
+    DeferredNotifier m_notifier;
+    int m_completion;
+
+    EventSeries m_events;
+
+    mutable QMutex m_mutex;  
 };
 
 
 #endif
 
-
-    
--- a/data/model/IntervalModel.h	Tue May 07 15:54:15 2019 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,194 +0,0 @@
-/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
-
-/*
-    Sonic Visualiser
-    An audio file viewer and annotation editor.
-    Centre for Digital Music, Queen Mary, University of London.
-    This file copyright 2006-2008 Chris Cannam and QMUL.
-    
-    This program is free software; you can redistribute it and/or
-    modify it under the terms of the GNU General Public License as
-    published by the Free Software Foundation; either version 2 of the
-    License, or (at your option) any later version.  See the file
-    COPYING included with this distribution for more information.
-*/
-
-#ifndef SV_INTERVAL_MODEL_H
-#define SV_INTERVAL_MODEL_H
-
-#include "SparseValueModel.h"
-#include "base/RealTime.h"
-
-/**
- * Model containing sparse data (points with some properties) of which
- * the properties include a duration and an arbitrary float value.
- * The other properties depend on the point type.
- */
-
-template <typename PointType>
-class IntervalModel : public SparseValueModel<PointType>
-{
-public:
-    IntervalModel(sv_samplerate_t sampleRate, int resolution,
-                  bool notifyOnAdd = true) :
-        SparseValueModel<PointType>(sampleRate, resolution, notifyOnAdd)
-    { }
-
-    IntervalModel(sv_samplerate_t sampleRate, int resolution,
-                  float valueMinimum, float valueMaximum,
-                  bool notifyOnAdd = true) :
-        SparseValueModel<PointType>(sampleRate, resolution,
-                                    valueMinimum, valueMaximum,
-                                    notifyOnAdd)
-    { }
-
-    /**
-     * PointTypes have a duration, so this returns all points that span any
-     * of the given range (as well as the usual additional few before
-     * and after).  Consequently this can be very slow (optimised data
-     * structures still to be done!).
-     */
-    typename SparseValueModel<PointType>::PointList getPoints(sv_frame_t start, sv_frame_t end) const override;
-
-    /**
-     * PointTypes have a duration, so this returns all points that span the
-     * given frame.  Consequently this can be very slow (optimised
-     * data structures still to be done!).
-     */
-    typename SparseValueModel<PointType>::PointList getPoints(sv_frame_t frame) const override;
-
-    const typename SparseModel<PointType>::PointList &getPoints() const override {
-        return SparseModel<PointType>::getPoints(); 
-    }
-
-    /**
-     * TabularModel methods.  
-     */
-
-    QVariant getData(int row, int column, int role) const override
-    {
-        if (column < 2) {
-            return SparseValueModel<PointType>::getData
-                (row, column, role);
-        }
-
-        typename SparseModel<PointType>::PointList::const_iterator i
-            = SparseModel<PointType>::getPointListIteratorForRow(row);
-        if (i == SparseModel<PointType>::m_points.end()) return QVariant();
-
-        switch (column) {
-        case 2:
-            if (role == Qt::EditRole || role == TabularModel::SortRole) return i->value;
-            else return QString("%1 %2").arg(i->value).arg
-                     (IntervalModel<PointType>::getScaleUnits());
-        case 3: return int(i->duration); //!!! could be better presented
-        default: return QVariant();
-        }
-    }
-
-    Command *getSetDataCommand(int row, int column, const QVariant &value, int role) override
-    {
-        typedef IntervalModel<PointType> I;
-
-        if (column < 2) {
-            return SparseValueModel<PointType>::getSetDataCommand
-                (row, column, value, role);
-        }
-
-        if (role != Qt::EditRole) return 0;
-        typename I::PointList::const_iterator i
-            = I::getPointListIteratorForRow(row);
-        if (i == I::m_points.end()) return 0;
-        typename I::EditCommand *command = new typename I::EditCommand
-            (this, I::tr("Edit Data"));
-
-        PointType point(*i);
-        command->deletePoint(point);
-
-        switch (column) {
-        // column cannot be 0 or 1, those cases were handled above
-        case 2: point.value = float(value.toDouble()); break;
-        case 3: point.duration = value.toInt(); break;
-        }
-
-        command->addPoint(point);
-        return command->finish();
-    }
-
-    bool isColumnTimeValue(int column) const override
-    {
-        // NB duration is not a "time value" -- that's for columns
-        // whose sort ordering is exactly that of the frame time
-        return (column < 2);
-    }
-};
-
-template <typename PointType>
-typename SparseValueModel<PointType>::PointList
-IntervalModel<PointType>::getPoints(sv_frame_t start, sv_frame_t end) const
-{
-    typedef IntervalModel<PointType> I;
-
-    if (start > end) return typename I::PointList();
-
-    QMutex &mutex(I::m_mutex);
-    QMutexLocker locker(&mutex);
-
-    PointType endPoint(end);
-    
-    typename I::PointListConstIterator endItr = I::m_points.upper_bound(endPoint);
-
-    if (endItr != I::m_points.end()) ++endItr;
-    if (endItr != I::m_points.end()) ++endItr;
-
-    typename I::PointList rv;
-
-    for (typename I::PointListConstIterator i = endItr; i != I::m_points.begin(); ) {
-        --i;
-        if (i->frame < start) {
-            if (i->frame + i->duration >= start) {
-                rv.insert(*i);
-            }
-        } else if (i->frame <= end) {
-            rv.insert(*i);
-        }
-    }
-
-    return rv;
-}
-
-template <typename PointType>
-typename SparseValueModel<PointType>::PointList
-IntervalModel<PointType>::getPoints(sv_frame_t frame) const
-{
-    typedef IntervalModel<PointType> I;
-
-    QMutex &mutex(I::m_mutex);
-    QMutexLocker locker(&mutex);
-
-    if (I::m_resolution == 0) return typename I::PointList();
-
-    sv_frame_t start = (frame / I::m_resolution) * I::m_resolution;
-    sv_frame_t end = start + I::m_resolution;
-
-    PointType endPoint(end);
-    
-    typename I::PointListConstIterator endItr = I::m_points.upper_bound(endPoint);
-
-    typename I::PointList rv;
-
-    for (typename I::PointListConstIterator i = endItr; i != I::m_points.begin(); ) {
-        --i;
-        if (i->frame < start) {
-            if (i->frame + i->duration >= start) {
-                rv.insert(*i);
-            }
-        } else if (i->frame <= end) {
-            rv.insert(*i);
-        }
-    }
-
-    return rv;
-}
-
-#endif
--- a/data/model/Labeller.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/Labeller.h	Thu May 16 11:07:47 2019 +0100
@@ -16,10 +16,10 @@
 #ifndef SV_LABELLER_H
 #define SV_LABELLER_H
 
-#include "SparseModel.h"
-#include "SparseValueModel.h"
+#include "base/Selection.h"
+#include "base/Event.h"
 
-#include "base/Selection.h"
+#include "EventCommands.h"
 
 #include <QObject>
 
@@ -62,6 +62,9 @@
     //
     // 4. re-label a set of points that have already been added to a
     // model
+    //
+    // 5. generate new labelled points in the gaps between other
+    // points (subdivide), or remove them (winnow)
 
     Labeller(ValueType type = ValueNone) :
         m_type(type),
@@ -151,113 +154,160 @@
         }
     }
 
-    template <typename PointType>
-    void label(PointType &newPoint, PointType *prevPoint = 0) {
+    enum Application {
+        AppliesToThisEvent,
+        AppliesToPreviousEvent
+    };
+    typedef std::pair<Application, Event> Relabelling;
+    typedef std::pair<Application, Event> Revaluing;
+
+    /** 
+     * Return a labelled event based on the given event, previous
+     * event if supplied, and internal labeller state. The returned
+     * event replaces either the event provided or the previous event,
+     * depending on the Application value in the returned pair.
+     */
+    Relabelling
+    label(Event e, const Event *prev = nullptr) {
+
+        QString label = e.getLabel();
+
         if (m_type == ValueNone) {
-            newPoint.label = "";
+            label = "";
         } else if (m_type == ValueFromTwoLevelCounter) {
-            newPoint.label = tr("%1.%2").arg(m_counter2).arg(m_counter);
+            label = tr("%1.%2").arg(m_counter2).arg(m_counter);
             incrementCounter();
         } else if (m_type == ValueFromFrameNumber) {
             // avoid going through floating-point value
-            newPoint.label = tr("%1").arg(newPoint.frame);
+            label = tr("%1").arg(e.getFrame());
         } else {
-            float value = getValueFor<PointType>(newPoint, prevPoint);
-            if (actingOnPrevPoint() && prevPoint) {
-                prevPoint->label = QString("%1").arg(value);
-            } else {
-                newPoint.label = QString("%1").arg(value);
-            }
+            float value = getValueFor(e, prev);
+            label = QString("%1").arg(value);
+        }
+
+        if (actingOnPrevEvent() && prev) {
+            return { AppliesToPreviousEvent, prev->withLabel(label) };
+        } else {
+            return { AppliesToThisEvent, e.withLabel(label) };
         }
     }
+
+    /** 
+     * Return an event with a value following the labelling scheme,
+     * based on the given event, previous event if supplied, and
+     * internal labeller state. The returned event replaces either the
+     * event provided or the previous event, depending on the
+     * Application value in the returned pair.
+     */
+    Revaluing
+    revalue(Event e, const Event *prev = nullptr) {
+
+        float value = e.getValue();
         
+        if (m_type == ValueFromExistingNeighbour) {
+            if (!prev) {
+                std::cerr << "ERROR: Labeller::setValue: Previous point required but not provided" << std::endl;
+            } else {
+                return { AppliesToThisEvent, e.withValue(prev->getValue()) };
+            }
+        } else {
+            value = getValueFor(e, prev);
+        }
+
+        if (actingOnPrevEvent() && prev) {
+            return { AppliesToPreviousEvent, prev->withValue(value) };
+        } else {
+            return { AppliesToThisEvent, e.withValue(value) };
+        }
+    }
+    
     /**
-     * Relabel all points in the given model that lie within the given
-     * multi-selection, according to the labelling properties of this
-     * labeller.  Return a command that has been executed but not yet
-     * added to the history.
+     * Relabel all events in the given event vector that lie within
+     * the given multi-selection, according to the labelling
+     * properties of this labeller.  Return a command that has been
+     * executed but not yet added to the history.
      */
-    template <typename PointType>
-    Command *labelAll(SparseModel<PointType> &model, MultiSelection *ms) {
+    Command *labelAll(EventEditable *editable,
+                      MultiSelection *ms,
+                      const EventVector &allEvents) {
 
-        auto points(model.getPoints());
-        auto command = new typename SparseModel<PointType>::EditCommand
-            (&model, tr("Label Points"));
+        ChangeEventsCommand *command = new ChangeEventsCommand
+            (editable, tr("Label Points"));
 
-        PointType prevPoint(0);
-        bool havePrevPoint(false);
+        Event prev;
+        bool havePrev = false;
 
-        for (auto p: points) {
+        for (auto p: allEvents) {
 
             if (ms) {
-                Selection s(ms->getContainingSelection(p.frame, false));
-                if (!s.contains(p.frame)) {
-                    prevPoint = p;
-                    havePrevPoint = true;
+                Selection s(ms->getContainingSelection(p.getFrame(), false));
+                if (!s.contains(p.getFrame())) {
+                    prev = p;
+                    havePrev = true;
                     continue;
                 }
             }
 
-            if (actingOnPrevPoint()) {
-                if (havePrevPoint) {
-                    command->deletePoint(prevPoint);
-                    label<PointType>(p, &prevPoint);
-                    command->addPoint(prevPoint);
-                }
+            auto labelling = label(p, havePrev ? &prev : nullptr);
+
+            if (labelling.first == AppliesToThisEvent) {
+                command->remove(p);
             } else {
-                command->deletePoint(p);
-                label<PointType>(p, &prevPoint);
-                command->addPoint(p);
+                command->remove(prev);
             }
 
-            prevPoint = p;
-            havePrevPoint = true;
+            command->add(labelling.second);
+
+            prev = p;
+            havePrev = true;
         }
 
         return command->finish();
     }
 
     /**
-     * For each point in the given model (except the last), if that
-     * point lies within the given multi-selection, add n-1 new points
-     * at equally spaced intervals between it and the following point.
-     * Return a command that has been executed but not yet added to
-     * the history.
+     * For each event in the given event vector (except the last), if
+     * that event lies within the given multi-selection, add n-1 new
+     * events at equally spaced intervals between it and the following
+     * event.  Return a command that has been executed but not yet
+     * added to the history.
      */
-    template <typename PointType>
-    Command *subdivide(SparseModel<PointType> &model, MultiSelection *ms, int n) {
-        
-        auto points(model.getPoints());
-        auto command = new typename SparseModel<PointType>::EditCommand
-            (&model, tr("Subdivide Points"));
+    Command *subdivide(EventEditable *editable,
+                       MultiSelection *ms,
+                       const EventVector &allEvents,
+                       int n) {
 
-        for (auto i = points.begin(); i != points.end(); ++i) {
+        ChangeEventsCommand *command = new ChangeEventsCommand
+            (editable, tr("Subdivide Points"));
+
+        for (auto i = allEvents.begin(); i != allEvents.end(); ++i) {
 
             auto j = i;
             // require a "next point" even if it's not in selection
-            if (++j == points.end()) {
+            if (++j == allEvents.end()) {
                 break;
             }
 
             if (ms) {
-                Selection s(ms->getContainingSelection(i->frame, false));
-                if (!s.contains(i->frame)) {
+                Selection s(ms->getContainingSelection(i->getFrame(), false));
+                if (!s.contains(i->getFrame())) {
                     continue;
                 }
             }
 
-            PointType p(*i);
-            PointType nextP(*j);
+            Event p(*i);
+            Event nextP(*j);
 
             // n is the number of subdivisions, so we add n-1 new
             // points equally spaced between p and nextP
 
             for (int m = 1; m < n; ++m) {
-                sv_frame_t f = p.frame + (m * (nextP.frame - p.frame)) / n;
-                PointType newPoint(p);
-                newPoint.frame = f;
-                newPoint.label = tr("%1.%2").arg(p.label).arg(m+1);
-                command->addPoint(newPoint);
+                sv_frame_t f = p.getFrame() +
+                    (m * (nextP.getFrame() - p.getFrame())) / n;
+                Event newPoint = p
+                    .withFrame(f)
+                    .withLabel(tr("%1.%2").arg(p.getLabel()).arg(m+1));
+                command->add(newPoint);
             }
         }
 
@@ -265,23 +315,27 @@
     }
 
     /**
+     * The opposite of subdivide. Given an event vector, a
+     * multi-selection, and a number n, remove all but every nth event
+     * from the vector within the extents of the multi-selection.
      * Return a command that has been executed but not yet added to
      * the history.
      */
-    template <typename PointType>
-    Command *winnow(SparseModel<PointType> &model, MultiSelection *ms, int n) {
-        
-        auto points(model.getPoints());
-        auto command = new typename SparseModel<PointType>::EditCommand
-            (&model, tr("Winnow Points"));
+    Command *winnow(EventEditable *editable,
+                    MultiSelection *ms,
+                    const EventVector &allEvents,
+                    int n) {
+
+        ChangeEventsCommand *command = new ChangeEventsCommand
+            (editable, tr("Winnow Points"));
 
         int counter = 0;
         
-        for (auto p: points) {
+        for (auto p: allEvents) {
 
             if (ms) {
-                Selection s(ms->getContainingSelection(p.frame, false));
-                if (!s.contains(p.frame)) {
+                Selection s(ms->getContainingSelection(p.getFrame(), false));
+                if (!s.contains(p.getFrame())) {
                     counter = 0;
                     continue;
                 }
@@ -295,30 +349,12 @@
                 continue;
             }
             
-            command->deletePoint(p);
+            command->remove(p);
         }
 
         return command->finish();
     }
 
-    template <typename PointType>
-    void setValue(PointType &newPoint, PointType *prevPoint = 0) {
-        if (m_type == ValueFromExistingNeighbour) {
-            if (!prevPoint) {
-                std::cerr << "ERROR: Labeller::setValue: Previous point required but not provided" << std::endl;
-            } else {
-                newPoint.value = prevPoint->value;
-            }
-        } else {
-            float value = getValueFor<PointType>(newPoint, prevPoint);
-            if (actingOnPrevPoint() && prevPoint) {
-                prevPoint->value = value;
-            } else {
-                newPoint.value = value;
-            }
-        }
-    }
-
     bool requiresPrevPoint() const {
         return (m_type == ValueFromDurationFromPrevious ||
                 m_type == ValueFromDurationToNext ||
@@ -326,15 +362,14 @@
                 m_type == ValueFromDurationToNext);
     }
 
-    bool actingOnPrevPoint() const {
+    bool actingOnPrevEvent() const {
         return (m_type == ValueFromDurationToNext ||
                 m_type == ValueFromTempoToNext);
     }
 
 protected:
-    template <typename PointType>
-    float getValueFor(PointType &newPoint, PointType *prevPoint)
-    {
+    float getValueFor(Event p, const Event *prev) {
+        
         float value = 0.f;
 
         switch (m_type) {
@@ -355,14 +390,14 @@
             break;
 
         case ValueFromFrameNumber:
-            value = float(newPoint.frame);
+            value = float(p.getFrame());
             break;
             
         case ValueFromRealTime: 
             if (m_rate == 0.0) {
                 std::cerr << "ERROR: Labeller::getValueFor: Real-time conversion required, but no sample rate set" << std::endl;
             } else {
-                value = float(double(newPoint.frame) / m_rate);
+                value = float(double(p.getFrame()) / m_rate);
             }
             break;
 
@@ -372,10 +407,10 @@
         case ValueFromTempoFromPrevious:
             if (m_rate == 0.0) {
                 std::cerr << "ERROR: Labeller::getValueFor: Real-time conversion required, but no sample rate set" << std::endl;
-            } else if (!prevPoint) {
+            } else if (!prev) {
                 std::cerr << "ERROR: Labeller::getValueFor: Time difference required, but only one point provided" << std::endl;
             } else {
-                sv_frame_t f0 = prevPoint->frame, f1 = newPoint.frame;
+                sv_frame_t f0 = prev->getFrame(), f1 = p.getFrame();
                 if (m_type == ValueFromDurationToNext ||
                     m_type == ValueFromDurationFromPrevious) {
                     value = float(double(f1 - f0) / m_rate);
@@ -394,9 +429,9 @@
             break;
 
         case ValueFromLabel:
-            if (newPoint.label != "") {
+            if (p.getLabel() != "") {
                 // more forgiving than QString::toFloat()
-                value = float(atof(newPoint.label.toLocal8Bit()));
+                value = float(atof(p.getLabel().toLocal8Bit()));
             } else {
                 value = 0.f;
             }
--- a/data/model/Model.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/Model.cpp	Thu May 16 11:07:47 2019 +0100
@@ -20,11 +20,11 @@
 
 #include <iostream>
 
-const int Model::COMPLETION_UNKNOWN = -1;
+//#define DEBUG_COMPLETION 1
 
 Model::~Model()
 {
-//    SVDEBUG << "Model::~Model(" << this << ")" << endl;
+    SVDEBUG << "Model::~Model(" << this << ")" << endl;
 
     if (!m_aboutToDelete) {
         SVDEBUG << "NOTE: Model(" << this << ", \""
@@ -75,9 +75,10 @@
 void
 Model::aboutToDelete()
 {
-//    SVDEBUG << "Model(" << this << ", \""
-//            << objectName() << "\", type uri <"
-//            << m_typeUri << ">)::aboutToDelete()" << endl;
+    SVDEBUG << "Model(" << this << ", \""
+            << objectName() << "\", type name \""
+            << getTypeName() << "\", type uri <"
+            << m_typeUri << ">)::aboutToDelete()" << endl;
 
     if (m_aboutToDelete) {
         SVDEBUG << "WARNING: Model(" << this << ", \""
@@ -100,6 +101,9 @@
 void
 Model::setAlignment(AlignmentModel *alignment)
 {
+    SVDEBUG << "Model(" << this << "): accepting alignment model "
+            << alignment << endl;
+    
     if (m_alignment) {
         m_alignment->aboutToDelete();
         delete m_alignment;
@@ -161,15 +165,20 @@
 int
 Model::getAlignmentCompletion() const
 {
-//    SVDEBUG << "Model::getAlignmentCompletion: m_alignment = "
-//            << m_alignment << endl;
+#ifdef DEBUG_COMPLETION
+    SVCERR << "Model(" << this << ")::getAlignmentCompletion: m_alignment = "
+           << m_alignment << endl;
+#endif
     if (!m_alignment) {
         if (m_sourceModel) return m_sourceModel->getAlignmentCompletion();
         else return 100;
     }
     int completion = 0;
     (void)m_alignment->isReady(&completion);
-//    SVDEBUG << " -> " << completion << endl;
+#ifdef DEBUG_COMPLETION
+    SVCERR << "Model(" << this << ")::getAlignmentCompletion: completion = " << completion
+           << endl;
+#endif
     return completion;
 }
 
@@ -200,7 +209,7 @@
 {
     stream << indent;
     stream << QString("<model id=\"%1\" name=\"%2\" sampleRate=\"%3\" start=\"%4\" end=\"%5\" %6/>\n")
-        .arg(getObjectExportId(this))
+        .arg(getExportId())
         .arg(encodeEntities(objectName()))
         .arg(getSampleRate())
         .arg(getStartFrame())
--- a/data/model/Model.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/Model.h	Thu May 16 11:07:47 2019 +0100
@@ -55,11 +55,13 @@
     virtual sv_frame_t getStartFrame() const = 0;
 
     /**
-     * Return the audio frame at the end of the model, i.e. 1 more
-     * than the final frame contained within the model. The end frame
-     * minus the start frame should yield the total duration in frames
-     * spanned by the model. This is consistent with the definition of
-     * the end frame of a Selection object.
+     * Return the audio frame at the end of the model, i.e. the final
+     * frame contained within the model plus 1 (rounded up to the
+     * model's "resolution" granularity, if more than 1). The end
+     * frame minus the start frame should yield the total duration in
+     * frames (as a multiple of the resolution) spanned by the
+     * model. This is broadly consistent with the definition of the
+     * end frame of a Selection object.
      */
     virtual sv_frame_t getEndFrame() const = 0;
 
@@ -131,23 +133,42 @@
     /**
      * Return true if the model has finished loading or calculating
      * all its data, for a model that is capable of calculating in a
-     * background thread.  The default implementation is appropriate
-     * for a thread that does not background any work but carries out
-     * all its calculation from the constructor or accessors.
+     * background thread.
      *
-     * If "completion" is non-NULL, this function should return
-     * through it an estimated percentage value showing how far
-     * through the background operation it thinks it is (for progress
-     * reporting).  If it has no way to calculate progress, it may
-     * return the special value COMPLETION_UNKNOWN.  See also
-     * getCompletion().
+     * If "completion" is non-NULL, return through it an estimated
+     * percentage value showing how far through the background
+     * operation it thinks it is (for progress reporting). This should
+     * be identical to the value returned by getCompletion().
+     *
+     * A model that carries out all its calculation from the
+     * constructor or accessor functions would typically return true
+     * (and completion == 100) as long as isOK() is true. Other models
+     * may make the return value here depend on the internal
+     * completion status.
+     *
+     * See also getCompletion().
      */
-    virtual bool isReady(int *completion = 0) const {
-        bool ok = isOK();
-        if (completion) *completion = (ok ? 100 : 0);
-        return ok;
+    virtual bool isReady(int *cp = nullptr) const {
+        int c = getCompletion();
+        if (cp) *cp = c;
+        if (!isOK()) return false;
+        else return (c == 100);
     }
-    static const int COMPLETION_UNKNOWN;
+    
+    /**
+     * Return an estimated percentage value showing how far through
+     * any background operation used to calculate or load the model
+     * data the model thinks it is. Must return 100 when the model is
+     * complete.
+     *
+     * A model that carries out all its calculation from the
+     * constructor or accessor functions might return 0 if isOK() is
+     * false and 100 if isOK() is true. Other models may make the
+     * return value here depend on the internal completion status.
+     *
+     * See also isReady().
+     */
+    virtual int getCompletion() const = 0;
 
     /**
      * If this model imposes a zoom constraint, i.e. some limit to the
@@ -236,21 +257,10 @@
                QString indent = "",
                QString extraAttributes = "") const override;
 
-    virtual QString toDelimitedDataString(QString delimiter) const {
-        return toDelimitedDataStringSubset
-            (delimiter, getStartFrame(), getEndFrame());
-    }
-    virtual QString toDelimitedDataStringWithOptions(QString delimiter, DataExportOptions opts) const {
-        return toDelimitedDataStringSubsetWithOptions
-            (delimiter, opts, getStartFrame(), getEndFrame());
-    }
-    virtual QString toDelimitedDataStringSubset(QString, sv_frame_t /* f0 */, sv_frame_t /* f1 */) const {
-        return "";
-    }
-    virtual QString toDelimitedDataStringSubsetWithOptions(QString delimiter, DataExportOptions, sv_frame_t f0, sv_frame_t f1) const {
-        // Default implementation supports no options
-        return toDelimitedDataStringSubset(delimiter, f0, f1);
-    }
+    virtual QString toDelimitedDataString(QString delimiter,
+                                          DataExportOptions options,
+                                          sv_frame_t startFrame,
+                                          sv_frame_t duration) const = 0;
 
 public slots:
     void aboutToDelete();
--- a/data/model/NoteData.h	Tue May 07 15:54:15 2019 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,54 +0,0 @@
-/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
-
-/*
-    Sonic Visualiser
-    An audio file viewer and annotation editor.
-    Centre for Digital Music, Queen Mary, University of London.
-    
-    This program is free software; you can redistribute it and/or
-    modify it under the terms of the GNU General Public License as
-    published by the Free Software Foundation; either version 2 of the
-    License, or (at your option) any later version.  See the file
-    COPYING included with this distribution for more information.
-*/
-
-#ifndef SV_NOTE_DATA_H
-#define SV_NOTE_DATA_H
-
-#include <vector>
-
-#include "base/Pitch.h"
-
-struct NoteData
-{
-    NoteData(sv_frame_t _start, sv_frame_t _dur, int _mp, int _vel) :
-        start(_start), duration(_dur), midiPitch(_mp), frequency(0),
-        isMidiPitchQuantized(true), velocity(_vel), channel(0) { };
-            
-    sv_frame_t start;       // audio sample frame
-    sv_frame_t duration;    // in audio sample frames
-    int midiPitch;   // 0-127
-    float frequency; // Hz, to be used if isMidiPitchQuantized false
-    bool isMidiPitchQuantized;
-    int velocity;    // MIDI-style 0-127
-    int channel;     // MIDI 0-15
-
-    float getFrequency() const {
-        if (isMidiPitchQuantized) {
-            return float(Pitch::getFrequencyForPitch(midiPitch));
-        } else {
-            return frequency;
-        }
-    }
-};
-
-typedef std::vector<NoteData> NoteList;
-
-class NoteExportable
-{
-public:
-    virtual NoteList getNotes() const = 0;
-    virtual NoteList getNotesWithin(sv_frame_t startFrame, sv_frame_t endFrame) const = 0;
-};
-
-#endif
--- a/data/model/NoteModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/NoteModel.h	Thu May 16 11:07:47 2019 +0100
@@ -4,7 +4,6 @@
     Sonic Visualiser
     An audio file viewer and annotation editor.
     Centre for Digital Music, Queen Mary, University of London.
-    This file copyright 2006 Chris Cannam.
     
     This program is free software; you can redistribute it and/or
     modify it under the terms of the GNU General Public License as
@@ -16,150 +15,251 @@
 #ifndef SV_NOTE_MODEL_H
 #define SV_NOTE_MODEL_H
 
-#include "IntervalModel.h"
-#include "NoteData.h"
+#include "Model.h"
+#include "TabularModel.h"
+#include "EventCommands.h"
+#include "DeferredNotifier.h"
+#include "base/UnitDatabase.h"
+#include "base/EventSeries.h"
+#include "base/NoteData.h"
+#include "base/NoteExportable.h"
 #include "base/RealTime.h"
 #include "base/PlayParameterRepository.h"
 #include "base/Pitch.h"
+#include "system/System.h"
 
-/**
- * NoteModel -- a concrete IntervalModel for notes.
- */
+#include <QMutex>
+#include <QMutexLocker>
 
-/**
- * Note type for use in a sparse model.  All we mean by a "note" is
- * something that has an onset time, a single value, a duration, and a
- * level.  Like other points, it can also have a label.  With this
- * point type, the model can be thought of as representing a simple
- * MIDI-type piano roll, except that the y coordinates (values) do not
- * have to be discrete integers.
- */
-
-struct Note
-{
-public:
-    Note(sv_frame_t _frame) : frame(_frame), value(0.0f), duration(0), level(1.f) { }
-    Note(sv_frame_t _frame, float _value, sv_frame_t _duration, float _level, QString _label) :
-        frame(_frame), value(_value), duration(_duration), level(_level), label(_label) { }
-
-    int getDimensions() const { return 3; }
-
-    sv_frame_t frame;
-    float value;
-    sv_frame_t duration;
-    float level;
-    QString label;
-
-    QString getLabel() const { return label; }
-    
-    void toXml(QTextStream &stream,
-               QString indent = "",
-               QString extraAttributes = "") const
-    {
-        stream <<
-            QString("%1<point frame=\"%2\" value=\"%3\" duration=\"%4\" level=\"%5\" label=\"%6\" %7/>\n")
-            .arg(indent).arg(frame).arg(value).arg(duration).arg(level)
-            .arg(XmlExportable::encodeEntities(label)).arg(extraAttributes);
-    }
-
-    QString toDelimitedDataString(QString delimiter, DataExportOptions opts, sv_samplerate_t sampleRate) const {
-        QStringList list;
-        list << RealTime::frame2RealTime(frame, sampleRate).toString().c_str();
-        list << QString("%1").arg(value);
-        list << RealTime::frame2RealTime(duration, sampleRate).toString().c_str();
-        if (!(opts & DataExportOmitLevels)) {
-            list << QString("%1").arg(level);
-        }
-        if (label != "") list << label;
-        return list.join(delimiter);
-    }
-
-    struct Comparator {
-        bool operator()(const Note &p1,
-                        const Note &p2) const {
-            if (p1.frame != p2.frame) return p1.frame < p2.frame;
-            if (p1.value != p2.value) return p1.value < p2.value;
-            if (p1.duration != p2.duration) return p1.duration < p2.duration;
-            if (p1.level != p2.level) return p1.level < p2.level;
-            return p1.label < p2.label;
-        }
-    };
-    
-    struct OrderComparator {
-        bool operator()(const Note &p1,
-                        const Note &p2) const {
-            return p1.frame < p2.frame;
-        }
-    };
-};
-
-
-class NoteModel : public IntervalModel<Note>, public NoteExportable
+class NoteModel : public Model,
+                  public TabularModel,
+                  public NoteExportable,
+                  public EventEditable
 {
     Q_OBJECT
     
 public:
-    NoteModel(sv_samplerate_t sampleRate, int resolution,
-              bool notifyOnAdd = true) :
-        IntervalModel<Note>(sampleRate, resolution, notifyOnAdd),
-        m_valueQuantization(0)
-    {
+    enum Subtype {
+        NORMAL_NOTE,
+        FLEXI_NOTE
+    };
+    
+    NoteModel(sv_samplerate_t sampleRate,
+              int resolution,
+              bool notifyOnAdd = true,
+              Subtype subtype = NORMAL_NOTE) :
+        m_subtype(subtype),
+        m_sampleRate(sampleRate),
+        m_resolution(resolution),
+        m_valueMinimum(0.f),
+        m_valueMaximum(0.f),
+        m_haveExtents(false),
+        m_valueQuantization(0),
+        m_units(""),
+        m_extendTo(0),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
+        m_completion(100) {
+        if (subtype == FLEXI_NOTE) {
+            m_valueMinimum = 33.f;
+            m_valueMaximum = 88.f;
+        }
         PlayParameterRepository::getInstance()->addPlayable(this);
     }
 
     NoteModel(sv_samplerate_t sampleRate, int resolution,
               float valueMinimum, float valueMaximum,
-              bool notifyOnAdd = true) :
-        IntervalModel<Note>(sampleRate, resolution,
-                            valueMinimum, valueMaximum,
-                            notifyOnAdd),
-        m_valueQuantization(0)
-    {
+              bool notifyOnAdd = true,
+              Subtype subtype = NORMAL_NOTE) :
+        m_subtype(subtype),
+        m_sampleRate(sampleRate),
+        m_resolution(resolution),
+        m_valueMinimum(valueMinimum),
+        m_valueMaximum(valueMaximum),
+        m_haveExtents(true),
+        m_valueQuantization(0),
+        m_units(""),
+        m_extendTo(0),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
+        m_completion(100) {
         PlayParameterRepository::getInstance()->addPlayable(this);
     }
 
-    virtual ~NoteModel()
-    {
+    virtual ~NoteModel() {
         PlayParameterRepository::getInstance()->removePlayable(this);
     }
 
+    QString getTypeName() const override { return tr("Note"); }
+    Subtype getSubtype() const { return m_subtype; }
+    bool isSparse() const override { return true; }
+    bool isOK() const override { return true; }
+    
+    sv_frame_t getStartFrame() const override {
+        return m_events.getStartFrame();
+    }
+    sv_frame_t getEndFrame() const override {
+        if (m_events.isEmpty()) return 0;
+        sv_frame_t e = m_events.getEndFrame();
+        if (e % m_resolution == 0) return e;
+        else return (e / m_resolution + 1) * m_resolution;
+    }
+
+    sv_samplerate_t getSampleRate() const override { return m_sampleRate; }
+    int getResolution() const { return m_resolution; }
+
+    bool canPlay() const override { return true; }
+    QString getDefaultPlayClipId() const override {
+        return "elecpiano";
+    }
+
+    QString getScaleUnits() const { return m_units; }
+    void setScaleUnits(QString units) {
+        m_units = units;
+        UnitDatabase::getInstance()->registerUnit(units);
+    }
+
     float getValueQuantization() const { return m_valueQuantization; }
     void setValueQuantization(float q) { m_valueQuantization = q; }
 
-    QString getTypeName() const override { return tr("Note"); }
+    float getValueMinimum() const { return m_valueMinimum; }
+    float getValueMaximum() const { return m_valueMaximum; }
+    
+    int getCompletion() const override { return m_completion; }
 
-    bool canPlay() const override { return true; }
+    void setCompletion(int completion, bool update = true) {
 
-    QString getDefaultPlayClipId() const override
-    {
-        return "elecpiano";
+        {   QMutexLocker locker(&m_mutex);
+            if (m_completion == completion) return;
+            m_completion = completion;
+        }
+
+        if (update) {
+            m_notifier.makeDeferredNotifications();
+        }
+        
+        emit completionChanged();
+
+        if (completion == 100) {
+            // henceforth:
+            m_notifier.switchMode(DeferredNotifier::NOTIFY_ALWAYS);
+            emit modelChanged();
+        }
     }
 
-    void toXml(QTextStream &out,
-                       QString indent = "",
-                       QString extraAttributes = "") const override
-    {
-        std::cerr << "NoteModel::toXml: extraAttributes = \"" 
-                  << extraAttributes.toStdString() << std::endl;
+    /**
+     * Query methods.
+     */
 
-        IntervalModel<Note>::toXml
-            (out,
-             indent,
-             QString("%1 subtype=\"note\" valueQuantization=\"%2\"")
-             .arg(extraAttributes).arg(m_valueQuantization));
+    int getEventCount() const {
+        return m_events.count();
+    }
+    bool isEmpty() const {
+        return m_events.isEmpty();
+    }
+    bool containsEvent(const Event &e) const {
+        return m_events.contains(e);
+    }
+    EventVector getAllEvents() const {
+        return m_events.getAllEvents();
+    }
+    EventVector getEventsSpanning(sv_frame_t f, sv_frame_t duration) const {
+        return m_events.getEventsSpanning(f, duration);
+    }
+    EventVector getEventsCovering(sv_frame_t f) const {
+        return m_events.getEventsCovering(f);
+    }
+    EventVector getEventsWithin(sv_frame_t f, sv_frame_t duration) const {
+        return m_events.getEventsWithin(f, duration);
+    }
+    EventVector getEventsStartingWithin(sv_frame_t f, sv_frame_t duration) const {
+        return m_events.getEventsStartingWithin(f, duration);
+    }
+    EventVector getEventsStartingAt(sv_frame_t f) const {
+        return m_events.getEventsStartingAt(f);
+    }
+    bool getNearestEventMatching(sv_frame_t startSearchAt,
+                                 std::function<bool(Event)> predicate,
+                                 EventSeries::Direction direction,
+                                 Event &found) const {
+        return m_events.getNearestEventMatching
+            (startSearchAt, predicate, direction, found);
+    }
+
+    /**
+     * Editing methods.
+     */
+    void add(Event e) override {
+
+        bool allChange = false;
+           
+        {
+            QMutexLocker locker(&m_mutex);
+            m_events.add(e);
+
+            float v = e.getValue();
+            if (!ISNAN(v) && !ISINF(v)) {
+                if (!m_haveExtents || v < m_valueMinimum) {
+                    m_valueMinimum = v; allChange = true;
+                }
+                if (!m_haveExtents || v > m_valueMaximum) {
+                    m_valueMaximum = v; allChange = true;
+                }
+                m_haveExtents = true;
+            }
+        }
+        
+        m_notifier.update(e.getFrame(), e.getDuration() + m_resolution);
+
+        if (allChange) {
+            emit modelChanged();
+        }
+    }
+    
+    void remove(Event e) override {
+        {
+            QMutexLocker locker(&m_mutex);
+            m_events.remove(e);
+        }
+        emit modelChangedWithin(e.getFrame(),
+                                e.getFrame() + e.getDuration() + m_resolution);
     }
 
     /**
      * TabularModel methods.  
      */
+
+    int getRowCount() const override {
+        return m_events.count();
+    }
     
-    int getColumnCount() const override
-    {
+    int getColumnCount() const override {
         return 6;
     }
 
-    QString getHeading(int column) const override
-    {
+    bool isColumnTimeValue(int column) const override {
+        // NB duration is not a "time value" -- that's for columns
+        // whose sort ordering is exactly that of the frame time
+        return (column < 2);
+    }
+
+    sv_frame_t getFrameForRow(int row) const override {
+        if (row < 0 || row >= m_events.count()) {
+            return 0;
+        }
+        Event e = m_events.getEventByIndex(row);
+        return e.getFrame();
+    }
+
+    int getRowForFrame(sv_frame_t frame) const override {
+        return m_events.getIndexForEvent(Event(frame));
+    }
+    
+    QString getHeading(int column) const override {
         switch (column) {
         case 0: return tr("Time");
         case 1: return tr("Frame");
@@ -171,43 +271,47 @@
         }
     }
 
-    QVariant getData(int row, int column, int role) const override
-    {
-        if (column < 4) {
-            return IntervalModel<Note>::getData(row, column, role);
+    QVariant getData(int row, int column, int role) const override {
+
+        if (row < 0 || row >= m_events.count()) {
+            return QVariant();
         }
 
-        PointListConstIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return QVariant();
+        Event e = m_events.getEventByIndex(row);
 
         switch (column) {
-        case 4: return i->level;
-        case 5: return i->label;
+        case 0: return adaptFrameForRole(e.getFrame(), getSampleRate(), role);
+        case 1: return int(e.getFrame());
+        case 2: return adaptValueForRole(e.getValue(), getScaleUnits(), role);
+        case 3: return int(e.getDuration());
+        case 4: return e.getLevel();
+        case 5: return e.getLabel();
         default: return QVariant();
         }
     }
 
-    Command *getSetDataCommand(int row, int column, const QVariant &value, int role) override
-    {
-        if (column < 4) {
-            return IntervalModel<Note>::getSetDataCommand
-                (row, column, value, role);
+    Command *getSetDataCommand(int row, int column, const QVariant &value, int role) override {
+        
+        if (row < 0 || row >= m_events.count()) return nullptr;
+        if (role != Qt::EditRole) return nullptr;
+
+        Event e0 = m_events.getEventByIndex(row);
+        Event e1;
+
+        switch (column) {
+        case 0: e1 = e0.withFrame(sv_frame_t(round(value.toDouble() *
+                                                   getSampleRate()))); break;
+        case 1: e1 = e0.withFrame(value.toInt()); break;
+        case 2: e1 = e0.withValue(float(value.toDouble())); break;
+        case 3: e1 = e0.withDuration(value.toInt()); break;
+        case 4: e1 = e0.withLevel(float(value.toDouble())); break;
+        case 5: e1 = e0.withLabel(value.toString()); break;
         }
 
-        if (role != Qt::EditRole) return 0;
-        PointListConstIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return 0;
-        EditCommand *command = new EditCommand(this, tr("Edit Data"));
-
-        Point point(*i);
-        command->deletePoint(point);
-
-        switch (column) {
-        case 4: point.level = float(value.toDouble()); break;
-        case 5: point.label = value.toString(); break;
-        }
-
-        command->addPoint(point);
+        ChangeEventsCommand *command =
+            new ChangeEventsCommand(this, tr("Edit Data"));
+        command->remove(e0);
+        command->add(e1);
         return command->finish();
     }
 
@@ -222,45 +326,98 @@
      */
 
     NoteList getNotes() const override {
-        return getNotesWithin(getStartFrame(), getEndFrame());
+        return getNotesStartingWithin(getStartFrame(),
+                                      getEndFrame() - getStartFrame());
     }
 
-    NoteList getNotesWithin(sv_frame_t startFrame, sv_frame_t endFrame) const override {
-        
-        PointList points = getPoints(startFrame, endFrame);
+    NoteList getNotesActiveAt(sv_frame_t frame) const override {
+
         NoteList notes;
+        EventVector ee = m_events.getEventsCovering(frame);
+        for (const auto &e: ee) {
+            notes.push_back(e.toNoteData(getSampleRate(),
+                                         getScaleUnits() != "Hz"));
+        }
+        return notes;
+    }
+    
+    NoteList getNotesStartingWithin(sv_frame_t startFrame,
+                                    sv_frame_t duration) const override {
 
-        for (PointList::iterator pli =
-                 points.begin(); pli != points.end(); ++pli) {
-
-            sv_frame_t duration = pli->duration;
-            if (duration == 0 || duration == 1) {
-                duration = sv_frame_t(getSampleRate() / 20);
-            }
-
-            int pitch = int(lrintf(pli->value));
-            
-            int velocity = 100;
-            if (pli->level > 0.f && pli->level <= 1.f) {
-                velocity = int(lrintf(pli->level * 127));
-            }
-
-            NoteData note(pli->frame, duration, pitch, velocity);
-
-            if (getScaleUnits() == "Hz") {
-                note.frequency = pli->value;
-                note.midiPitch = Pitch::getPitchForFrequency(note.frequency);
-                note.isMidiPitchQuantized = false;
-            }
-        
-            notes.push_back(note);
+        NoteList notes;
+        EventVector ee = m_events.getEventsStartingWithin(startFrame, duration);
+        for (const auto &e: ee) {
+            notes.push_back(e.toNoteData(getSampleRate(),
+                                         getScaleUnits() != "Hz"));
         }
-        
         return notes;
     }
 
+    /**
+     * XmlExportable methods.
+     */
+    
+    void toXml(QTextStream &out,
+               QString indent = "",
+               QString extraAttributes = "") const override {
+
+        //!!! what is valueQuantization used for?
+        
+        Model::toXml
+            (out,
+             indent,
+             QString("type=\"sparse\" dimensions=\"3\" resolution=\"%1\" "
+                     "notifyOnAdd=\"%2\" dataset=\"%3\" subtype=\"%4\" "
+                     "valueQuantization=\"%5\" minimum=\"%6\" maximum=\"%7\" "
+                     "units=\"%8\" %9")
+             .arg(m_resolution)
+             .arg("true") // always true after model reaches 100% -
+                          // subsequent events are always notified
+             .arg(m_events.getExportId())
+             .arg(m_subtype == FLEXI_NOTE ? "flexinote" : "note")
+             .arg(m_valueQuantization)
+             .arg(m_valueMinimum)
+             .arg(m_valueMaximum)
+             .arg(encodeEntities(m_units))
+             .arg(extraAttributes));
+        
+        m_events.toXml(out, indent, QString("dimensions=\"3\""));
+    }
+
+    QString toDelimitedDataString(QString delimiter,
+                                  DataExportOptions options,
+                                  sv_frame_t startFrame,
+                                  sv_frame_t duration) const override {
+        return m_events.toDelimitedDataString
+            (delimiter,
+             options,
+             startFrame,
+             duration,
+             m_sampleRate,
+             m_resolution,
+             Event().withValue(0.f).withDuration(0.f).withLevel(0.f));
+    }
+
 protected:
+    Subtype m_subtype;
+    sv_samplerate_t m_sampleRate;
+    int m_resolution;
+
+    float m_valueMinimum;
+    float m_valueMaximum;
+    bool m_haveExtents;
     float m_valueQuantization;
+    QString m_units;
+    sv_frame_t m_extendTo;
+    DeferredNotifier m_notifier;
+    int m_completion;
+
+    EventSeries m_events;
+
+    mutable QMutex m_mutex;
+
+    //!!! do we have general docs for ownership and synchronisation of models?
+    // this might be a good opportunity to stop using bare pointers to them
 };
 
 #endif
--- a/data/model/PathModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/PathModel.h	Thu May 16 11:07:47 2019 +0100
@@ -17,26 +17,26 @@
 #define SV_PATH_MODEL_H
 
 #include "Model.h"
-#include "SparseModel.h"
+#include "DeferredNotifier.h"
 #include "base/RealTime.h"
 #include "base/BaseTypes.h"
 
+#include "base/XmlExportable.h"
+#include "base/RealTime.h"
+
 #include <QStringList>
-
+#include <set>
 
 struct PathPoint
 {
-    PathPoint(sv_frame_t _frame) : frame(_frame), mapframe(_frame) { }
+    PathPoint(sv_frame_t _frame) :
+        frame(_frame), mapframe(_frame) { }
     PathPoint(sv_frame_t _frame, sv_frame_t _mapframe) :
         frame(_frame), mapframe(_mapframe) { }
 
-    int getDimensions() const { return 2; }
-
     sv_frame_t frame;
     sv_frame_t mapframe;
 
-    QString getLabel() const { return ""; }
-
     void toXml(QTextStream &stream, QString indent = "",
                QString extraAttributes = "") const {
         stream << QString("%1<point frame=\"%2\" mapframe=\"%3\" %4/>\n")
@@ -51,44 +51,178 @@
         return list.join(delimiter);
     }
 
-    struct Comparator {
-        bool operator()(const PathPoint &p1, const PathPoint &p2) const {
-            if (p1.frame != p2.frame) return p1.frame < p2.frame;
-            return p1.mapframe < p2.mapframe;
-        }
-    };
-    
-    struct OrderComparator {
-        bool operator()(const PathPoint &p1, const PathPoint &p2) const {
-            return p1.frame < p2.frame;
-        }
-    };
+    bool operator<(const PathPoint &p2) const {
+        if (frame != p2.frame) return frame < p2.frame;
+        return mapframe < p2.mapframe;
+    }
 };
 
-class PathModel : public SparseModel<PathPoint>
+class PathModel : public Model
 {
 public:
-    PathModel(sv_samplerate_t sampleRate, int resolution, bool notify = true) :
-        SparseModel<PathPoint>(sampleRate, resolution, notify) { }
+    typedef std::set<PathPoint> PointList;
 
-    void toXml(QTextStream &out,
-                       QString indent = "",
-                       QString extraAttributes = "") const override
-    {
-        SparseModel<PathPoint>::toXml
-            (out, 
-             indent,
-             QString("%1 subtype=\"path\"")
-             .arg(extraAttributes));
+    PathModel(sv_samplerate_t sampleRate,
+              int resolution,
+              bool notifyOnAdd = true) :
+        m_sampleRate(sampleRate),
+        m_resolution(resolution),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
+        m_completion(100),
+        m_start(0),
+        m_end(0) {
+    }
+
+    QString getTypeName() const override { return tr("Path"); }
+    bool isSparse() const override { return true; }
+    bool isOK() const override { return true; }
+
+    sv_frame_t getStartFrame() const override {
+        return m_start;
+    }
+    sv_frame_t getEndFrame() const override {
+        return m_end;
+    }
+    
+    sv_samplerate_t getSampleRate() const override { return m_sampleRate; }
+    int getResolution() const { return m_resolution; }
+    
+    int getCompletion() const override { return m_completion; }
+
+    void setCompletion(int completion, bool update = true) {
+        
+        {   QMutexLocker locker(&m_mutex);
+            if (m_completion == completion) return;
+            m_completion = completion;
+        }
+
+        if (update) {
+            m_notifier.makeDeferredNotifications();
+        }
+        
+        emit completionChanged();
+
+        if (completion == 100) {
+            // henceforth:
+            m_notifier.switchMode(DeferredNotifier::NOTIFY_ALWAYS);
+            emit modelChanged();
+        }
     }
 
     /**
-     * TabularModel is inherited via SparseModel, but we don't need it here.
+     * Query methods.
      */
-    QString getHeading(int) const override { return ""; }
-    bool isColumnTimeValue(int) const override { return false; }
-    SortType getSortType(int) const override { return SortNumeric; }
+    int getPointCount() const {
+        return int(m_points.size());
+    }
+    const PointList &getPoints() const {
+        return m_points;
+    }
 
+    /**
+     * Editing methods.
+     */
+    void add(PathPoint p) {
+
+        {   QMutexLocker locker(&m_mutex);
+            m_points.insert(p);
+
+            if (m_start == m_end) {
+                m_start = p.frame;
+                m_end = m_start + m_resolution;
+            } else {
+                if (p.frame < m_start) {
+                    m_start = p.frame;
+                }
+                if (p.frame + m_resolution > m_end) {
+                    m_end = p.frame + m_resolution;
+                }
+            }
+        }
+        
+        m_notifier.update(p.frame, m_resolution);
+    }
+    
+    void remove(PathPoint p) {
+        {   QMutexLocker locker(&m_mutex);
+            m_points.erase(p);
+        }
+
+        emit modelChangedWithin(p.frame, p.frame + m_resolution);
+    }
+
+    void clear() {
+        {   QMutexLocker locker(&m_mutex);
+            m_start = m_end = 0;
+            m_points.clear();
+        }
+    }
+
+    /**
+     * XmlExportable methods.
+     */
+    void toXml(QTextStream &out,
+                       QString indent = "",
+                       QString extraAttributes = "") const override {
+
+        // Our dataset doesn't have its own export ID, we just use
+        // ours. Actually any model could do that, since datasets
+        // aren't in the same id-space as models when re-read
+        
+        Model::toXml
+            (out,
+             indent,
+             QString("type=\"sparse\" dimensions=\"2\" resolution=\"%1\" "
+                     "notifyOnAdd=\"%2\" dataset=\"%3\" subtype=\"path\" %4")
+             .arg(m_resolution)
+             .arg("true") // always true after model reaches 100% -
+                          // subsequent points are always notified
+             .arg(getExportId())
+             .arg(extraAttributes));
+
+        out << indent << QString("<dataset id=\"%1\" dimensions=\"2\">\n")
+            .arg(getExportId());
+        
+        for (PathPoint p: m_points) {
+            p.toXml(out, indent + "  ", "");
+        }
+
+        out << indent << "</dataset>\n";
+    }
+
+    QString toDelimitedDataString(QString delimiter,
+                                  DataExportOptions,
+                                  sv_frame_t startFrame,
+                                  sv_frame_t duration) const override {
+
+        QString s;
+        for (PathPoint p: m_points) {
+            if (p.frame < startFrame) continue;
+            if (p.frame >= startFrame + duration) break;
+            s += QString("%1%2%3\n")
+                .arg(p.frame)
+                .arg(delimiter)
+                .arg(p.mapframe);
+        }
+
+        return s;
+    }
+    
+protected:
+    sv_samplerate_t m_sampleRate;
+    int m_resolution;
+
+    DeferredNotifier m_notifier;
+    int m_completion;
+
+    sv_frame_t m_start;
+    sv_frame_t m_end;
+    PointList m_points;
+
+    mutable QMutex m_mutex;  
 };
 
 
--- a/data/model/ReadOnlyWaveFileModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/ReadOnlyWaveFileModel.h	Thu May 16 11:07:47 2019 +0100
@@ -54,6 +54,11 @@
 
     bool isOK() const override;
     bool isReady(int *) const override;
+    int getCompletion() const override {
+        int c = 0;
+        (void)isReady(&c);
+        return c;
+    }
 
     const ZoomConstraint *getZoomConstraint() const override { return &m_zoomConstraint; }
 
--- a/data/model/RegionModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/RegionModel.h	Thu May 16 11:07:47 2019 +0100
@@ -4,7 +4,6 @@
     Sonic Visualiser
     An audio file viewer and annotation editor.
     Centre for Digital Music, Queen Mary, University of London.
-    This file copyright 2006 Chris Cannam.
     
     This program is free software; you can redistribute it and/or
     modify it under the terms of the GNU General Public License as
@@ -16,105 +15,88 @@
 #ifndef SV_REGION_MODEL_H
 #define SV_REGION_MODEL_H
 
-#include "IntervalModel.h"
+#include "EventCommands.h"
+#include "TabularModel.h"
+#include "Model.h"
+#include "DeferredNotifier.h"
+
 #include "base/RealTime.h"
+#include "base/EventSeries.h"
+#include "base/UnitDatabase.h"
+
+#include "system/System.h"
+
+#include <QMutex>
 
 /**
- * RegionModel -- a concrete IntervalModel for intervals associated
- * with a value, which we call regions for no very compelling reason.
+ * RegionModel -- a model for intervals associated with a value, which
+ * we call regions for no very compelling reason.
  */
-
-/**
- * Region "point" type.  A region is something that has an onset time,
- * a single value, and a duration.  Like other points, it can also
- * have a label.
- *
- * This is called RegionRec instead of Region to avoid name collisions
- * with the X11 Region struct.  Bah.
- */
-
-struct RegionRec
-{
-public:
-    RegionRec() : frame(0), value(0.f), duration(0) { }
-    RegionRec(sv_frame_t _frame) : frame(_frame), value(0.0f), duration(0) { }
-    RegionRec(sv_frame_t _frame, float _value, sv_frame_t _duration, QString _label) :
-        frame(_frame), value(_value), duration(_duration), label(_label) { }
-
-    int getDimensions() const { return 3; }
-
-    sv_frame_t frame;
-    float value;
-    sv_frame_t duration;
-    QString label;
-
-    QString getLabel() const { return label; }
-    
-    void toXml(QTextStream &stream,
-               QString indent = "",
-               QString extraAttributes = "") const
-    {
-        stream <<
-            QString("%1<point frame=\"%2\" value=\"%3\" duration=\"%4\" label=\"%5\" %6/>\n")
-            .arg(indent).arg(frame).arg(value).arg(duration)
-            .arg(XmlExportable::encodeEntities(label)).arg(extraAttributes);
-    }
-
-    QString toDelimitedDataString(QString delimiter, DataExportOptions, sv_samplerate_t sampleRate) const
-    {
-        QStringList list;
-        list << RealTime::frame2RealTime(frame, sampleRate).toString().c_str();
-        list << QString("%1").arg(value);
-        list << RealTime::frame2RealTime(duration, sampleRate).toString().c_str();
-        if (label != "") list << label;
-        return list.join(delimiter);
-    }
-
-    struct Comparator {
-        bool operator()(const RegionRec &p1,
-                        const RegionRec &p2) const {
-            if (p1.frame != p2.frame) return p1.frame < p2.frame;
-            if (p1.value != p2.value) return p1.value < p2.value;
-            if (p1.duration != p2.duration) return p1.duration < p2.duration;
-            return p1.label < p2.label;
-        }
-    };
-    
-    struct OrderComparator {
-        bool operator()(const RegionRec &p1,
-                        const RegionRec &p2) const {
-            return p1.frame < p2.frame;
-        }
-    };
-};
-
-
-class RegionModel : public IntervalModel<RegionRec>
+class RegionModel : public Model,
+                    public TabularModel,
+                    public EventEditable
 {
     Q_OBJECT
     
 public:
-    RegionModel(sv_samplerate_t sampleRate, int resolution,
+    RegionModel(sv_samplerate_t sampleRate,
+                int resolution,
                 bool notifyOnAdd = true) :
-        IntervalModel<RegionRec>(sampleRate, resolution, notifyOnAdd),
+        m_sampleRate(sampleRate),
+        m_resolution(resolution),
+        m_valueMinimum(0.f),
+        m_valueMaximum(0.f),
+        m_haveExtents(false),
         m_valueQuantization(0),
-        m_haveDistinctValues(false)
-    {
+        m_haveDistinctValues(false),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
+        m_completion(100) {
     }
 
     RegionModel(sv_samplerate_t sampleRate, int resolution,
                 float valueMinimum, float valueMaximum,
                 bool notifyOnAdd = true) :
-        IntervalModel<RegionRec>(sampleRate, resolution,
-                            valueMinimum, valueMaximum,
-                            notifyOnAdd),
+        m_sampleRate(sampleRate),
+        m_resolution(resolution),
+        m_valueMinimum(valueMinimum),
+        m_valueMaximum(valueMaximum),
+        m_haveExtents(true),
         m_valueQuantization(0),
-        m_haveDistinctValues(false)
-    {
+        m_haveDistinctValues(false),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
+        m_completion(100) {
     }
 
-    virtual ~RegionModel()
-    {
+    virtual ~RegionModel() {
+    }
+
+    QString getTypeName() const override { return tr("Region"); }
+    bool isSparse() const override { return true; }
+    bool isOK() const override { return true; }
+
+    sv_frame_t getStartFrame() const override {
+        return m_events.getStartFrame();
+    }
+    sv_frame_t getEndFrame() const override {
+        if (m_events.isEmpty()) return 0;
+        sv_frame_t e = m_events.getEndFrame();
+        if (e % m_resolution == 0) return e;
+        else return (e / m_resolution + 1) * m_resolution;
+    }
+
+    sv_samplerate_t getSampleRate() const override { return m_sampleRate; }
+    int getResolution() const { return m_resolution; }
+
+    QString getScaleUnits() const { return m_units; }
+    void setScaleUnits(QString units) {
+        m_units = units;
+        UnitDatabase::getInstance()->registerUnit(units);
     }
 
     float getValueQuantization() const { return m_valueQuantization; }
@@ -122,33 +104,143 @@
 
     bool haveDistinctValues() const { return m_haveDistinctValues; }
 
-    QString getTypeName() const override { return tr("Region"); }
+    float getValueMinimum() const { return m_valueMinimum; }
+    float getValueMaximum() const { return m_valueMaximum; }
+    
+    int getCompletion() const override { return m_completion; }
 
-    void toXml(QTextStream &out,
-                       QString indent = "",
-                       QString extraAttributes = "") const override
-    {
-        std::cerr << "RegionModel::toXml: extraAttributes = \"" 
-                  << extraAttributes.toStdString() << std::endl;
+    void setCompletion(int completion, bool update = true) {
 
-        IntervalModel<RegionRec>::toXml
-            (out,
-             indent,
-             QString("%1 subtype=\"region\" valueQuantization=\"%2\"")
-             .arg(extraAttributes).arg(m_valueQuantization));
+        {   QMutexLocker locker(&m_mutex);
+            if (m_completion == completion) return;
+            m_completion = completion;
+        }
+
+        if (update) {
+            m_notifier.makeDeferredNotifications();
+        }
+        
+        emit completionChanged();
+
+        if (completion == 100) {
+            // henceforth:
+            m_notifier.switchMode(DeferredNotifier::NOTIFY_ALWAYS);
+            emit modelChanged();
+        }
     }
 
     /**
+     * Query methods.
+     */
+    int getEventCount() const {
+        return m_events.count();
+    }
+    bool isEmpty() const {
+        return m_events.isEmpty();
+    }
+    bool containsEvent(const Event &e) const {
+        return m_events.contains(e);
+    }
+    EventVector getAllEvents() const {
+        return m_events.getAllEvents();
+    }
+    EventVector getEventsSpanning(sv_frame_t f, sv_frame_t duration) const {
+        return m_events.getEventsSpanning(f, duration);
+    }
+    EventVector getEventsCovering(sv_frame_t f) const {
+        return m_events.getEventsCovering(f);
+    }
+    EventVector getEventsWithin(sv_frame_t f, sv_frame_t duration) const {
+        return m_events.getEventsWithin(f, duration);
+    }
+    EventVector getEventsStartingWithin(sv_frame_t f, sv_frame_t duration) const {
+        return m_events.getEventsStartingWithin(f, duration);
+    }
+    EventVector getEventsStartingAt(sv_frame_t f) const {
+        return m_events.getEventsStartingAt(f);
+    }
+    bool getNearestEventMatching(sv_frame_t startSearchAt,
+                                 std::function<bool(Event)> predicate,
+                                 EventSeries::Direction direction,
+                                 Event &found) const {
+        return m_events.getNearestEventMatching
+            (startSearchAt, predicate, direction, found);
+    }
+
+    /**
+     * Editing methods.
+     */
+    void add(Event e) override {
+
+        bool allChange = false;
+           
+        {
+            QMutexLocker locker(&m_mutex);
+            m_events.add(e);
+
+            float v = e.getValue();
+            if (!ISNAN(v) && !ISINF(v)) {
+                if (!m_haveExtents || v < m_valueMinimum) {
+                    m_valueMinimum = v; allChange = true;
+                }
+                if (!m_haveExtents || v > m_valueMaximum) {
+                    m_valueMaximum = v; allChange = true;
+                }
+                m_haveExtents = true;
+            }
+
+            if (e.hasValue() && e.getValue() != 0.f) {
+                m_haveDistinctValues = true;
+            }
+        }
+        
+        m_notifier.update(e.getFrame(), e.getDuration() + m_resolution);
+
+        if (allChange) {
+            emit modelChanged();
+        }
+    }
+    
+    void remove(Event e) override {
+        {
+            QMutexLocker locker(&m_mutex);
+            m_events.remove(e);
+        }
+        emit modelChangedWithin(e.getFrame(),
+                                e.getFrame() + e.getDuration() + m_resolution);
+    }
+    
+    /**
      * TabularModel methods.  
      */
-    
-    int getColumnCount() const override
-    {
+
+    int getRowCount() const override {
+        return m_events.count();
+    }
+
+    int getColumnCount() const override {
         return 5;
     }
 
-    QString getHeading(int column) const override
-    {
+    bool isColumnTimeValue(int column) const override {
+        // NB duration is not a "time value" -- that's for columns
+        // whose sort ordering is exactly that of the frame time
+        return (column < 2);
+    }
+
+    sv_frame_t getFrameForRow(int row) const override {
+        if (row < 0 || row >= m_events.count()) {
+            return 0;
+        }
+        Event e = m_events.getEventByIndex(row);
+        return e.getFrame();
+    }
+
+    int getRowForFrame(sv_frame_t frame) const override {
+        return m_events.getIndexForEvent(Event(frame));
+    }
+    
+    QString getHeading(int column) const override {
         switch (column) {
         case 0: return tr("Time");
         case 1: return tr("Frame");
@@ -159,59 +251,112 @@
         }
     }
 
-    QVariant getData(int row, int column, int role) const override
-    {
-        if (column < 4) {
-            return IntervalModel<RegionRec>::getData(row, column, role);
+    SortType getSortType(int column) const override {
+        if (column == 4) return SortAlphabetical;
+        return SortNumeric;
+    }
+
+    QVariant getData(int row, int column, int role) const override {
+
+        if (row < 0 || row >= m_events.count()) {
+            return QVariant();
         }
 
-        PointListConstIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return QVariant();
+        Event e = m_events.getEventByIndex(row);
 
         switch (column) {
-        case 4: return i->label;
+        case 0: return adaptFrameForRole(e.getFrame(), getSampleRate(), role);
+        case 1: return int(e.getFrame());
+        case 2: return adaptValueForRole(e.getValue(), getScaleUnits(), role);
+        case 3: return int(e.getDuration());
+        case 4: return e.getLabel();
         default: return QVariant();
         }
     }
 
-    Command *getSetDataCommand(int row, int column, const QVariant &value, int role) override
-    {
-        if (column < 4) {
-            return IntervalModel<RegionRec>::getSetDataCommand
-                (row, column, value, role);
+    Command *getSetDataCommand(int row, int column, const QVariant &value, int role) override {
+        
+        if (row < 0 || row >= m_events.count()) return nullptr;
+        if (role != Qt::EditRole) return nullptr;
+
+        Event e0 = m_events.getEventByIndex(row);
+        Event e1;
+
+        switch (column) {
+        case 0: e1 = e0.withFrame(sv_frame_t(round(value.toDouble() *
+                                                   getSampleRate()))); break;
+        case 1: e1 = e0.withFrame(value.toInt()); break;
+        case 2: e1 = e0.withValue(float(value.toDouble())); break;
+        case 3: e1 = e0.withDuration(value.toInt()); break;
+        case 4: e1 = e0.withLabel(value.toString()); break;
         }
 
-        if (role != Qt::EditRole) return 0;
-        PointListIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return 0;
-        EditCommand *command = new EditCommand(this, tr("Edit Data"));
-
-        Point point(*i);
-        command->deletePoint(point);
-
-        switch (column) {
-        case 4: point.label = value.toString(); break;
-        }
-
-        command->addPoint(point);
+        ChangeEventsCommand *command =
+            new ChangeEventsCommand(this, tr("Edit Data"));
+        command->remove(e0);
+        command->add(e1);
         return command->finish();
     }
 
-    SortType getSortType(int column) const override
-    {
-        if (column == 4) return SortAlphabetical;
-        return SortNumeric;
+    
+    /**
+     * XmlExportable methods.
+     */
+    void toXml(QTextStream &out,
+               QString indent = "",
+               QString extraAttributes = "") const override {
+
+        Model::toXml
+            (out,
+             indent,
+             QString("type=\"sparse\" dimensions=\"3\" resolution=\"%1\" "
+                     "notifyOnAdd=\"%2\" dataset=\"%3\" subtype=\"%4\" "
+                     "valueQuantization=\"%5\" minimum=\"%6\" maximum=\"%7\" "
+                     "units=\"%8\" %9")
+             .arg(m_resolution)
+             .arg("true") // always true after model reaches 100% -
+                          // subsequent events are always notified
+             .arg(m_events.getExportId())
+             .arg("region")
+             .arg(m_valueQuantization)
+             .arg(m_valueMinimum)
+             .arg(m_valueMaximum)
+             .arg(encodeEntities(m_units))
+             .arg(extraAttributes));
+        
+        m_events.toXml(out, indent, QString("dimensions=\"3\""));
     }
 
-    void addPoint(const Point &point) override
-    {
-        if (point.value != 0.f) m_haveDistinctValues = true;
-        IntervalModel<RegionRec>::addPoint(point);
+    QString toDelimitedDataString(QString delimiter,
+                                  DataExportOptions options,
+                                  sv_frame_t startFrame,
+                                  sv_frame_t duration) const override {
+        return m_events.toDelimitedDataString
+            (delimiter,
+             options,
+             startFrame,
+             duration,
+             m_sampleRate,
+             m_resolution,
+             Event().withValue(0.f).withDuration(m_resolution));
     }
-    
+
 protected:
+    sv_samplerate_t m_sampleRate;
+    int m_resolution;
+
+    float m_valueMinimum;
+    float m_valueMaximum;
+    bool m_haveExtents;
     float m_valueQuantization;
     bool m_haveDistinctValues;
+    QString m_units;
+    DeferredNotifier m_notifier;
+    int m_completion;
+
+    EventSeries m_events;
+
+    mutable QMutex m_mutex;
 };
 
 #endif
--- a/data/model/SparseModel.h	Tue May 07 15:54:15 2019 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1003 +0,0 @@
-/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
-
-/*
-    Sonic Visualiser
-    An audio file viewer and annotation editor.
-    Centre for Digital Music, Queen Mary, University of London.
-    This file copyright 2006 Chris Cannam.
-    
-    This program is free software; you can redistribute it and/or
-    modify it under the terms of the GNU General Public License as
-    published by the Free Software Foundation; either version 2 of the
-    License, or (at your option) any later version.  See the file
-    COPYING included with this distribution for more information.
-*/
-
-#ifndef SV_SPARSE_MODEL_H
-#define SV_SPARSE_MODEL_H
-
-#include "Model.h"
-#include "TabularModel.h"
-#include "base/Command.h"
-#include "base/RealTime.h"
-#include "system/System.h"
-
-#include <iostream>
-
-#include <set>
-#include <vector>
-#include <algorithm>
-#include <iterator>
-
-#include <cmath>
-
-#include <QMutex>
-#include <QTextStream>
-
-/**
- * Model containing sparse data (points with some properties).  The
- * properties depend on the point type.
- */
-
-template <typename PointType>
-class SparseModel : public Model,
-                    public TabularModel
-{
-    // If we omit the Q_OBJECT macro, lupdate complains.
-
-    // If we include it, moc fails (can't handle template classes).
-
-    // If we omit it, lupdate still seems to emit translatable
-    // messages for the tr() strings in here. So I guess we omit it.
-    
-public:
-    SparseModel(sv_samplerate_t sampleRate, int resolution,
-                bool notifyOnAdd = true);
-    virtual ~SparseModel() { }
-    
-    bool isOK() const override { return true; }
-    sv_frame_t getStartFrame() const override;
-    sv_frame_t getEndFrame() const override;
-    sv_samplerate_t getSampleRate() const override { return m_sampleRate; }
-
-    // Number of frames of the underlying sample rate that this model
-    // is capable of resolving to.  For example, if m_resolution == 10
-    // then every point in this model will be at a multiple of 10
-    // sample frames and should be considered to cover a window ending
-    // 10 sample frames later.
-    virtual int getResolution() const {
-        return m_resolution ? m_resolution : 1;
-    }
-    virtual void setResolution(int resolution);
-
-    // Extend the end of the model. If this is set to something beyond
-    // the end of the final point in the model, then getEndFrame()
-    // will return this value. Otherwise getEndFrame() will return the
-    // end of the final point. (This is used by the Tony application)
-    virtual void extendEndFrame(sv_frame_t to) { m_extendTo = to; }
-
-    typedef PointType Point;
-    typedef std::multiset<PointType,
-                          typename PointType::OrderComparator> PointList;
-    typedef typename PointList::iterator PointListIterator;
-    typedef typename PointList::const_iterator PointListConstIterator;
-
-    /**
-     * Return whether the model is empty or not.
-     */
-    virtual bool isEmpty() const;
-
-    /**
-     * Get the total number of points in the model.
-     */
-    virtual int getPointCount() const;
-
-    /**
-     * Get all points.
-     */
-    virtual const PointList &getPoints() const;
-
-    /**
-     * Get all of the points in this model between the given
-     * boundaries (in frames), as well as up to two points before and
-     * after the boundaries.  If you need exact boundaries, check the
-     * point coordinates in the returned list.
-     */
-    virtual PointList getPoints(sv_frame_t start, sv_frame_t end) const;
-
-    /**
-     * Get all points that cover the given frame number, taking the
-     * resolution of the model into account.
-     */
-    virtual PointList getPoints(sv_frame_t frame) const;
-
-    /**
-     * Return all points that share the nearest frame number prior to
-     * the given one at which there are any points.
-     */
-    virtual PointList getPreviousPoints(sv_frame_t frame) const;
-
-    /**
-     * Return all points that share the nearest frame number
-     * subsequent to the given one at which there are any points.
-     */
-    virtual PointList getNextPoints(sv_frame_t frame) const;
-
-    /**
-     * Remove all points.
-     */
-    virtual void clear();
-
-    /**
-     * Add a point.
-     */
-    virtual void addPoint(const PointType &point);
-
-    /** 
-     * Remove a point.  Points are not necessarily unique, so this
-     * function will remove the first point that compares equal to the
-     * supplied one using Point::Comparator.  Other identical points
-     * may remain in the model.
-     */
-    virtual void deletePoint(const PointType &point);
-
-    /**
-     * Return true if the given point is found in this model, false
-     * otherwise.
-     */
-    virtual bool containsPoint(const PointType &point);
-    
-    bool isReady(int *completion = 0) const override {
-        bool ready = isOK() && (m_completion == 100);
-        if (completion) *completion = m_completion;
-        return ready;
-    }
-
-    virtual void setCompletion(int completion, bool update = true);
-    virtual int getCompletion() const { return m_completion; }
-
-    virtual bool hasTextLabels() const { return m_hasTextLabels; }
-
-    bool isSparse() const override { return true; }
-
-    QString getTypeName() const override { return tr("Sparse"); }
-
-    virtual QString getXmlOutputType() const { return "sparse"; }
-
-    void toXml(QTextStream &out,
-               QString indent = "",
-               QString extraAttributes = "") const override;
-
-    QString toDelimitedDataString(QString delimiter) const override {
-        return toDelimitedDataStringWithOptions
-            (delimiter, DataExportDefaults);
-    }
-
-    QString toDelimitedDataStringWithOptions(QString delimiter,
-                                                     DataExportOptions opts) const override {
-        return toDelimitedDataStringSubsetWithOptions
-            (delimiter, opts,
-             std::min(getStartFrame(), sv_frame_t(0)), getEndFrame());
-    }
-
-    QString toDelimitedDataStringSubset(QString delimiter, sv_frame_t f0, sv_frame_t f1) const override {
-        return toDelimitedDataStringSubsetWithOptions
-            (delimiter, DataExportDefaults, f0, f1);
-    }
-
-    QString toDelimitedDataStringSubsetWithOptions(QString delimiter, DataExportOptions opts, sv_frame_t f0, sv_frame_t f1) const override {
-        if (opts & DataExportFillGaps) {
-            return toDelimitedDataStringSubsetFilled(delimiter, opts, f0, f1);
-        } else {
-            QString s;
-            for (PointListConstIterator i = m_points.begin(); i != m_points.end(); ++i) {
-                if (i->frame >= f0 && i->frame < f1) {
-                    s += i->toDelimitedDataString(delimiter, opts, m_sampleRate) + "\n";
-                }
-            }
-            return s;
-        }
-    }
-
-    /**
-     * Command to add a point, with undo.
-     */
-    class AddPointCommand : public Command
-    {
-    public:
-        AddPointCommand(SparseModel<PointType> *model,
-                        const PointType &point,
-                        QString name = "") :
-            m_model(model), m_point(point), m_name(name) { }
-
-        QString getName() const override {
-            return (m_name == "" ? tr("Add Point") : m_name);
-        }
-
-        void execute() override { m_model->addPoint(m_point); }
-        void unexecute() override { m_model->deletePoint(m_point); }
-
-        const PointType &getPoint() const { return m_point; }
-
-    private:
-        SparseModel<PointType> *m_model;
-        PointType m_point;
-        QString m_name;
-    };
-
-
-    /**
-     * Command to remove a point, with undo.
-     */
-    class DeletePointCommand : public Command
-    {
-    public:
-        DeletePointCommand(SparseModel<PointType> *model,
-                           const PointType &point) :
-            m_model(model), m_point(point) { }
-
-        QString getName() const override { return tr("Delete Point"); }
-
-        void execute() override { m_model->deletePoint(m_point); }
-        void unexecute() override { m_model->addPoint(m_point); }
-
-        const PointType &getPoint() const { return m_point; }
-
-    private:
-        SparseModel<PointType> *m_model;
-        PointType m_point;
-    };
-
-    
-    /**
-     * Command to add or remove a series of points, with undo.
-     * Consecutive add/remove pairs for the same point are collapsed.
-     */
-    class EditCommand : public MacroCommand
-    {
-    public:
-        EditCommand(SparseModel<PointType> *model, QString commandName);
-
-        virtual void addPoint(const PointType &point);
-        virtual void deletePoint(const PointType &point);
-
-        /**
-         * Stack an arbitrary other command in the same sequence.
-         */
-        void addCommand(Command *command) override { addCommand(command, true); }
-
-        /**
-         * If any points have been added or deleted, return this
-         * command (so the caller can add it to the command history).
-         * Otherwise delete the command and return NULL.
-         */
-        virtual EditCommand *finish();
-
-    protected:
-        virtual void addCommand(Command *command, bool executeFirst);
-
-        SparseModel<PointType> *m_model;
-    };
-
-
-    /**
-     * Command to relabel a point.
-     */
-    class RelabelCommand : public Command
-    {
-    public:
-        RelabelCommand(SparseModel<PointType> *model,
-                       const PointType &point,
-                       QString newLabel) :
-            m_model(model), m_oldPoint(point), m_newPoint(point) {
-            m_newPoint.label = newLabel;
-        }
-
-        QString getName() const override { return tr("Re-Label Point"); }
-
-        void execute() override { 
-            m_model->deletePoint(m_oldPoint);
-            m_model->addPoint(m_newPoint);
-            std::swap(m_oldPoint, m_newPoint);
-        }
-
-        void unexecute() override { execute(); }
-
-    private:
-        SparseModel<PointType> *m_model;
-        PointType m_oldPoint;
-        PointType m_newPoint;
-    };
-
-    /**
-     * TabularModel methods.  
-     */
-
-    int getRowCount() const override
-    {
-        return int(m_points.size());
-    }
-
-    sv_frame_t getFrameForRow(int row) const override
-    {
-        PointListConstIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return 0;
-        return i->frame;
-    }
-
-    int getRowForFrame(sv_frame_t frame) const override
-    {
-        if (m_rows.empty()) rebuildRowVector();
-        std::vector<sv_frame_t>::iterator i =
-            std::lower_bound(m_rows.begin(), m_rows.end(), frame);
-        ssize_t row = std::distance(m_rows.begin(), i);
-        if (i != m_rows.begin() && (i == m_rows.end() || *i != frame)) {
-            --row;
-        }
-        return int(row);
-    }
-
-    int getColumnCount() const override { return 1; }
-    QVariant getData(int row, int column, int role) const override
-    {
-        PointListConstIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) {
-//            cerr << "no iterator for row " << row << " (have " << getRowCount() << " rows)" << endl;
-            return QVariant();
-        }
-
-//        cerr << "returning data for row " << row << " col " << column << endl;
-        
-        switch (column) {
-        case 0: {
-            if (role == SortRole) return int(i->frame);
-            RealTime rt = RealTime::frame2RealTime(i->frame, getSampleRate());
-            if (role == Qt::EditRole) return rt.toString().c_str();
-            else return rt.toText().c_str();
-        }
-        case 1: return int(i->frame);
-        }
-
-        return QVariant();
-    }
-
-    Command *getSetDataCommand(int row, int column,
-                               const QVariant &value, int role) override
-    {
-        if (role != Qt::EditRole) return 0;
-        PointListIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return 0;
-        EditCommand *command = new EditCommand(this, tr("Edit Data"));
-
-        Point point(*i);
-        command->deletePoint(point);
-
-        switch (column) {
-        case 0: point.frame = lrint(value.toDouble() * getSampleRate()); break;
-        case 1: point.frame = value.toInt(); break; 
-        }
-
-        command->addPoint(point);
-        return command->finish();
-    }
-
-    Command *getInsertRowCommand(int row) override
-    {
-        EditCommand *command = new EditCommand(this, tr("Insert Data Point"));
-        Point point(0);
-        PointListIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end() && i != m_points.begin()) --i;
-        if (i != m_points.end()) point = *i;
-        command->addPoint(point);
-        return command->finish();
-    }
-            
-    Command *getRemoveRowCommand(int row) override
-    {
-        PointListIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return 0;
-        EditCommand *command = new EditCommand(this, tr("Delete Data Point"));
-        command->deletePoint(*i);
-        return command->finish();
-    }
-            
-protected:
-    sv_samplerate_t m_sampleRate;
-    int m_resolution;
-    sv_frame_t m_extendTo;
-    bool m_notifyOnAdd;
-    sv_frame_t m_sinceLastNotifyMin;
-    sv_frame_t m_sinceLastNotifyMax;
-    bool m_hasTextLabels;
-
-    PointList m_points;
-    int m_pointCount;
-    mutable QMutex m_mutex;
-    int m_completion;
-
-    void getPointIterators(sv_frame_t frame,
-                           PointListIterator &startItr,
-                           PointListIterator &endItr);
-    void getPointIterators(sv_frame_t frame,
-                           PointListConstIterator &startItr,
-                           PointListConstIterator &endItr) const;
-
-    // This is only used if the model is called on to act in
-    // TabularModel mode
-    mutable std::vector<sv_frame_t> m_rows; // map from row number to frame
-
-    void rebuildRowVector() const
-    {
-        m_rows.clear();
-        for (PointListConstIterator i = m_points.begin(); i != m_points.end(); ++i) {
-//            std::cerr << "rebuildRowVector: row " << m_rows.size() << " -> " << i->frame << std::endl;
-            m_rows.push_back(i->frame);
-        }
-    }
-
-    PointListIterator getPointListIteratorForRow(int row)
-    {
-        if (m_rows.empty()) rebuildRowVector();
-        if (row < 0 || row + 1 > int(m_rows.size())) return m_points.end();
-
-        sv_frame_t frame = m_rows[row];
-        int indexAtFrame = 0;
-        int ri = row;
-        while (ri > 0 && m_rows[ri-1] == m_rows[row]) { --ri; ++indexAtFrame; }
-        int initialIndexAtFrame = indexAtFrame;
-
-        PointListIterator i0, i1;
-        getPointIterators(frame, i0, i1);
-        PointListIterator i = i0;
-
-        for (i = i0; i != i1; ++i) {
-            if (i->frame < (int)frame) { continue; }
-            if (indexAtFrame > 0) { --indexAtFrame; continue; }
-            return i;
-        }
-
-        if (indexAtFrame > 0) {
-            std::cerr << "WARNING: SparseModel::getPointListIteratorForRow: No iterator available for row " << row << " (frame = " << frame << ", index at frame = " << initialIndexAtFrame << ", leftover index " << indexAtFrame << ")" << std::endl;
-        }
-        return i;
-    }
-
-    PointListConstIterator getPointListIteratorForRow(int row) const
-    {
-        if (m_rows.empty()) rebuildRowVector();
-        if (row < 0 || row + 1 > int(m_rows.size())) return m_points.end();
-
-        sv_frame_t frame = m_rows[row];
-        int indexAtFrame = 0;
-        int ri = row;
-        while (ri > 0 && m_rows[ri-1] == m_rows[row]) { --ri; ++indexAtFrame; }
-        int initialIndexAtFrame = indexAtFrame;
-
-//        std::cerr << "getPointListIteratorForRow " << row << ": initialIndexAtFrame = " << initialIndexAtFrame << " for frame " << frame << std::endl;
-
-        PointListConstIterator i0, i1;
-        getPointIterators(frame, i0, i1);
-        PointListConstIterator i = i0;
-
-        for (i = i0; i != i1; ++i) {
-//            std::cerr << "i->frame is " << i->frame << ", wanting " << frame << std::endl;
-
-            if (i->frame < (int)frame) { continue; }
-            if (indexAtFrame > 0) { --indexAtFrame; continue; }
-            return i;
-        }
-/*
-        if (i == m_points.end()) {
-            std::cerr << "returning i at end" << std::endl;
-        } else {
-            std::cerr << "returning i with i->frame = " << i->frame << std::endl;
-        }
-*/
-        if (indexAtFrame > 0) {
-            std::cerr << "WARNING: SparseModel::getPointListIteratorForRow: No iterator available for row " << row << " (frame = " << frame << ", index at frame = " << initialIndexAtFrame << ", leftover index " << indexAtFrame << ")" << std::endl;
-        }
-        return i;
-    }
-
-    QString toDelimitedDataStringSubsetFilled(QString delimiter,
-                                              DataExportOptions opts,
-                                              sv_frame_t f0,
-                                              sv_frame_t f1) const {
-
-        QString s;
-        opts &= ~DataExportFillGaps;
-
-        // find frame time of first point in range (if any)
-        sv_frame_t first = f0;
-        for (auto &p: m_points) {
-            if (p.frame >= f0) {
-                first = p.frame;
-                break;
-            }
-        }
-
-        // project back to first frame time in range according to
-        // resolution.  e.g. if f0 = 2, first = 9, resolution = 4 then
-        // we start at 5 (because 1 is too early and we need to arrive
-        // at 9 to match the first actual point). This method is
-        // stupid but easy to understand:
-        sv_frame_t f = first;
-        while (f >= f0 + m_resolution) f -= m_resolution;
-        
-        // now progress, either writing the next point (if within
-        // distance) or a default point
-        PointListConstIterator itr = m_points.begin();
-
-        while (f < f1) {
-            if (itr != m_points.end() && itr->frame <= f) {
-                s += itr->toDelimitedDataString(delimiter, opts, m_sampleRate);
-                ++itr;
-            } else {
-                s += Point(f).toDelimitedDataString(delimiter, opts, m_sampleRate);
-            }
-            s += "\n";
-            f += m_resolution;
-        }
-
-        return s;
-    }
-};
-
-
-template <typename PointType>
-SparseModel<PointType>::SparseModel(sv_samplerate_t sampleRate,
-                                    int resolution,
-                                    bool notifyOnAdd) :
-    m_sampleRate(sampleRate),
-    m_resolution(resolution),
-    m_extendTo(0),
-    m_notifyOnAdd(notifyOnAdd),
-    m_sinceLastNotifyMin(-1),
-    m_sinceLastNotifyMax(-1),
-    m_hasTextLabels(false),
-    m_pointCount(0),
-    m_completion(100)
-{
-}
-
-template <typename PointType>
-sv_frame_t
-SparseModel<PointType>::getStartFrame() const
-{
-    QMutexLocker locker(&m_mutex);
-    sv_frame_t f = 0;
-    if (!m_points.empty()) {
-        f = m_points.begin()->frame;
-    }
-    return f;
-}
-
-template <typename PointType>
-sv_frame_t
-SparseModel<PointType>::getEndFrame() const
-{
-    QMutexLocker locker(&m_mutex);
-    sv_frame_t f = 0;
-    if (!m_points.empty()) {
-        PointListConstIterator i(m_points.end());
-        f = (--i)->frame + 1;
-    }
-    if (m_extendTo > f) {
-        return m_extendTo;
-    } else {
-        return f;
-    }
-}
-
-template <typename PointType>
-bool
-SparseModel<PointType>::isEmpty() const
-{
-    return m_pointCount == 0;
-}
-
-template <typename PointType>
-int
-SparseModel<PointType>::getPointCount() const
-{
-    return m_pointCount;
-}
-
-template <typename PointType>
-const typename SparseModel<PointType>::PointList &
-SparseModel<PointType>::getPoints() const
-{
-    return m_points;
-}
-
-template <typename PointType>
-typename SparseModel<PointType>::PointList
-SparseModel<PointType>::getPoints(sv_frame_t start, sv_frame_t end) const
-{
-    if (start > end) return PointList();
-    QMutexLocker locker(&m_mutex);
-
-    PointType startPoint(start), endPoint(end);
-    
-    PointListConstIterator startItr = m_points.lower_bound(startPoint);
-    PointListConstIterator   endItr = m_points.upper_bound(endPoint);
-
-    if (startItr != m_points.begin()) --startItr;
-    if (startItr != m_points.begin()) --startItr;
-    if (endItr != m_points.end()) ++endItr;
-    if (endItr != m_points.end()) ++endItr;
-
-    PointList rv;
-
-    for (PointListConstIterator i = startItr; i != endItr; ++i) {
-        rv.insert(*i);
-    }
-
-    return rv;
-}
-
-template <typename PointType>
-typename SparseModel<PointType>::PointList
-SparseModel<PointType>::getPoints(sv_frame_t frame) const
-{
-    PointListConstIterator startItr, endItr;
-    getPointIterators(frame, startItr, endItr);
-
-    PointList rv;
-
-    for (PointListConstIterator i = startItr; i != endItr; ++i) {
-        rv.insert(*i);
-    }
-
-    return rv;
-}
-
-template <typename PointType>
-void
-SparseModel<PointType>::getPointIterators(sv_frame_t frame,
-                                          PointListIterator &startItr,
-                                          PointListIterator &endItr)
-{
-    QMutexLocker locker(&m_mutex);
-
-    if (m_resolution == 0) {
-        startItr = m_points.end();
-        endItr = m_points.end();
-        return;
-    }
-
-    sv_frame_t start = (frame / m_resolution) * m_resolution;
-    sv_frame_t end = start + m_resolution;
-
-    PointType startPoint(start), endPoint(end);
-
-    startItr = m_points.lower_bound(startPoint);
-      endItr = m_points.upper_bound(endPoint);
-}
-
-template <typename PointType>
-void
-SparseModel<PointType>::getPointIterators(sv_frame_t frame,
-                                          PointListConstIterator &startItr,
-                                          PointListConstIterator &endItr) const
-{
-    QMutexLocker locker(&m_mutex);
-
-    if (m_resolution == 0) {
-//        std::cerr << "getPointIterators: resolution == 0, returning end()" << std::endl;
-        startItr = m_points.end();
-        endItr = m_points.end();
-        return;
-    }
-
-    sv_frame_t start = (frame / m_resolution) * m_resolution;
-    sv_frame_t end = start + m_resolution;
-
-    PointType startPoint(start), endPoint(end);
-    
-//    std::cerr << "getPointIterators: start frame " << start << ", end frame " << end << ", m_resolution " << m_resolution << std::endl;
-
-    startItr = m_points.lower_bound(startPoint);
-      endItr = m_points.upper_bound(endPoint);
-}
-
-template <typename PointType>
-typename SparseModel<PointType>::PointList
-SparseModel<PointType>::getPreviousPoints(sv_frame_t originFrame) const
-{
-    QMutexLocker locker(&m_mutex);
-
-    PointType lookupPoint(originFrame);
-    PointList rv;
-
-    PointListConstIterator i = m_points.lower_bound(lookupPoint);
-    if (i == m_points.begin()) return rv;
-
-    --i;
-    sv_frame_t frame = i->frame;
-    while (i->frame == frame) {
-        rv.insert(*i);
-        if (i == m_points.begin()) break;
-        --i;
-    }
-
-    return rv;
-}
- 
-template <typename PointType>
-typename SparseModel<PointType>::PointList
-SparseModel<PointType>::getNextPoints(sv_frame_t originFrame) const
-{
-    QMutexLocker locker(&m_mutex);
-
-    PointType lookupPoint(originFrame);
-    PointList rv;
-
-    PointListConstIterator i = m_points.upper_bound(lookupPoint);
-    if (i == m_points.end()) return rv;
-
-    sv_frame_t frame = i->frame;
-    while (i != m_points.end() && i->frame == frame) {
-        rv.insert(*i);
-        ++i;
-    }
-
-    return rv;
-}
-
-template <typename PointType>
-void
-SparseModel<PointType>::setResolution(int resolution)
-{
-    {
-        QMutexLocker locker(&m_mutex);
-        m_resolution = resolution;
-        m_rows.clear();
-    }
-    emit modelChanged();
-}
-
-template <typename PointType>
-void
-SparseModel<PointType>::clear()
-{
-    {
-        QMutexLocker locker(&m_mutex);
-        m_points.clear();
-        m_pointCount = 0;
-        m_rows.clear();
-    }
-    emit modelChanged();
-}
-
-template <typename PointType>
-void
-SparseModel<PointType>::addPoint(const PointType &point)
-{
-    {
-        QMutexLocker locker(&m_mutex);
-
-        m_points.insert(point);
-        m_pointCount++;
-        if (point.getLabel() != "") m_hasTextLabels = true;
-
-        // Even though this model is nominally sparse, there may still
-        // be too many signals going on here (especially as they'll
-        // probably be queued from one thread to another), which is
-        // why we need the notifyOnAdd as an option rather than a
-        // necessity (the alternative is to notify on setCompletion).
-
-        if (m_notifyOnAdd) {
-            m_rows.clear(); //!!! inefficient
-        } else {
-            if (m_sinceLastNotifyMin == -1 ||
-                point.frame < m_sinceLastNotifyMin) {
-                m_sinceLastNotifyMin = point.frame;
-            }
-            if (m_sinceLastNotifyMax == -1 ||
-                point.frame > m_sinceLastNotifyMax) {
-                m_sinceLastNotifyMax = point.frame;
-            }
-        }
-    }
-
-    if (m_notifyOnAdd) {
-        emit modelChangedWithin(point.frame, point.frame + m_resolution);
-    }
-}
-
-template <typename PointType>
-bool
-SparseModel<PointType>::containsPoint(const PointType &point)
-{
-    QMutexLocker locker(&m_mutex);
-
-    PointListIterator i = m_points.lower_bound(point);
-    typename PointType::Comparator comparator;
-    while (i != m_points.end()) {
-        if (i->frame > point.frame) break;
-        if (!comparator(*i, point) && !comparator(point, *i)) {
-            return true;
-        }
-        ++i;
-    }
-
-    return false;
-}
-
-template <typename PointType>
-void
-SparseModel<PointType>::deletePoint(const PointType &point)
-{
-    {
-        QMutexLocker locker(&m_mutex);
-
-        PointListIterator i = m_points.lower_bound(point);
-        typename PointType::Comparator comparator;
-        while (i != m_points.end()) {
-            if (i->frame > point.frame) break;
-            if (!comparator(*i, point) && !comparator(point, *i)) {
-                m_points.erase(i);
-                m_pointCount--;
-                break;
-            }
-            ++i;
-        }
-
-//    std::cout << "SparseOneDimensionalModel: emit modelChanged("
-//              << point.frame << ")" << std::endl;
-        m_rows.clear(); //!!! inefficient
-    }
-    
-    emit modelChangedWithin(point.frame, point.frame + m_resolution);
-}
-
-template <typename PointType>
-void
-SparseModel<PointType>::setCompletion(int completion, bool update)
-{
-//    std::cerr << "SparseModel::setCompletion(" << completion << ")" << std::endl;
-    bool emitCompletionChanged = true;
-    bool emitGeneralModelChanged = false;
-    bool emitRegionChanged = false;
-
-    {
-        QMutexLocker locker(&m_mutex);
-
-        if (m_completion != completion) {
-            m_completion = completion;
-
-            if (completion == 100) {
-
-                if (m_notifyOnAdd) {
-                    emitCompletionChanged = false;
-                }
-
-                m_notifyOnAdd = true; // henceforth
-                m_rows.clear(); //!!! inefficient
-                emitGeneralModelChanged = true;
-
-            } else if (!m_notifyOnAdd) {
-
-                if (update &&
-                    m_sinceLastNotifyMin >= 0 &&
-                    m_sinceLastNotifyMax >= 0) {
-                    m_rows.clear(); //!!! inefficient
-                    emitRegionChanged = true;
-                }
-            }
-        }
-    }
-
-    if (emitCompletionChanged) {
-        emit completionChanged();
-    }
-    if (emitGeneralModelChanged) {
-        emit modelChanged();
-    }
-    if (emitRegionChanged) {
-        emit modelChangedWithin(m_sinceLastNotifyMin, m_sinceLastNotifyMax);
-        m_sinceLastNotifyMin = m_sinceLastNotifyMax = -1;
-    }
-}
-
-template <typename PointType>
-void
-SparseModel<PointType>::toXml(QTextStream &out,
-                              QString indent,
-                              QString extraAttributes) const
-{
-//    std::cerr << "SparseModel::toXml: extraAttributes = \"" 
-//              << extraAttributes.toStdString() << std::endl;
-
-    QString type = getXmlOutputType();
-
-    Model::toXml
-        (out,
-         indent,
-         QString("type=\"%1\" dimensions=\"%2\" resolution=\"%3\" notifyOnAdd=\"%4\" dataset=\"%5\" %6")
-         .arg(type)
-         .arg(PointType(0).getDimensions())
-         .arg(m_resolution)
-         .arg(m_notifyOnAdd ? "true" : "false")
-         .arg(getObjectExportId(&m_points))
-         .arg(extraAttributes));
-
-    out << indent;
-    out << QString("<dataset id=\"%1\" dimensions=\"%2\">\n")
-        .arg(getObjectExportId(&m_points))
-        .arg(PointType(0).getDimensions());
-
-    for (PointListConstIterator i = m_points.begin(); i != m_points.end(); ++i) {
-        i->toXml(out, indent + "  ");
-    }
-
-    out << indent;
-    out << "</dataset>\n";
-}
-
-template <typename PointType>
-SparseModel<PointType>::EditCommand::EditCommand(SparseModel *model,
-                                                 QString commandName) :
-    MacroCommand(commandName),
-    m_model(model)
-{
-}
-
-template <typename PointType>
-void
-SparseModel<PointType>::EditCommand::addPoint(const PointType &point)
-{
-    addCommand(new AddPointCommand(m_model, point), true);
-}
-
-template <typename PointType>
-void
-SparseModel<PointType>::EditCommand::deletePoint(const PointType &point)
-{
-    addCommand(new DeletePointCommand(m_model, point), true);
-}
-
-template <typename PointType>
-typename SparseModel<PointType>::EditCommand *
-SparseModel<PointType>::EditCommand::finish()
-{
-    if (!m_commands.empty()) {
-        return this;
-    } else {
-        delete this;
-        return 0;
-    }
-}
-
-template <typename PointType>
-void
-SparseModel<PointType>::EditCommand::addCommand(Command *command,
-                                                bool executeFirst)
-{
-    if (executeFirst) command->execute();
-
-    if (!m_commands.empty()) {
-        DeletePointCommand *dpc = dynamic_cast<DeletePointCommand *>(command);
-        if (dpc) {
-            AddPointCommand *apc = dynamic_cast<AddPointCommand *>
-                (m_commands[m_commands.size() - 1]);
-            typename PointType::Comparator comparator;
-            if (apc) {
-                if (!comparator(apc->getPoint(), dpc->getPoint()) &&
-                    !comparator(dpc->getPoint(), apc->getPoint())) {
-                    deleteCommand(apc);
-                    return;
-                }
-            }
-        }
-    }
-
-    MacroCommand::addCommand(command);
-}
-
-
-#endif
-
-
-    
--- a/data/model/SparseOneDimensionalModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/SparseOneDimensionalModel.h	Thu May 16 11:07:47 2019 +0100
@@ -4,7 +4,6 @@
     Sonic Visualiser
     An audio file viewer and annotation editor.
     Centre for Digital Music, Queen Mary, University of London.
-    This file copyright 2006 Chris Cannam.
     
     This program is free software; you can redistribute it and/or
     modify it under the terms of the GNU General Public License as
@@ -16,110 +15,187 @@
 #ifndef SV_SPARSE_ONE_DIMENSIONAL_MODEL_H
 #define SV_SPARSE_ONE_DIMENSIONAL_MODEL_H
 
-#include "SparseModel.h"
-#include "NoteData.h"
+#include "EventCommands.h"
+#include "TabularModel.h"
+#include "Model.h"
+#include "DeferredNotifier.h"
+
+#include "base/NoteData.h"
+#include "base/EventSeries.h"
+#include "base/NoteExportable.h"
 #include "base/PlayParameterRepository.h"
 #include "base/RealTime.h"
 
+#include "system/System.h"
+
 #include <QStringList>
 
-struct OneDimensionalPoint
-{
-public:
-    OneDimensionalPoint(sv_frame_t _frame) : frame(_frame) { }
-    OneDimensionalPoint(sv_frame_t _frame, QString _label) : frame(_frame), label(_label) { }
-
-    int getDimensions() const { return 1; }
-    
-    sv_frame_t frame;
-    QString label;
-
-    QString getLabel() const { return label; }
-
-    void toXml(QTextStream &stream,
-               QString indent = "",
-               QString extraAttributes = "") const
-    {
-        stream << QString("%1<point frame=\"%2\" label=\"%3\" %4/>\n")
-            .arg(indent).arg(frame).arg(XmlExportable::encodeEntities(label))
-            .arg(extraAttributes);
-    }
-
-    QString toDelimitedDataString(QString delimiter, DataExportOptions, sv_samplerate_t sampleRate) const
-    {
-        QStringList list;
-        list << RealTime::frame2RealTime(frame, sampleRate).toString().c_str();
-        if (label != "") list << label;
-        return list.join(delimiter);
-    }
-
-    struct Comparator {
-        bool operator()(const OneDimensionalPoint &p1,
-                        const OneDimensionalPoint &p2) const {
-            if (p1.frame != p2.frame) return p1.frame < p2.frame;
-            return p1.label < p2.label;
-        }
-    };
-    
-    struct OrderComparator {
-        bool operator()(const OneDimensionalPoint &p1,
-                        const OneDimensionalPoint &p2) const {
-            return p1.frame < p2.frame;
-        }
-    };
-};
-
-
-class SparseOneDimensionalModel : public SparseModel<OneDimensionalPoint>,
+/**
+ * A model representing a series of time instants with optional labels
+ * but without values.
+ */
+class SparseOneDimensionalModel : public Model,
+                                  public TabularModel,
+                                  public EventEditable,
                                   public NoteExportable
 {
     Q_OBJECT
     
 public:
-    SparseOneDimensionalModel(sv_samplerate_t sampleRate, int resolution,
+    SparseOneDimensionalModel(sv_samplerate_t sampleRate,
+                              int resolution,
                               bool notifyOnAdd = true) :
-        SparseModel<OneDimensionalPoint>(sampleRate, resolution, notifyOnAdd)
-    {
+        m_sampleRate(sampleRate),
+        m_resolution(resolution),
+        m_haveTextLabels(false),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
+        m_completion(100) {
         PlayParameterRepository::getInstance()->addPlayable(this);
     }
 
-    virtual ~SparseOneDimensionalModel()
-    {
+    virtual ~SparseOneDimensionalModel() {
         PlayParameterRepository::getInstance()->removePlayable(this);
     }
 
+    QString getTypeName() const override { return tr("Sparse 1-D"); }
+    bool isSparse() const override { return true; }
+    bool isOK() const override { return true; }
+
+    sv_frame_t getStartFrame() const override {
+        return m_events.getStartFrame();
+    }
+    sv_frame_t getEndFrame() const override {
+        if (m_events.isEmpty()) return 0;
+        sv_frame_t e = m_events.getEndFrame() + 1;
+        if (e % m_resolution == 0) return e;
+        else return (e / m_resolution + 1) * m_resolution;
+    }
+    
+    sv_samplerate_t getSampleRate() const override { return m_sampleRate; }
+    int getResolution() const { return m_resolution; }
+
     bool canPlay() const override { return true; }
+    QString getDefaultPlayClipId() const override { return "tap"; }
+    
+    bool hasTextLabels() const { return m_haveTextLabels; }
+        
+    int getCompletion() const override { return m_completion; }
 
-    QString getDefaultPlayClipId() const override
-    {
-        return "tap";
+    void setCompletion(int completion, bool update = true) {
+        
+        {   QMutexLocker locker(&m_mutex);
+            if (m_completion == completion) return;
+            m_completion = completion;
+        }
+
+        if (update) {
+            m_notifier.makeDeferredNotifications();
+        }
+        
+        emit completionChanged();
+
+        if (completion == 100) {
+            // henceforth:
+            m_notifier.switchMode(DeferredNotifier::NOTIFY_ALWAYS);
+            emit modelChanged();
+        }
+    }
+    
+    /**
+     * Query methods.
+     */
+
+    int getEventCount() const {
+        return m_events.count();
+    }
+    bool isEmpty() const {
+        return m_events.isEmpty();
+    }
+    bool containsEvent(const Event &e) const {
+        return m_events.contains(e);
+    }
+    EventVector getAllEvents() const {
+        return m_events.getAllEvents();
+    }
+    EventVector getEventsSpanning(sv_frame_t f, sv_frame_t duration) const {
+        return m_events.getEventsSpanning(f, duration);
+    }
+    EventVector getEventsCovering(sv_frame_t f) const {
+        return m_events.getEventsCovering(f);
+    }
+    EventVector getEventsWithin(sv_frame_t f, sv_frame_t duration,
+                                int overspill = 0) const {
+        return m_events.getEventsWithin(f, duration, overspill);
+    }
+    EventVector getEventsStartingWithin(sv_frame_t f, sv_frame_t duration) const {
+        return m_events.getEventsStartingWithin(f, duration);
+    }
+    EventVector getEventsStartingAt(sv_frame_t f) const {
+        return m_events.getEventsStartingAt(f);
+    }
+    bool getNearestEventMatching(sv_frame_t startSearchAt,
+                                 std::function<bool(Event)> predicate,
+                                 EventSeries::Direction direction,
+                                 Event &found) const {
+        return m_events.getNearestEventMatching
+            (startSearchAt, predicate, direction, found);
     }
 
-    int getIndexOf(const Point &point)
-    {
-        // slow
-        int i = 0;
-        Point::Comparator comparator;
-        for (PointList::const_iterator j = m_points.begin();
-             j != m_points.end(); ++j, ++i) {
-            if (!comparator(*j, point) && !comparator(point, *j)) return i;
+    /**
+     * Editing methods.
+     */
+    void add(Event e) override {
+
+        {   QMutexLocker locker(&m_mutex);
+            m_events.add(e.withoutValue().withoutDuration());
+
+            if (e.getLabel() != "") {
+                m_haveTextLabels = true;
+            }
         }
-        return -1;
+        
+        m_notifier.update(e.getFrame(), m_resolution);
     }
-
-    QString getTypeName() const override { return tr("Sparse 1-D"); }
-
+    
+    void remove(Event e) override {
+        {   QMutexLocker locker(&m_mutex);
+            m_events.remove(e);
+        }
+        emit modelChangedWithin(e.getFrame(), e.getFrame() + m_resolution);
+    }
+    
     /**
      * TabularModel methods.  
      */
     
-    int getColumnCount() const override
-    {
+    int getRowCount() const override {
+        return m_events.count();
+    }
+
+    int getColumnCount() const override {
         return 3;
     }
 
-    QString getHeading(int column) const override
-    {
+    bool isColumnTimeValue(int column) const override {
+        return (column < 2);
+    }
+
+    sv_frame_t getFrameForRow(int row) const override {
+        if (row < 0 || row >= m_events.count()) {
+            return 0;
+        }
+        Event e = m_events.getEventByIndex(row);
+        return e.getFrame();
+    }
+
+    int getRowForFrame(sv_frame_t frame) const override {
+        return m_events.getIndexForEvent(Event(frame));
+    }
+    
+    QString getHeading(int column) const override {
         switch (column) {
         case 0: return tr("Time");
         case 1: return tr("Frame");
@@ -128,82 +204,117 @@
         }
     }
 
-    QVariant getData(int row, int column, int role) const override
-    {
-        if (column < 2) {
-            return SparseModel<OneDimensionalPoint>::getData
-                (row, column, role);
+    SortType getSortType(int column) const override {
+        if (column == 2) return SortAlphabetical;
+        return SortNumeric;
+    }
+
+    QVariant getData(int row, int column, int role) const override {
+        
+        if (row < 0 || row >= m_events.count()) {
+            return QVariant();
         }
 
-        PointListConstIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return QVariant();
+        Event e = m_events.getEventByIndex(row);
 
         switch (column) {
-        case 2: return i->label;
+        case 0: return adaptFrameForRole(e.getFrame(), getSampleRate(), role);
+        case 1: return int(e.getFrame());
+        case 2: return e.getLabel();
         default: return QVariant();
         }
     }
 
-    Command *getSetDataCommand(int row, int column, const QVariant &value, int role) override
-    {
-        if (column < 2) {
-            return SparseModel<OneDimensionalPoint>::getSetDataCommand
-                (row, column, value, role);
+    Command *getSetDataCommand(int row, int column, const QVariant &value, int role) override {
+        if (row < 0 || row >= m_events.count()) return nullptr;
+        if (role != Qt::EditRole) return nullptr;
+
+        Event e0 = m_events.getEventByIndex(row);
+        Event e1;
+
+        switch (column) {
+        case 0: e1 = e0.withFrame(sv_frame_t(round(value.toDouble() *
+                                                   getSampleRate()))); break;
+        case 1: e1 = e0.withFrame(value.toInt()); break;
+        case 2: e1 = e0.withLabel(value.toString()); break;
         }
 
-        if (role != Qt::EditRole) return 0;
-        PointListConstIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return 0;
-        EditCommand *command = new EditCommand(this, tr("Edit Data"));
-
-        Point point(*i);
-        command->deletePoint(point);
-
-        switch (column) {
-        case 2: point.label = value.toString(); break;
-        }
-
-        command->addPoint(point);
+        ChangeEventsCommand *command =
+            new ChangeEventsCommand(this, tr("Edit Data"));
+        command->remove(e0);
+        command->add(e1);
         return command->finish();
     }
 
-
-    bool isColumnTimeValue(int column) const override
-    {
-        return (column < 2); 
-    }
-
-    SortType getSortType(int column) const override
-    {
-        if (column == 2) return SortAlphabetical;
-        return SortNumeric;
-    }
-
     /**
      * NoteExportable methods.
      */
 
     NoteList getNotes() const override {
-        return getNotesWithin(getStartFrame(), getEndFrame());
+        return getNotesStartingWithin(getStartFrame(),
+                                      getEndFrame() - getStartFrame());
     }
 
-    NoteList getNotesWithin(sv_frame_t startFrame, sv_frame_t endFrame) const override {
+    NoteList getNotesActiveAt(sv_frame_t frame) const override {
+        return getNotesStartingWithin(frame, 1);
+    }
+
+    NoteList getNotesStartingWithin(sv_frame_t startFrame,
+                                    sv_frame_t duration) const override {
         
-        PointList points = getPoints(startFrame, endFrame);
         NoteList notes;
-
-        for (PointList::iterator pli =
-                 points.begin(); pli != points.end(); ++pli) {
-
-            notes.push_back
-                (NoteData(pli->frame,
-                          sv_frame_t(getSampleRate() / 6), // arbitrary short duration
-                          64,   // default pitch
-                          100)); // default velocity
+        EventVector ee = m_events.getEventsStartingWithin(startFrame, duration);
+        for (const auto &e: ee) {
+            notes.push_back(e.toNoteData(getSampleRate(), true));
         }
-
         return notes;
     }
+    
+    /**
+     * XmlExportable methods.
+     */
+    void toXml(QTextStream &out,
+               QString indent = "",
+               QString extraAttributes = "") const override {
+
+        Model::toXml
+            (out,
+             indent,
+             QString("type=\"sparse\" dimensions=\"1\" resolution=\"%1\" "
+                     "notifyOnAdd=\"%2\" dataset=\"%3\" %4")
+             .arg(m_resolution)
+             .arg("true") // always true after model reaches 100% -
+                          // subsequent events are always notified
+             .arg(m_events.getExportId())
+             .arg(extraAttributes));
+        
+        m_events.toXml(out, indent, QString("dimensions=\"1\""));
+    }
+
+    QString toDelimitedDataString(QString delimiter,
+                                  DataExportOptions options,
+                                  sv_frame_t startFrame,
+                                  sv_frame_t duration) const override {
+        return m_events.toDelimitedDataString(delimiter,
+                                              options,
+                                              startFrame,
+                                              duration,
+                                              m_sampleRate,
+                                              m_resolution,
+                                              Event());
+    }
+    
+protected:
+    sv_samplerate_t m_sampleRate;
+    int m_resolution;
+
+    bool m_haveTextLabels;
+    DeferredNotifier m_notifier;
+    int m_completion;
+
+    EventSeries m_events;
+
+    mutable QMutex m_mutex;  
 };
 
 #endif
--- a/data/model/SparseTimeValueModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/SparseTimeValueModel.h	Thu May 16 11:07:47 2019 +0100
@@ -4,7 +4,6 @@
     Sonic Visualiser
     An audio file viewer and annotation editor.
     Centre for Digital Music, Queen Mary, University of London.
-    This file copyright 2006 Chris Cannam.
     
     This program is free software; you can redistribute it and/or
     modify it under the terms of the GNU General Public License as
@@ -16,76 +15,43 @@
 #ifndef SV_SPARSE_TIME_VALUE_MODEL_H
 #define SV_SPARSE_TIME_VALUE_MODEL_H
 
-#include "SparseValueModel.h"
+#include "EventCommands.h"
+#include "TabularModel.h"
+#include "Model.h"
+#include "DeferredNotifier.h"
+
+#include "base/RealTime.h"
+#include "base/EventSeries.h"
+#include "base/UnitDatabase.h"
 #include "base/PlayParameterRepository.h"
-#include "base/RealTime.h"
+
+#include "system/System.h"
 
 /**
- * Time/value point type for use in a SparseModel or SparseValueModel.
- * With this point type, the model basically represents a wiggly-line
- * plot with points at arbitrary intervals of the model resolution.
+ * A model representing a wiggly-line plot with points at arbitrary
+ * intervals of the model resolution.
  */
-
-struct TimeValuePoint
-{
-public:
-    TimeValuePoint(sv_frame_t _frame) : frame(_frame), value(0.0f) { }
-    TimeValuePoint(sv_frame_t _frame, float _value, QString _label) : 
-        frame(_frame), value(_value), label(_label) { }
-
-    int getDimensions() const { return 2; }
-    
-    sv_frame_t frame;
-    float value;
-    QString label;
-
-    QString getLabel() const { return label; }
-    
-    void toXml(QTextStream &stream, QString indent = "",
-               QString extraAttributes = "") const
-    {
-        stream << QString("%1<point frame=\"%2\" value=\"%3\" label=\"%4\" %5/>\n")
-            .arg(indent).arg(frame).arg(value).arg(XmlExportable::encodeEntities(label))
-            .arg(extraAttributes);
-    }
-
-    QString toDelimitedDataString(QString delimiter, DataExportOptions, sv_samplerate_t sampleRate) const
-    {
-        QStringList list;
-        list << RealTime::frame2RealTime(frame, sampleRate).toString().c_str();
-        list << QString("%1").arg(value);
-        if (label != "") list << label;
-        return list.join(delimiter);
-    }
-
-    struct Comparator {
-        bool operator()(const TimeValuePoint &p1,
-                        const TimeValuePoint &p2) const {
-            if (p1.frame != p2.frame) return p1.frame < p2.frame;
-            if (p1.value != p2.value) return p1.value < p2.value;
-            return p1.label < p2.label;
-        }
-    };
-    
-    struct OrderComparator {
-        bool operator()(const TimeValuePoint &p1,
-                        const TimeValuePoint &p2) const {
-            return p1.frame < p2.frame;
-        }
-    };
-};
-
-
-class SparseTimeValueModel : public SparseValueModel<TimeValuePoint>
+class SparseTimeValueModel : public Model,
+                             public TabularModel,
+                             public EventEditable
 {
     Q_OBJECT
     
 public:
-    SparseTimeValueModel(sv_samplerate_t sampleRate, int resolution,
+    SparseTimeValueModel(sv_samplerate_t sampleRate,
+                         int resolution,
                          bool notifyOnAdd = true) :
-        SparseValueModel<TimeValuePoint>(sampleRate, resolution,
-                                         notifyOnAdd)
-    {
+        m_sampleRate(sampleRate),
+        m_resolution(resolution),
+        m_valueMinimum(0.f),
+        m_valueMaximum(0.f),
+        m_haveExtents(false),
+        m_haveTextLabels(false),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
+        m_completion(100) {
         // Model is playable, but may not sound (if units not Hz or
         // range unsuitable)
         PlayParameterRepository::getInstance()->addPlayable(this);
@@ -94,36 +60,189 @@
     SparseTimeValueModel(sv_samplerate_t sampleRate, int resolution,
                          float valueMinimum, float valueMaximum,
                          bool notifyOnAdd = true) :
-        SparseValueModel<TimeValuePoint>(sampleRate, resolution,
-                                         valueMinimum, valueMaximum,
-                                         notifyOnAdd)
-    {
+        m_sampleRate(sampleRate),
+        m_resolution(resolution),
+        m_valueMinimum(valueMinimum),
+        m_valueMaximum(valueMaximum),
+        m_haveExtents(true),
+        m_haveTextLabels(false),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
+        m_completion(100) {
         // Model is playable, but may not sound (if units not Hz or
         // range unsuitable)
         PlayParameterRepository::getInstance()->addPlayable(this);
     }
 
-    virtual ~SparseTimeValueModel()
-    {
+    virtual ~SparseTimeValueModel() {
         PlayParameterRepository::getInstance()->removePlayable(this);
     }
 
     QString getTypeName() const override { return tr("Sparse Time-Value"); }
+    bool isSparse() const override { return true; }
+    bool isOK() const override { return true; }
+
+    sv_frame_t getStartFrame() const override {
+        return m_events.getStartFrame();
+    }
+    sv_frame_t getEndFrame() const override {
+        if (m_events.isEmpty()) return 0;
+        sv_frame_t e = m_events.getEndFrame() + 1;
+        if (e % m_resolution == 0) return e;
+        else return (e / m_resolution + 1) * m_resolution;
+    }
+    
+    sv_samplerate_t getSampleRate() const override { return m_sampleRate; }
+    int getResolution() const { return m_resolution; }
 
     bool canPlay() const override { return true; }
     bool getDefaultPlayAudible() const override { return false; } // user must unmute
 
+    QString getScaleUnits() const { return m_units; }
+    void setScaleUnits(QString units) {
+        m_units = units;
+        UnitDatabase::getInstance()->registerUnit(units);
+    }
+
+    bool hasTextLabels() const { return m_haveTextLabels; }
+    
+    float getValueMinimum() const { return m_valueMinimum; }
+    float getValueMaximum() const { return m_valueMaximum; }
+    
+    int getCompletion() const override { return m_completion; }
+
+    void setCompletion(int completion, bool update = true) {
+        
+        {   QMutexLocker locker(&m_mutex);
+            if (m_completion == completion) return;
+            m_completion = completion;
+        }
+
+        if (update) {
+            m_notifier.makeDeferredNotifications();
+        }
+        
+        emit completionChanged();
+
+        if (completion == 100) {
+            // henceforth:
+            m_notifier.switchMode(DeferredNotifier::NOTIFY_ALWAYS);
+            emit modelChanged();
+        }
+    }
+    
+    /**
+     * Query methods.
+     */
+
+    int getEventCount() const {
+        return m_events.count();
+    }
+    bool isEmpty() const {
+        return m_events.isEmpty();
+    }
+    bool containsEvent(const Event &e) const {
+        return m_events.contains(e);
+    }
+    EventVector getAllEvents() const {
+        return m_events.getAllEvents();
+    }
+    EventVector getEventsSpanning(sv_frame_t f, sv_frame_t duration) const {
+        return m_events.getEventsSpanning(f, duration);
+    }
+    EventVector getEventsCovering(sv_frame_t f) const {
+        return m_events.getEventsCovering(f);
+    }
+    EventVector getEventsWithin(sv_frame_t f, sv_frame_t duration,
+                                int overspill = 0) const {
+        return m_events.getEventsWithin(f, duration, overspill);
+    }
+    EventVector getEventsStartingWithin(sv_frame_t f, sv_frame_t duration) const {
+        return m_events.getEventsStartingWithin(f, duration);
+    }
+    EventVector getEventsStartingAt(sv_frame_t f) const {
+        return m_events.getEventsStartingAt(f);
+    }
+    bool getNearestEventMatching(sv_frame_t startSearchAt,
+                                 std::function<bool(Event)> predicate,
+                                 EventSeries::Direction direction,
+                                 Event &found) const {
+        return m_events.getNearestEventMatching
+            (startSearchAt, predicate, direction, found);
+    }
+    
+    /**
+     * Editing methods.
+     */
+    void add(Event e) override {
+
+        bool allChange = false;
+           
+        {   QMutexLocker locker(&m_mutex);
+            m_events.add(e.withoutDuration()); // can't have duration here
+
+            if (e.getLabel() != "") {
+                m_haveTextLabels = true;
+            }
+
+            float v = e.getValue();
+            if (!ISNAN(v) && !ISINF(v)) {
+                if (!m_haveExtents || v < m_valueMinimum) {
+                    m_valueMinimum = v; allChange = true;
+                }
+                if (!m_haveExtents || v > m_valueMaximum) {
+                    m_valueMaximum = v; allChange = true;
+                }
+                m_haveExtents = true;
+            }
+        }
+        
+        m_notifier.update(e.getFrame(), m_resolution);
+
+        if (allChange) {
+            emit modelChanged();
+        }
+    }
+    
+    void remove(Event e) override {
+        {
+            QMutexLocker locker(&m_mutex);
+            m_events.remove(e);
+        }
+        emit modelChangedWithin(e.getFrame(), e.getFrame() + m_resolution);
+    }
+    
     /**
      * TabularModel methods.  
      */
     
-    int getColumnCount() const override
-    {
+    int getRowCount() const override {
+        return m_events.count();
+    }
+
+    int getColumnCount() const override {
         return 4;
     }
 
-    QString getHeading(int column) const override
-    {
+    bool isColumnTimeValue(int column) const override {
+        return (column < 2);
+    }
+
+    sv_frame_t getFrameForRow(int row) const override {
+        if (row < 0 || row >= m_events.count()) {
+            return 0;
+        }
+        Event e = m_events.getEventByIndex(row);
+        return e.getFrame();
+    }
+
+    int getRowForFrame(sv_frame_t frame) const override {
+        return m_events.getIndexForEvent(Event(frame));
+    }
+    
+    QString getHeading(int column) const override {
         switch (column) {
         case 0: return tr("Time");
         case 1: return tr("Frame");
@@ -133,59 +252,104 @@
         }
     }
 
-    QVariant getData(int row, int column, int role) const override
-    {
-        if (column < 2) {
-            return SparseValueModel<TimeValuePoint>::getData
-                (row, column, role);
+    SortType getSortType(int column) const override {
+        if (column == 3) return SortAlphabetical;
+        return SortNumeric;
+    }
+
+    QVariant getData(int row, int column, int role) const override {
+        
+        if (row < 0 || row >= m_events.count()) {
+            return QVariant();
         }
 
-        PointListConstIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return QVariant();
+        Event e = m_events.getEventByIndex(row);
 
         switch (column) {
-        case 2:
-            if (role == Qt::EditRole || role == SortRole) return i->value;
-            else return QString("%1 %2").arg(i->value).arg(getScaleUnits());
-        case 3: return i->label;
+        case 0: return adaptFrameForRole(e.getFrame(), getSampleRate(), role);
+        case 1: return int(e.getFrame());
+        case 2: return adaptValueForRole(e.getValue(), getScaleUnits(), role);
+        case 3: return e.getLabel();
         default: return QVariant();
         }
     }
 
-    Command *getSetDataCommand(int row, int column, const QVariant &value, int role) override
-    {
-        if (column < 2) {
-            return SparseValueModel<TimeValuePoint>::getSetDataCommand
-                (row, column, value, role);
+    Command *getSetDataCommand(int row, int column, const QVariant &value, int role) override {
+        if (row < 0 || row >= m_events.count()) return nullptr;
+        if (role != Qt::EditRole) return nullptr;
+
+        Event e0 = m_events.getEventByIndex(row);
+        Event e1;
+
+        switch (column) {
+        case 0: e1 = e0.withFrame(sv_frame_t(round(value.toDouble() *
+                                                   getSampleRate()))); break;
+        case 1: e1 = e0.withFrame(value.toInt()); break;
+        case 2: e1 = e0.withValue(float(value.toDouble())); break;
+        case 3: e1 = e0.withLabel(value.toString()); break;
         }
 
-        if (role != Qt::EditRole) return 0;
-        PointListConstIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return 0;
-        EditCommand *command = new EditCommand(this, tr("Edit Data"));
-
-        Point point(*i);
-        command->deletePoint(point);
-
-        switch (column) {
-        case 2: point.value = float(value.toDouble()); break;
-        case 3: point.label = value.toString(); break;
-        }
-
-        command->addPoint(point);
+        ChangeEventsCommand *command =
+            new ChangeEventsCommand(this, tr("Edit Data"));
+        command->remove(e0);
+        command->add(e1);
         return command->finish();
     }
+    
+    /**
+     * XmlExportable methods.
+     */
+    void toXml(QTextStream &out,
+               QString indent = "",
+               QString extraAttributes = "") const override {
 
-    bool isColumnTimeValue(int column) const override
-    {
-        return (column < 2); 
+        Model::toXml
+            (out,
+             indent,
+             QString("type=\"sparse\" dimensions=\"2\" resolution=\"%1\" "
+                     "notifyOnAdd=\"%2\" dataset=\"%3\" "
+                     "minimum=\"%4\" maximum=\"%5\" "
+                     "units=\"%6\" %7")
+             .arg(m_resolution)
+             .arg("true") // always true after model reaches 100% -
+                          // subsequent events are always notified
+             .arg(m_events.getExportId())
+             .arg(m_valueMinimum)
+             .arg(m_valueMaximum)
+             .arg(encodeEntities(m_units))
+             .arg(extraAttributes));
+        
+        m_events.toXml(out, indent, QString("dimensions=\"2\""));
     }
 
-    SortType getSortType(int column) const override
-    {
-        if (column == 3) return SortAlphabetical;
-        return SortNumeric;
+    QString toDelimitedDataString(QString delimiter,
+                                  DataExportOptions options,
+                                  sv_frame_t startFrame,
+                                  sv_frame_t duration) const override {
+        return m_events.toDelimitedDataString(delimiter,
+                                              options,
+                                              startFrame,
+                                              duration,
+                                              m_sampleRate,
+                                              m_resolution,
+                                              Event().withValue(0.f));
     }
+  
+protected:
+    sv_samplerate_t m_sampleRate;
+    int m_resolution;
+
+    float m_valueMinimum;
+    float m_valueMaximum;
+    bool m_haveExtents;
+    bool m_haveTextLabels;
+    QString m_units;
+    DeferredNotifier m_notifier;
+    int m_completion;
+
+    EventSeries m_events;
+
+    mutable QMutex m_mutex;  
 };
 
 
--- a/data/model/SparseValueModel.h	Tue May 07 15:54:15 2019 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,140 +0,0 @@
-/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
-
-/*
-    Sonic Visualiser
-    An audio file viewer and annotation editor.
-    Centre for Digital Music, Queen Mary, University of London.
-    This file copyright 2006 Chris Cannam.
-    
-    This program is free software; you can redistribute it and/or
-    modify it under the terms of the GNU General Public License as
-    published by the Free Software Foundation; either version 2 of the
-    License, or (at your option) any later version.  See the file
-    COPYING included with this distribution for more information.
-*/
-
-#ifndef SV_SPARSE_VALUE_MODEL_H
-#define SV_SPARSE_VALUE_MODEL_H
-
-#include "SparseModel.h"
-#include "base/UnitDatabase.h"
-
-#include "system/System.h"
-
-/**
- * Model containing sparse data (points with some properties) of which
- * one of the properties is an arbitrary float value.  The other
- * properties depend on the point type.
- */
-
-template <typename PointType>
-class SparseValueModel : public SparseModel<PointType>
-{
-public:
-    SparseValueModel(sv_samplerate_t sampleRate, int resolution,
-                     bool notifyOnAdd = true) :
-        SparseModel<PointType>(sampleRate, resolution, notifyOnAdd),
-        m_valueMinimum(0.f),
-        m_valueMaximum(0.f),
-        m_haveExtents(false)
-    { }
-
-    SparseValueModel(sv_samplerate_t sampleRate, int resolution,
-                     float valueMinimum, float valueMaximum,
-                     bool notifyOnAdd = true) :
-        SparseModel<PointType>(sampleRate, resolution, notifyOnAdd),
-        m_valueMinimum(valueMinimum),
-        m_valueMaximum(valueMaximum),
-        m_haveExtents(true)
-    { }
-
-    using SparseModel<PointType>::m_points;
-    using SparseModel<PointType>::modelChanged;
-    using SparseModel<PointType>::getPoints;
-    using SparseModel<PointType>::tr;
-
-    QString getTypeName() const override { return tr("Sparse Value"); }
-
-    virtual float getValueMinimum() const { return m_valueMinimum; }
-    virtual float getValueMaximum() const { return m_valueMaximum; }
-
-    virtual QString getScaleUnits() const { return m_units; }
-    virtual void setScaleUnits(QString units) {
-        m_units = units;
-        UnitDatabase::getInstance()->registerUnit(units);
-    }
-
-    void addPoint(const PointType &point) override
-    {
-        bool allChange = false;
-
-        if (!ISNAN(point.value) && !ISINF(point.value)) {
-            if (!m_haveExtents || point.value < m_valueMinimum) {
-                m_valueMinimum = point.value; allChange = true;
-//                std::cerr << "addPoint: value min = " << m_valueMinimum << std::endl;
-            }
-            if (!m_haveExtents || point.value > m_valueMaximum) {
-                m_valueMaximum = point.value; allChange = true;
-//                std::cerr << "addPoint: value max = " << m_valueMaximum << " (min = " << m_valueMinimum << ")" << std::endl;
-            }
-            m_haveExtents = true;
-        }
-
-        SparseModel<PointType>::addPoint(point);
-        if (allChange) emit modelChanged();
-    }
-
-    void deletePoint(const PointType &point) override
-    {
-        SparseModel<PointType>::deletePoint(point);
-
-        if (point.value == m_valueMinimum ||
-            point.value == m_valueMaximum) {
-
-            float formerMin = m_valueMinimum, formerMax = m_valueMaximum;
-
-            for (typename SparseModel<PointType>::PointList::const_iterator i
-                     = m_points.begin();
-                 i != m_points.end(); ++i) {
-
-                if (i == m_points.begin() || i->value < m_valueMinimum) {
-                    m_valueMinimum = i->value;
-//                    std::cerr << "deletePoint: value min = " << m_valueMinimum << std::endl;
-                } 
-                if (i == m_points.begin() || i->value > m_valueMaximum) {
-                    m_valueMaximum = i->value;
-//                    std::cerr << "deletePoint: value max = " << m_valueMaximum << std::endl;
-                } 
-            }
-
-            if (formerMin != m_valueMinimum || formerMax != m_valueMaximum) {
-                emit modelChanged();
-            }
-        }
-    }
-
-    void toXml(QTextStream &stream,
-               QString indent = "",
-               QString extraAttributes = "") const override
-    {
-        std::cerr << "SparseValueModel::toXml: extraAttributes = \"" 
-                  << extraAttributes.toStdString() << std::endl;
-
-        SparseModel<PointType>::toXml
-            (stream,
-             indent,
-             QString("%1 minimum=\"%2\" maximum=\"%3\" units=\"%4\"")
-             .arg(extraAttributes).arg(m_valueMinimum).arg(m_valueMaximum)
-             .arg(this->encodeEntities(m_units)));
-    }
-
-protected:
-    float m_valueMinimum;
-    float m_valueMaximum;
-    bool m_haveExtents;
-    QString m_units;
-};
-
-
-#endif
-
--- a/data/model/TabularModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/TabularModel.h	Thu May 16 11:07:47 2019 +0100
@@ -19,6 +19,8 @@
 #include <QVariant>
 #include <QString>
 
+#include "base/RealTime.h"
+
 class Command;
 
 /**
@@ -55,6 +57,22 @@
     virtual Command *getSetDataCommand(int /* row */, int /* column */, const QVariant &, int /* role */) { return 0; }
     virtual Command *getInsertRowCommand(int /* beforeRow */) { return 0; }
     virtual Command *getRemoveRowCommand(int /* row */) { return 0; }
+
+    QVariant adaptFrameForRole(sv_frame_t frame,
+                               sv_samplerate_t rate,
+                               int role) const {
+        if (role == SortRole) return int(frame);
+        RealTime rt = RealTime::frame2RealTime(frame, rate);
+        if (role == Qt::EditRole) return rt.toString().c_str();
+        else return rt.toText().c_str();
+    }
+
+    QVariant adaptValueForRole(float value,
+                               QString unit,
+                               int role) const {
+        if (role == SortRole || role == Qt::EditRole) return value;
+        else return QString("%1 %2").arg(value).arg(unit);
+    }
 };
 
 #endif
--- a/data/model/TextModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/TextModel.h	Thu May 16 11:07:47 2019 +0100
@@ -4,7 +4,6 @@
     Sonic Visualiser
     An audio file viewer and annotation editor.
     Centre for Digital Music, Queen Mary, University of London.
-    This file copyright 2006 Chris Cannam.
     
     This program is free software; you can redistribute it and/or
     modify it under the terms of the GNU General Public License as
@@ -16,103 +15,170 @@
 #ifndef SV_TEXT_MODEL_H
 #define SV_TEXT_MODEL_H
 
-#include "SparseModel.h"
+#include "EventCommands.h"
+#include "TabularModel.h"
+#include "Model.h"
+#include "DeferredNotifier.h"
+
+#include "base/EventSeries.h"
 #include "base/XmlExportable.h"
 #include "base/RealTime.h"
 
+#include "system/System.h"
+
 #include <QStringList>
 
 /**
- * Text point type for use in a SparseModel.  This represents a piece
- * of text at a given time and y-value in the [0,1) range (indicative
- * of height on the window).  Intended for casual textual annotations.
+ * A model representing casual textual annotations. A piece of text
+ * has a given time and y-value in the [0,1) range (indicative of
+ * height on the window).
  */
-
-struct TextPoint : public XmlExportable
-{
-public:
-    TextPoint(sv_frame_t _frame) : frame(_frame), height(0.0f) { }
-    TextPoint(sv_frame_t _frame, float _height, QString _label) : 
-        frame(_frame), height(_height), label(_label) { }
-
-    int getDimensions() const { return 2; }
-    
-    sv_frame_t frame;
-    float height;
-    QString label;
-
-    QString getLabel() const { return label; }
-    
-    void toXml(QTextStream &stream, QString indent = "",
-               QString extraAttributes = "") const override
-    {
-        stream << QString("%1<point frame=\"%2\" height=\"%3\" label=\"%4\" %5/>\n")
-            .arg(indent).arg(frame).arg(height)
-            .arg(encodeEntities(label)).arg(extraAttributes);
-    }
-
-    QString toDelimitedDataString(QString delimiter, DataExportOptions, sv_samplerate_t sampleRate) const
-    {
-        QStringList list;
-        list << RealTime::frame2RealTime(frame, sampleRate).toString().c_str();
-        list << QString("%1").arg(height);
-        if (label != "") list << label;
-        return list.join(delimiter);
-    }
-
-    struct Comparator {
-        bool operator()(const TextPoint &p1,
-                        const TextPoint &p2) const {
-            if (p1.frame != p2.frame) return p1.frame < p2.frame;
-            if (p1.height != p2.height) return p1.height < p2.height;
-            return p1.label < p2.label;
-        }
-    };
-    
-    struct OrderComparator {
-        bool operator()(const TextPoint &p1,
-                        const TextPoint &p2) const {
-            return p1.frame < p2.frame;
-        }
-    };
-};
-
-
-// Make this a class rather than a typedef so it can be predeclared.
-
-class TextModel : public SparseModel<TextPoint>
+class TextModel : public Model,
+                  public TabularModel,
+                  public EventEditable
 {
     Q_OBJECT
     
 public:
-    TextModel(sv_samplerate_t sampleRate, int resolution, bool notifyOnAdd = true) :
-        SparseModel<TextPoint>(sampleRate, resolution, notifyOnAdd)
-    { }
-
-    void toXml(QTextStream &out,
-                       QString indent = "",
-                       QString extraAttributes = "") const override
-    {
-        SparseModel<TextPoint>::toXml
-            (out, 
-             indent,
-             QString("%1 subtype=\"text\"")
-             .arg(extraAttributes));
+    TextModel(sv_samplerate_t sampleRate,
+              int resolution,
+              bool notifyOnAdd = true) :
+        m_sampleRate(sampleRate),
+        m_resolution(resolution),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
+        m_completion(100) {
     }
 
     QString getTypeName() const override { return tr("Text"); }
+    bool isSparse() const override { return true; }
+    bool isOK() const override { return true; }
+
+    sv_frame_t getStartFrame() const override {
+        return m_events.getStartFrame();
+    }
+    sv_frame_t getEndFrame() const override {
+        if (m_events.isEmpty()) return 0;
+        sv_frame_t e = m_events.getEndFrame() + 1;
+        if (e % m_resolution == 0) return e;
+        else return (e / m_resolution + 1) * m_resolution;
+    }
+    
+    sv_samplerate_t getSampleRate() const override { return m_sampleRate; }
+    int getResolution() const { return m_resolution; }
+    
+    int getCompletion() const override { return m_completion; }
+
+    void setCompletion(int completion, bool update = true) {
+        
+        {   QMutexLocker locker(&m_mutex);
+            if (m_completion == completion) return;
+            m_completion = completion;
+        }
+
+        if (update) {
+            m_notifier.makeDeferredNotifications();
+        }
+        
+        emit completionChanged();
+
+        if (completion == 100) {
+            // henceforth:
+            m_notifier.switchMode(DeferredNotifier::NOTIFY_ALWAYS);
+            emit modelChanged();
+        }
+    }
+    
+    /**
+     * Query methods.
+     */
+
+    int getEventCount() const {
+        return m_events.count();
+    }
+    bool isEmpty() const {
+        return m_events.isEmpty();
+    }
+    bool containsEvent(const Event &e) const {
+        return m_events.contains(e);
+    }
+    EventVector getAllEvents() const {
+        return m_events.getAllEvents();
+    }
+    EventVector getEventsSpanning(sv_frame_t f, sv_frame_t duration) const {
+        return m_events.getEventsSpanning(f, duration);
+    }
+    EventVector getEventsCovering(sv_frame_t f) const {
+        return m_events.getEventsCovering(f);
+    }
+    EventVector getEventsWithin(sv_frame_t f, sv_frame_t duration,
+                                int overspill = 0) const {
+        return m_events.getEventsWithin(f, duration, overspill);
+    }
+    EventVector getEventsStartingWithin(sv_frame_t f, sv_frame_t duration) const {
+        return m_events.getEventsStartingWithin(f, duration);
+    }
+    EventVector getEventsStartingAt(sv_frame_t f) const {
+        return m_events.getEventsStartingAt(f);
+    }
+    bool getNearestEventMatching(sv_frame_t startSearchAt,
+                                 std::function<bool(Event)> predicate,
+                                 EventSeries::Direction direction,
+                                 Event &found) const {
+        return m_events.getNearestEventMatching
+            (startSearchAt, predicate, direction, found);
+    }
+
+    /**
+     * Editing methods.
+     */
+    void add(Event e) override {
+
+        {   QMutexLocker locker(&m_mutex);
+            m_events.add(e.withoutDuration().withoutLevel());
+        }
+        
+        m_notifier.update(e.getFrame(), m_resolution);
+    }
+    
+    void remove(Event e) override {
+        {   QMutexLocker locker(&m_mutex);
+            m_events.remove(e);
+        }
+        emit modelChangedWithin(e.getFrame(), e.getFrame() + m_resolution);
+    }
 
     /**
      * TabularModel methods.  
      */
     
-    int getColumnCount() const override
-    {
+    int getRowCount() const override {
+        return m_events.count();
+    }
+
+    int getColumnCount() const override {
         return 4;
     }
 
-    QString getHeading(int column) const override
-    {
+    bool isColumnTimeValue(int column) const override {
+        return (column < 2);
+    }
+
+    sv_frame_t getFrameForRow(int row) const override {
+        if (row < 0 || row >= m_events.count()) {
+            return 0;
+        }
+        Event e = m_events.getEventByIndex(row);
+        return e.getFrame();
+    }
+
+    int getRowForFrame(sv_frame_t frame) const override {
+        return m_events.getIndexForEvent(Event(frame));
+    }
+    
+    QString getHeading(int column) const override {
         switch (column) {
         case 0: return tr("Time");
         case 1: return tr("Frame");
@@ -122,57 +188,98 @@
         }
     }
 
-    QVariant getData(int row, int column, int role) const override
-    {
-        if (column < 2) {
-            return SparseModel<TextPoint>::getData
-                (row, column, role);
+    SortType getSortType(int column) const override {
+        if (column == 3) return SortAlphabetical;
+        return SortNumeric;
+    }
+
+    QVariant getData(int row, int column, int role) const override {
+        
+        if (row < 0 || row >= m_events.count()) {
+            return QVariant();
         }
 
-        PointListConstIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return QVariant();
+        Event e = m_events.getEventByIndex(row);
 
         switch (column) {
-        case 2: return i->height;
-        case 3: return i->label;
+        case 0: return adaptFrameForRole(e.getFrame(), getSampleRate(), role);
+        case 1: return int(e.getFrame());
+        case 2: return e.getValue();
+        case 3: return e.getLabel();
         default: return QVariant();
         }
     }
 
-    Command *getSetDataCommand(int row, int column, const QVariant &value, int role) override
-    {
-        if (column < 2) {
-            return SparseModel<TextPoint>::getSetDataCommand
-                (row, column, value, role);
+    Command *getSetDataCommand(int row, int column, const QVariant &value, int role) override {
+        
+        if (row < 0 || row >= m_events.count()) return nullptr;
+        if (role != Qt::EditRole) return nullptr;
+
+        Event e0 = m_events.getEventByIndex(row);
+        Event e1;
+
+        switch (column) {
+        case 0: e1 = e0.withFrame(sv_frame_t(round(value.toDouble() *
+                                                   getSampleRate()))); break;
+        case 1: e1 = e0.withFrame(value.toInt()); break;
+        case 2: e1 = e0.withValue(float(value.toDouble())); break;
+        case 3: e1 = e0.withLabel(value.toString()); break;
         }
 
-        if (role != Qt::EditRole) return 0;
-        PointListIterator i = getPointListIteratorForRow(row);
-        if (i == m_points.end()) return 0;
-        EditCommand *command = new EditCommand(this, tr("Edit Data"));
-
-        Point point(*i);
-        command->deletePoint(point);
-
-        switch (column) {
-        case 2: point.height = float(value.toDouble()); break;
-        case 3: point.label = value.toString(); break;
-        }
-
-        command->addPoint(point);
+        ChangeEventsCommand *command =
+            new ChangeEventsCommand(this, tr("Edit Data"));
+        command->remove(e0);
+        command->add(e1);
         return command->finish();
     }
+    
+    /**
+     * XmlExportable methods.
+     */
+    void toXml(QTextStream &out,
+               QString indent = "",
+               QString extraAttributes = "") const override {
 
-    bool isColumnTimeValue(int column) const override
-    {
-        return (column < 2); 
+        Model::toXml
+            (out,
+             indent,
+             QString("type=\"sparse\" dimensions=\"2\" resolution=\"%1\" "
+                     "notifyOnAdd=\"%2\" dataset=\"%3\" subtype=\"text\" %4")
+             .arg(m_resolution)
+             .arg("true") // always true after model reaches 100% -
+                          // subsequent events are always notified
+             .arg(m_events.getExportId())
+             .arg(extraAttributes));
+
+        Event::ExportNameOptions options;
+        options.valueAtttributeName = "height";
+        
+        m_events.toXml(out, indent, QString("dimensions=\"2\""), options);
     }
 
-    SortType getSortType(int column) const override
-    {
-        if (column == 3) return SortAlphabetical;
-        return SortNumeric;
+    QString toDelimitedDataString(QString delimiter,
+                                  DataExportOptions options,
+                                  sv_frame_t startFrame,
+                                  sv_frame_t duration) const override {
+        return m_events.toDelimitedDataString(delimiter,
+                                              options,
+                                              startFrame,
+                                              duration,
+                                              m_sampleRate,
+                                              m_resolution,
+                                              Event().withValue(0.f));
     }
+  
+protected:
+    sv_samplerate_t m_sampleRate;
+    int m_resolution;
+
+    DeferredNotifier m_notifier;
+    int m_completion;
+
+    EventSeries m_events;
+
+    mutable QMutex m_mutex;  
 
 };
 
--- a/data/model/WritableWaveFileModel.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/WritableWaveFileModel.cpp	Thu May 16 11:07:47 2019 +0100
@@ -221,15 +221,6 @@
     return (m_model && m_model->isOK());
 }
 
-bool
-WritableWaveFileModel::isReady(int *completion) const
-{
-    int c = getCompletion();
-    if (completion) *completion = c;
-    if (!isOK()) return false;
-    return (c == 100);
-}
-
 void
 WritableWaveFileModel::setWriteProportion(int proportion)
 {
--- a/data/model/WritableWaveFileModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/WritableWaveFileModel.h	Thu May 16 11:07:47 2019 +0100
@@ -139,7 +139,6 @@
     int getWriteProportion() const;
     
     bool isOK() const override;
-    bool isReady(int *) const override;
     
     /**
      * Return the generation completion percentage of this model. This
@@ -147,7 +146,7 @@
      * -- it just contains varying amounts of data depending on how
      * much has been written.
      */
-    virtual int getCompletion() const { return 100; }
+    int getCompletion() const override { return 100; }
 
     const ZoomConstraint *getZoomConstraint() const override {
         static PowerOfSqrtTwoZoomConstraint zc;
--- a/data/model/test/MockWaveModel.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/test/MockWaveModel.h	Thu May 16 11:07:47 2019 +0100
@@ -51,6 +51,7 @@
     sv_frame_t getEndFrame() const override { return m_data[0].size(); }
     sv_samplerate_t getSampleRate() const override { return 44100; }
     bool isOK() const override { return true; }
+    int getCompletion() const override { return 100; }
     
     QString getTypeName() const override { return tr("Mock Wave"); }
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/data/model/test/TestSparseModels.h	Thu May 16 11:07:47 2019 +0100
@@ -0,0 +1,305 @@
+/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
+
+/*
+    Sonic Visualiser
+    An audio file viewer and annotation editor.
+    Centre for Digital Music, Queen Mary, University of London.
+    
+    This program is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public License as
+    published by the Free Software Foundation; either version 2 of the
+    License, or (at your option) any later version.  See the file
+    COPYING included with this distribution for more information.
+*/
+
+#ifndef TEST_SPARSE_MODELS_H
+#define TEST_SPARSE_MODELS_H
+
+#include "../SparseOneDimensionalModel.h"
+#include "../NoteModel.h"
+#include "../TextModel.h"
+#include "../PathModel.h"
+#include "../ImageModel.h"
+
+#include <QObject>
+#include <QtTest>
+
+#include <iostream>
+
+using namespace std;
+
+// NB model & dataset IDs in the export tests are incremental,
+// depending on how many have been exported in previous tests - so
+// when adding or removing tests we may occasionally need to update
+// the IDs in other ones
+
+class TestSparseModels : public QObject
+{
+    Q_OBJECT
+
+private slots:
+    void s1d_empty() {
+        SparseOneDimensionalModel m(100, 10, false);
+        QCOMPARE(m.isEmpty(), true);
+        QCOMPARE(m.getEventCount(), 0);
+        QCOMPARE(m.getAllEvents().size(), size_t(0));
+        QCOMPARE(m.getStartFrame(), 0);
+        QCOMPARE(m.getEndFrame(), 0);
+        QCOMPARE(m.getSampleRate(), 100.0);
+        QCOMPARE(m.getResolution(), 10);
+        QCOMPARE(m.isSparse(), true);
+
+        Event p(10);
+        m.add(p);
+        m.remove(p);
+        QCOMPARE(m.isEmpty(), true);
+        QCOMPARE(m.getEventCount(), 0);
+        QCOMPARE(m.getAllEvents().size(), size_t(0));
+        QCOMPARE(m.getStartFrame(), 0);
+        QCOMPARE(m.getEndFrame(), 0);
+    }
+
+    void s1d_extents() {
+        SparseOneDimensionalModel m(100, 10, false);
+        Event p1(20);
+        m.add(p1);
+        QCOMPARE(m.isEmpty(), false);
+        QCOMPARE(m.getEventCount(), 1);
+        Event p2(50);
+        m.add(p2);
+        QCOMPARE(m.isEmpty(), false);
+        QCOMPARE(m.getEventCount(), 2);
+        QCOMPARE(m.getAllEvents().size(), size_t(2));
+        QCOMPARE(*m.getAllEvents().begin(), p1);
+        QCOMPARE(*m.getAllEvents().rbegin(), p2);
+        QCOMPARE(m.getStartFrame(), 20);
+        QCOMPARE(m.getEndFrame(), 60);
+        QCOMPARE(m.containsEvent(p1), true);
+        m.remove(p1);
+        QCOMPARE(m.getEventCount(), 1);
+        QCOMPARE(m.getAllEvents().size(), size_t(1));
+        QCOMPARE(*m.getAllEvents().begin(), p2);
+        QCOMPARE(m.getStartFrame(), 50);
+        QCOMPARE(m.getEndFrame(), 60);
+        QCOMPARE(m.containsEvent(p1), false);
+    }
+             
+    void s1d_sample() {
+        SparseOneDimensionalModel m(100, 10, false);
+        Event p1(20), p2(20), p3(50);
+        m.add(p1);
+        m.add(p2);
+        m.add(p3);
+        QCOMPARE(m.getAllEvents().size(), size_t(3));
+        QCOMPARE(*m.getAllEvents().begin(), p1);
+        QCOMPARE(*m.getAllEvents().rbegin(), p3);
+
+        // The EventSeries that is used internally is tested more
+        // thoroughly in its own test suite. This is just a check
+        auto pp = m.getEventsWithin(20, 10);
+        QCOMPARE(pp.size(), size_t(2));
+        QCOMPARE(*pp.begin(), p1);
+        QCOMPARE(*pp.rbegin(), p2);
+        
+        pp = m.getEventsWithin(40, 10);
+        QCOMPARE(pp.size(), size_t(0));
+
+        pp = m.getEventsStartingAt(50);
+        QCOMPARE(pp.size(), size_t(1));
+        QCOMPARE(*pp.begin(), p3);
+    }
+
+    void s1d_xml() {
+        SparseOneDimensionalModel m(100, 10, false);
+        m.setObjectName("This \"&\" that");
+        Event p1(20);
+        Event p2(20, "Label &'\">");
+        Event p3(50, 12.4f, 16, ""); // value + duration should not be saved
+        m.add(p1);
+        m.add(p2);
+        m.add(p3);
+        QString xml;
+        QTextStream str(&xml, QIODevice::WriteOnly);
+        m.toXml(str);
+        str.flush();
+        QString expected =
+            "<model id='1' name='This &quot;&amp;&quot; that' sampleRate='100' start='20' end='60' type='sparse' dimensions='1' resolution='10' notifyOnAdd='true' dataset='0' />\n"
+            "<dataset id='0' dimensions='1'>\n"
+            "  <point frame='20' label='' />\n"
+            "  <point frame='20' label='Label &amp;&apos;&quot;&gt;' />\n"
+            "  <point frame='50' label='' />\n"
+            "</dataset>\n";
+        expected.replace("\'", "\"");
+        if (xml != expected) {
+            cerr << "Obtained xml:\n" << xml
+                 << "\nExpected:\n" << expected << std::endl;
+        }
+        QCOMPARE(xml, expected);
+    }
+
+    void note_extents() {
+        NoteModel m(100, 10, false);
+        Event p1(20, 123.4f, 40, 0.8f, "note 1");
+        m.add(p1);
+        QCOMPARE(m.isEmpty(), false);
+        QCOMPARE(m.getEventCount(), 1);
+        Event p2(50, 124.3f, 30, 0.9f, "note 2");
+        m.add(p2);
+        QCOMPARE(m.isEmpty(), false);
+        QCOMPARE(m.getEventCount(), 2);
+        QCOMPARE(m.getAllEvents().size(), size_t(2));
+        QCOMPARE(*m.getAllEvents().begin(), p1);
+        QCOMPARE(*m.getAllEvents().rbegin(), p2);
+        QCOMPARE(m.getStartFrame(), 20);
+        QCOMPARE(m.getEndFrame(), 80);
+        QCOMPARE(m.containsEvent(p1), true);
+        QCOMPARE(m.getValueMinimum(), 123.4f);
+        QCOMPARE(m.getValueMaximum(), 124.3f);
+        m.remove(p1);
+        QCOMPARE(m.getEventCount(), 1);
+        QCOMPARE(m.getAllEvents().size(), size_t(1));
+        QCOMPARE(*m.getAllEvents().begin(), p2);
+        QCOMPARE(m.getStartFrame(), 50);
+        QCOMPARE(m.getEndFrame(), 80);
+        QCOMPARE(m.containsEvent(p1), false);
+    }
+             
+    void note_sample() {
+        NoteModel m(100, 10, false);
+        Event p1(20, 123.4f, 10, 0.8f, "note 1");
+        Event p2(20, 124.3f, 20, 0.9f, "note 2");
+        Event p3(50, 126.3f, 30, 0.9f, "note 3");
+        m.add(p1);
+        m.add(p2);
+        m.add(p3);
+
+        QCOMPARE(m.getAllEvents().size(), size_t(3));
+        QCOMPARE(*m.getAllEvents().begin(), p1);
+        QCOMPARE(*m.getAllEvents().rbegin(), p3);
+
+        auto pp = m.getEventsSpanning(20, 10);
+        QCOMPARE(pp.size(), size_t(2));
+        QCOMPARE(*pp.begin(), p1);
+        QCOMPARE(*pp.rbegin(), p2);
+
+        pp = m.getEventsSpanning(30, 20);
+        QCOMPARE(pp.size(), size_t(1));
+        QCOMPARE(*pp.begin(), p2);
+
+        pp = m.getEventsSpanning(40, 10);
+        QCOMPARE(pp.size(), size_t(0));
+
+        pp = m.getEventsCovering(50);
+        QCOMPARE(pp.size(), size_t(1));
+        QCOMPARE(*pp.begin(), p3);
+    }
+
+    void note_xml() {
+        NoteModel m(100, 10, false);
+        Event p1(20, 123.4f, 20, 0.8f, "note 1");
+        Event p2(20, 124.3f, 10, 0.9f, "note 2");
+        Event p3(50, 126.3f, 30, 0.9f, "note 3");
+        m.setScaleUnits("Hz");
+        m.add(p1);
+        m.add(p2);
+        m.add(p3);
+        QString xml;
+        QTextStream str(&xml, QIODevice::WriteOnly);
+        m.toXml(str);
+        str.flush();
+        
+        QString expected =
+            "<model id='3' name='' sampleRate='100' start='20' end='80' type='sparse' dimensions='3' resolution='10' notifyOnAdd='true' dataset='2' subtype='note' valueQuantization='0' minimum='123.4' maximum='126.3' units='Hz' />\n"
+            "<dataset id='2' dimensions='3'>\n"
+            "  <point frame='20' value='124.3' duration='10' level='0.9' label='note 2' />\n"
+            "  <point frame='20' value='123.4' duration='20' level='0.8' label='note 1' />\n"
+            "  <point frame='50' value='126.3' duration='30' level='0.9' label='note 3' />\n"
+            "</dataset>\n";
+        expected.replace("\'", "\"");
+        if (xml != expected) {
+            cerr << "Obtained xml:\n" << xml
+                 << "\nExpected:\n" << expected << std::endl;
+        }
+        QCOMPARE(xml, expected);
+    }
+
+    void text_xml() {
+        TextModel m(100, 10, false);
+        Event p1(20, 1.0f, "text 1");
+        Event p2(20, 0.0f, "text 2");
+        Event p3(50, 0.3f, "text 3");
+        m.add(p1);
+        m.add(p2.withLevel(0.8f));
+        m.add(p3);
+        QString xml;
+        QTextStream str(&xml, QIODevice::WriteOnly);
+        m.toXml(str);
+        str.flush();
+
+        QString expected =
+            "<model id='5' name='' sampleRate='100' start='20' end='60' type='sparse' dimensions='2' resolution='10' notifyOnAdd='true' dataset='4' subtype='text' />\n"
+            "<dataset id='4' dimensions='2'>\n"
+            "  <point frame='20' height='0' label='text 2' />\n"
+            "  <point frame='20' height='1' label='text 1' />\n"
+            "  <point frame='50' height='0.3' label='text 3' />\n"
+            "</dataset>\n";
+        expected.replace("\'", "\"");
+        if (xml != expected) {
+            cerr << "Obtained xml:\n" << xml
+                 << "\nExpected:\n" << expected << std::endl;
+        }
+        QCOMPARE(xml, expected);
+    }
+    
+    void path_xml() {
+        PathModel m(100, 10, false);
+        PathPoint p1(20, 30);
+        PathPoint p2(40, 60);
+        PathPoint p3(50, 49);
+        m.add(p1);
+        m.add(p2);
+        m.add(p3);
+        QString xml;
+        QTextStream str(&xml, QIODevice::WriteOnly);
+        m.toXml(str);
+        str.flush();
+
+        QString expected =
+            "<model id='6' name='' sampleRate='100' start='20' end='60' type='sparse' dimensions='2' resolution='10' notifyOnAdd='true' dataset='6' subtype='path' />\n"
+            "<dataset id='6' dimensions='2'>\n"
+            "  <point frame='20' mapframe='30' />\n"
+            "  <point frame='40' mapframe='60' />\n"
+            "  <point frame='50' mapframe='49' />\n"
+            "</dataset>\n";
+        expected.replace("\'", "\"");
+        if (xml != expected) {
+            cerr << "Obtained xml:\n" << xml
+                 << "\nExpected:\n" << expected << std::endl;
+        }
+        QCOMPARE(xml, expected);
+    }
+
+    void image_xml() {
+        ImageModel m(100, 10, false);
+        Event p1(20, 30, 40, "a label"); // value + duration should not be saved
+        m.add(p1.withURI("/path/to/thing.png").withLevel(0.8f));
+        QString xml;
+        QTextStream str(&xml, QIODevice::WriteOnly);
+        m.toXml(str);
+        str.flush();
+
+        QString expected =
+            "<model id='8' name='' sampleRate='100' start='20' end='30' type='sparse' dimensions='1' resolution='10' notifyOnAdd='true' dataset='7' subtype='image' />\n"
+            "<dataset id='7' dimensions='1'>\n"
+            "  <point frame='20' label='a label' image='/path/to/thing.png' />\n"
+            "</dataset>\n";
+        expected.replace("\'", "\"");
+        if (xml != expected) {
+            cerr << "Obtained xml:\n" << xml
+                 << "\nExpected:\n" << expected << std::endl;
+        }
+        QCOMPARE(xml, expected);
+    }
+};
+
+#endif
--- a/data/model/test/files.pri	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/test/files.pri	Thu May 16 11:07:47 2019 +0100
@@ -2,6 +2,7 @@
 	Compares.h \
 	MockWaveModel.h \
 	TestFFTModel.h \
+        TestSparseModels.h \
         TestWaveformOversampler.h \
         TestZoomConstraints.h
 	
--- a/data/model/test/svcore-data-model-test.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/data/model/test/svcore-data-model-test.cpp	Thu May 16 11:07:47 2019 +0100
@@ -14,6 +14,7 @@
 #include "TestFFTModel.h"
 #include "TestZoomConstraints.h"
 #include "TestWaveformOversampler.h"
+#include "TestSparseModels.h"
 
 #include "system/Init.h"
 
@@ -31,7 +32,7 @@
 
     QCoreApplication app(argc, argv);
     app.setOrganizationName("sonic-visualiser");
-    app.setApplicationName("test-model");
+    app.setApplicationName("test-svcore-data-model");
 
     {
         TestFFTModel t;
@@ -51,6 +52,12 @@
         else ++bad;
     }
 
+    {
+        TestSparseModels t;
+        if (QTest::qExec(&t, argc, argv) == 0) ++good;
+        else ++bad;
+    }
+
     if (bad > 0) {
         SVCERR << "\n********* " << bad << " test suite(s) failed!\n" << endl;
         return 1;
--- a/data/osc/OSCMessage.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/osc/OSCMessage.h	Thu May 16 11:07:47 2019 +0100
@@ -50,6 +50,18 @@
     int getArgCount() const;
     const QVariant &getArg(int i) const;
 
+    // For debugging purposes, not for interchange
+    QString toString() const {
+        QString s = QString("[%1][%2] %3")
+            .arg(m_target)
+            .arg(m_targetData)
+            .arg(m_method);
+        for (auto a: m_args) {
+            s.push_back(" \"" + a.toString() + "\"");
+        }
+        return s;
+    }
+
 private:
     int m_target;
     int m_targetData;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/data/osc/OSCMessageCallback.h	Thu May 16 11:07:47 2019 +0100
@@ -0,0 +1,25 @@
+/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*-  vi:set ts=8 sts=4 sw=4: */
+
+/*
+    Sonic Visualiser
+    An audio file viewer and annotation editor.
+    Centre for Digital Music, Queen Mary, University of London.
+    
+    This program is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public License as
+    published by the Free Software Foundation; either version 2 of the
+    License, or (at your option) any later version.  See the file
+    COPYING included with this distribution for more information.
+*/
+
+#ifndef SV_OSC_MESSAGE_CALLBACK_H
+#define SV_OSC_MESSAGE_CALLBACK_H
+
+class OSCMessage;
+
+class OSCMessageCallback {
+public:
+    virtual void handleOSCMessage(const OSCMessage &) = 0;
+};
+
+#endif
--- a/data/osc/OSCQueue.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/data/osc/OSCQueue.cpp	Thu May 16 11:07:47 2019 +0100
@@ -23,6 +23,7 @@
 #include "base/Profiler.h"
 
 #include <iostream>
+#include <QThread>
 
 #define OSC_MESSAGE_QUEUE_SIZE 1023
 
@@ -89,24 +90,37 @@
 
 #endif
    
-OSCQueue::OSCQueue() :
+OSCQueue::OSCQueue(bool withNetworkPort) :
 #ifdef HAVE_LIBLO
     m_thread(nullptr),
 #endif
+    m_withPort(withNetworkPort),
     m_buffer(OSC_MESSAGE_QUEUE_SIZE)
 {
     Profiler profiler("OSCQueue::OSCQueue");
 
 #ifdef HAVE_LIBLO
-    m_thread = lo_server_thread_new(nullptr, oscError);
+    if (m_withPort) {
+        m_thread = lo_server_thread_new(nullptr, oscError);
 
-    lo_server_thread_add_method(m_thread, nullptr, nullptr,
-                                oscMessageHandler, this);
+        lo_server_thread_add_method(m_thread, nullptr, nullptr,
+                                    oscMessageHandler, this);
 
-    lo_server_thread_start(m_thread);
+        lo_server_thread_start(m_thread);
 
-    cout << "OSCQueue::OSCQueue: Base OSC URL is "
-              << lo_server_thread_get_url(m_thread) << endl;
+        SVDEBUG << "OSCQueue::OSCQueue: Started OSC thread, URL is "
+             << lo_server_thread_get_url(m_thread) << endl;
+            
+        cout << "OSCQueue::OSCQueue: Base OSC URL is "
+             << lo_server_thread_get_url(m_thread) << endl;
+    }
+#else
+    if (m_withPort) {
+        SVDEBUG << "OSCQueue::OSCQueue: Note: OSC port support not "
+                << "compiled in; not opening port, falling back to "
+                << "internal-only queue" << endl;
+        m_withPort = false;
+    }
 #endif
 }
 
@@ -126,11 +140,15 @@
 bool
 OSCQueue::isOK() const
 {
+    if (!m_withPort) {
+        return true;
+    } else {
 #ifdef HAVE_LIBLO
-    return (m_thread != nullptr);
+        return (m_thread != nullptr);
 #else
-    return false;
+        return false;
 #endif
+    }
 }
 
 QString
@@ -138,7 +156,9 @@
 {
     QString url = "";
 #ifdef HAVE_LIBLO
-    url = lo_server_thread_get_url(m_thread);
+    if (m_thread) {
+        url = lo_server_thread_get_url(m_thread);
+    }
 #endif
     return url;
 }
@@ -155,6 +175,9 @@
     OSCMessage *message = m_buffer.readOne();
     OSCMessage rmessage = *message;
     delete message;
+    SVDEBUG << "OSCQueue::readMessage: In thread "
+            << QThread::currentThreadId() << ": message follows:\n"
+            << rmessage.toString() << endl;
     return rmessage;
 }
 
@@ -180,8 +203,9 @@
     OSCMessage *mp = new OSCMessage(message);
     m_buffer.write(&mp, 1);
     SVDEBUG << "OSCQueue::postMessage: Posted OSC message: target "
-              << message.getTarget() << ", target data " << message.getTargetData()
-              << ", method " << message.getMethod() << endl;
+            << message.getTarget() << ", target data "
+            << message.getTargetData() << ", method "
+            << message.getMethod() << endl;
     emit messagesAvailable();
 }
 
@@ -220,7 +244,8 @@
         return false;
     }
 
-    SVDEBUG << "OSCQueue::parseOSCPath: good path \"" << path              << "\"" << endl;
+    SVDEBUG << "OSCQueue::parseOSCPath: good path \"" << path
+            << "\"" << endl;
 
     return true;
 }
--- a/data/osc/OSCQueue.h	Tue May 07 15:54:15 2019 +0100
+++ b/data/osc/OSCQueue.h	Thu May 16 11:07:47 2019 +0100
@@ -36,17 +36,20 @@
     Q_OBJECT
 
 public:
-    OSCQueue();
+    OSCQueue(bool withNetworkPort);
     virtual ~OSCQueue();
 
     bool isOK() const;
 
     bool isEmpty() const { return getMessagesAvailable() == 0; }
     int getMessagesAvailable() const;
+    void postMessage(OSCMessage);
     OSCMessage readMessage();
 
     QString getOSCURL() const;
 
+    bool hasPort() const { return m_withPort; }
+
 signals:
     void messagesAvailable();
 
@@ -59,9 +62,9 @@
                                  int, lo_message, void *);
 #endif
 
-    void postMessage(OSCMessage);
     bool parseOSCPath(QString path, int &target, int &targetData, QString &method);
 
+    bool m_withPort;
     RingBuffer<OSCMessage *> m_buffer;
 };
 
--- a/files.pri	Tue May 07 15:54:15 2019 +0100
+++ b/files.pri	Thu May 16 11:07:47 2019 +0100
@@ -7,11 +7,16 @@
            base/ColumnOp.h \
            base/Command.h \
            base/Debug.h \
+           base/Event.h \
+           base/EventSeries.h \
            base/Exceptions.h \
+           base/Extents.h \
            base/HelperExecPath.h \
            base/HitCount.h \
            base/LogRange.h \
            base/MagnitudeRange.h \
+           base/NoteData.h \
+           base/NoteExportable.h \
            base/Pitch.h \
            base/Playable.h \
            base/PlayParameterRepository.h \
@@ -75,25 +80,23 @@
            data/model/Dense3DModelPeakCache.h \
            data/model/DenseThreeDimensionalModel.h \
            data/model/DenseTimeValueModel.h \
+           data/model/DeferredNotifier.h \
            data/model/EditableDenseThreeDimensionalModel.h \
+           data/model/EventCommands.h \
            data/model/FFTModel.h \
            data/model/ImageModel.h \
-           data/model/IntervalModel.h \
            data/model/Labeller.h \
            data/model/Model.h \
            data/model/ModelDataTableModel.h \
            data/model/NoteModel.h \
-           data/model/FlexiNoteModel.h \
            data/model/PathModel.h \
            data/model/PowerOfSqrtTwoZoomConstraint.h \
            data/model/PowerOfTwoZoomConstraint.h \
            data/model/RangeSummarisableTimeValueModel.h \
            data/model/RegionModel.h \
            data/model/RelativelyFineZoomConstraint.h \
-           data/model/SparseModel.h \
            data/model/SparseOneDimensionalModel.h \
            data/model/SparseTimeValueModel.h \
-           data/model/SparseValueModel.h \
            data/model/TabularModel.h \
            data/model/TextModel.h \
            data/model/WaveformOversampler.h \
@@ -101,6 +104,7 @@
            data/model/ReadOnlyWaveFileModel.h \
            data/model/WritableWaveFileModel.h \
            data/osc/OSCMessage.h \
+           data/osc/OSCMessageCallback.h \
            data/osc/OSCQueue.h \
 	   plugin/PluginScan.h \
            plugin/DSSIPluginFactory.h \
@@ -149,6 +153,7 @@
            base/ColumnOp.cpp \
            base/Command.cpp \
            base/Debug.cpp \
+           base/EventSeries.cpp \
            base/Exceptions.cpp \
            base/HelperExecPath.cpp \
            base/LogRange.cpp \
--- a/rdf/RDFExporter.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/rdf/RDFExporter.cpp	Thu May 16 11:07:47 2019 +0100
@@ -85,14 +85,13 @@
         if (m) {
             f.hasTimestamp = true;
             f.hasDuration = true;
-            const RegionModel::PointList &pl(m->getPoints());
-            for (RegionModel::PointList::const_iterator i = pl.begin(); 
-                 i != pl.end(); ++i) {
-                f.timestamp = RealTime::frame2RealTime(i->frame, sr).toVampRealTime();
-                f.duration = RealTime::frame2RealTime(i->duration, sr).toVampRealTime();
+            EventVector ee(m->getAllEvents());
+            for (auto e: ee) {
+                f.timestamp = RealTime::frame2RealTime(e.getFrame(), sr).toVampRealTime();
+                f.duration = RealTime::frame2RealTime(e.getDuration(), sr).toVampRealTime();
                 f.values.clear();
-                f.values.push_back(i->value);
-                f.label = i->label.toStdString();
+                f.values.push_back(e.getValue());
+                f.label = e.getLabel().toStdString();
                 m_fw->write(trackId, transform, output, features, summaryType);
             }
             return;
@@ -103,15 +102,14 @@
         if (m) {
             f.hasTimestamp = true;
             f.hasDuration = true;
-            const NoteModel::PointList &pl(m->getPoints());
-            for (NoteModel::PointList::const_iterator i = pl.begin(); 
-                 i != pl.end(); ++i) {
-                f.timestamp = RealTime::frame2RealTime(i->frame, sr).toVampRealTime();
-                f.duration = RealTime::frame2RealTime(i->duration, sr).toVampRealTime();
+            EventVector ee(m->getAllEvents());
+            for (auto e: ee) {
+                f.timestamp = RealTime::frame2RealTime(e.getFrame(), sr).toVampRealTime();
+                f.duration = RealTime::frame2RealTime(e.getDuration(), sr).toVampRealTime();
                 f.values.clear();
-                f.values.push_back(i->value);
-                f.values.push_back(i->level);
-                f.label = i->label.toStdString();
+                f.values.push_back(e.getValue());
+                f.values.push_back(e.getLevel());
+                f.label = e.getLabel().toStdString();
                 m_fw->write(trackId, transform, output, features, summaryType);
             }
             return;
@@ -122,12 +120,11 @@
         if (m) {
             f.hasTimestamp = true;
             f.hasDuration = false;
-            const SparseOneDimensionalModel::PointList &pl(m->getPoints());
-            for (SparseOneDimensionalModel::PointList::const_iterator i = pl.begin(); 
-                 i != pl.end(); ++i) {
-                f.timestamp = RealTime::frame2RealTime(i->frame, sr).toVampRealTime();
+            EventVector ee(m->getAllEvents());
+            for (auto e: ee) {
+                f.timestamp = RealTime::frame2RealTime(e.getFrame(), sr).toVampRealTime();
                 f.values.clear();
-                f.label = i->label.toStdString();
+                f.label = e.getLabel().toStdString();
                 m_fw->write(trackId, transform, output, features, summaryType);
             }
             return;
@@ -138,13 +135,12 @@
         if (m) {
             f.hasTimestamp = true;
             f.hasDuration = false;
-            const SparseTimeValueModel::PointList &pl(m->getPoints());
-            for (SparseTimeValueModel::PointList::const_iterator i = pl.begin(); 
-                 i != pl.end(); ++i) {
-                f.timestamp = RealTime::frame2RealTime(i->frame, sr).toVampRealTime();
+            EventVector ee(m->getAllEvents());
+            for (auto e: ee) {
+                f.timestamp = RealTime::frame2RealTime(e.getFrame(), sr).toVampRealTime();
                 f.values.clear();
-                f.values.push_back(i->value);
-                f.label = i->label.toStdString();
+                f.values.push_back(e.getValue());
+                f.label = e.getLabel().toStdString();
                 m_fw->write(trackId, transform, output, features, summaryType);
             }
             return;
@@ -155,14 +151,13 @@
         if (m) {
             f.hasTimestamp = true;
             f.hasDuration = false;
-            const TextModel::PointList &pl(m->getPoints());
             m_fw->setFixedEventTypeURI("af:Text");
-            for (TextModel::PointList::const_iterator i = pl.begin(); 
-                 i != pl.end(); ++i) {
-                f.timestamp = RealTime::frame2RealTime(i->frame, sr).toVampRealTime();
+            EventVector ee(m->getAllEvents());
+            for (auto e: ee) {
+                f.timestamp = RealTime::frame2RealTime(e.getFrame(), sr).toVampRealTime();
                 f.values.clear();
-                f.values.push_back(i->height);
-                f.label = i->label.toStdString();
+                f.values.push_back(e.getValue());
+                f.label = e.getLabel().toStdString();
                 m_fw->write(trackId, transform, output, features, summaryType);
             }
             return;
--- a/rdf/RDFImporter.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/rdf/RDFImporter.cpp	Thu May 16 11:07:47 2019 +0100
@@ -347,8 +347,8 @@
 
             for (int j = 0; j < values.size(); ++j) {
                 float f = values[j].toFloat();
-                SparseTimeValueModel::Point point(j * hopSize, f, "");
-                m->addPoint(point);
+                Event e(j * hopSize, f, "");
+                m->add(e);
             }
 
             getDenseModelTitle(m, feature, type);
@@ -709,28 +709,27 @@
     SparseOneDimensionalModel *sodm =
         dynamic_cast<SparseOneDimensionalModel *>(model);
     if (sodm) {
-        SparseOneDimensionalModel::Point point(ftime, label);
-        sodm->addPoint(point);
+        Event point(ftime, label);
+        sodm->add(point);
         return;
     }
 
     TextModel *tm =
         dynamic_cast<TextModel *>(model);
     if (tm) {
-        TextModel::Point point
+        Event e
             (ftime,
              values.empty() ? 0.5f : values[0] < 0.f ? 0.f : values[0] > 1.f ? 1.f : values[0], // I was young and feckless once too
              label);
-        tm->addPoint(point);
+        tm->add(e);
         return;
     }
 
     SparseTimeValueModel *stvm =
         dynamic_cast<SparseTimeValueModel *>(model);
     if (stvm) {
-        SparseTimeValueModel::Point point
-            (ftime, values.empty() ? 0.f : values[0], label);
-        stvm->addPoint(point);
+        Event e(ftime, values.empty() ? 0.f : values[0], label);
+        stvm->add(e);
         return;
     }
 
@@ -745,8 +744,8 @@
                     level = values[1];
                 }
             }
-            NoteModel::Point point(ftime, value, fduration, level, label);
-            nm->addPoint(point);
+            Event e(ftime, value, fduration, level, label);
+            nm->add(e);
         } else {
             float value = 0.f, duration = 1.f, level = 1.f;
             if (!values.empty()) {
@@ -758,9 +757,9 @@
                     }
                 }
             }
-            NoteModel::Point point(ftime, value, sv_frame_t(lrintf(duration)),
-                                   level, label);
-            nm->addPoint(point);
+            Event e(ftime, value, sv_frame_t(lrintf(duration)),
+                        level, label);
+            nm->add(e);
         }
         return;
     }
@@ -779,8 +778,8 @@
             value = values[0];
         }
         if (haveDuration) {
-            RegionModel::Point point(ftime, value, fduration, label);
-            rm->addPoint(point);
+            Event e(ftime, value, fduration, label);
+            rm->add(e);
         } else {
             // This won't actually happen -- we only create region models
             // if we do have duration -- but just for completeness
@@ -791,9 +790,8 @@
                     duration = values[1];
                 }
             }
-            RegionModel::Point point(ftime, value,
-                                     sv_frame_t(lrintf(duration)), label);
-            rm->addPoint(point);
+            Event e(ftime, value, sv_frame_t(lrintf(duration)), label);
+            rm->add(e);
         }
         return;
     }
--- a/transform/FeatureExtractionModelTransformer.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/transform/FeatureExtractionModelTransformer.cpp	Thu May 16 11:07:47 2019 +0100
@@ -28,7 +28,6 @@
 #include "data/model/EditableDenseThreeDimensionalModel.h"
 #include "data/model/DenseTimeValueModel.h"
 #include "data/model/NoteModel.h"
-#include "data/model/FlexiNoteModel.h"
 #include "data/model/RegionModel.h"
 #include "data/model/FFTModel.h"
 #include "data/model/WaveFileModel.h"
@@ -410,13 +409,8 @@
         // count > 1).  But we don't.
 
         QSettings settings;
-        settings.beginGroup("Transformer");
-        bool flexi = settings.value("use-flexi-note-model", false).toBool();
-        settings.endGroup();
 
-        cerr << "flexi = " << flexi << endl;
-
-        if (isNoteModel && !flexi) {
+        if (isNoteModel) {
 
             NoteModel *model;
             if (haveExtents) {
@@ -429,19 +423,6 @@
             model->setScaleUnits(m_descriptors[n]->unit.c_str());
             out = model;
 
-        } else if (isNoteModel && flexi) {
-
-            FlexiNoteModel *model;
-            if (haveExtents) {
-                model = new FlexiNoteModel
-                    (modelRate, modelResolution, minValue, maxValue, false);
-            } else {
-                model = new FlexiNoteModel
-                    (modelRate, modelResolution, false);
-            }
-            model->setScaleUnits(m_descriptors[n]->unit.c_str());
-            out = model;
-
         } else {
 
             RegionModel *model;
@@ -996,8 +977,7 @@
             getConformingOutput<SparseOneDimensionalModel>(n);
         if (!model) return;
 
-        model->addPoint(SparseOneDimensionalModel::Point
-                       (frame, feature.label.c_str()));
+        model->add(Event(frame, feature.label.c_str()));
         
     } else if (isOutput<SparseTimeValueModel>(n)) {
 
@@ -1023,11 +1003,10 @@
 //                          << " for output " << n << " bin " << i << std::endl;
             }
 
-            targetModel->addPoint
-                (SparseTimeValueModel::Point(frame, value, label));
+            targetModel->add(Event(frame, value, label));
         }
 
-    } else if (isOutput<FlexiNoteModel>(n) || isOutput<NoteModel>(n) || isOutput<RegionModel>(n)) { //GF: Added Note Model
+    } else if (isOutput<NoteModel>(n) || isOutput<RegionModel>(n)) {
 
         int index = 0;
 
@@ -1045,24 +1024,7 @@
             }
         }
 
-        if (isOutput<FlexiNoteModel>(n)) { // GF: added for flexi note model
-
-            float velocity = 100;
-            if ((int)feature.values.size() > index) {
-                velocity = feature.values[index++];
-            }
-            if (velocity < 0) velocity = 127;
-            if (velocity > 127) velocity = 127;
-
-            FlexiNoteModel *model = getConformingOutput<FlexiNoteModel>(n);
-            if (!model) return;
-            model->addPoint(FlexiNoteModel::Point(frame,
-                                                  value, // value is pitch
-                                                  duration,
-                                                  velocity / 127.f,
-                                                  feature.label.c_str()));
-                        // GF: end -- added for flexi note model
-        } else  if (isOutput<NoteModel>(n)) {
+        if (isOutput<NoteModel>(n)) {
 
             float velocity = 100;
             if ((int)feature.values.size() > index) {
@@ -1073,10 +1035,10 @@
 
             NoteModel *model = getConformingOutput<NoteModel>(n);
             if (!model) return;
-            model->addPoint(NoteModel::Point(frame, value, // value is pitch
-                                             duration,
-                                             velocity / 127.f,
-                                             feature.label.c_str()));
+            model->add(Event(frame, value, // value is pitch
+                             duration,
+                             velocity / 127.f,
+                             feature.label.c_str()));
         } else {
 
             RegionModel *model = getConformingOutput<RegionModel>(n);
@@ -1093,17 +1055,17 @@
                         label = QString("[%1] %2").arg(i+1).arg(label);
                     }
 
-                    model->addPoint(RegionModel::Point(frame,
-                                                       value,
-                                                       duration,
-                                                       label));
+                    model->add(Event(frame,
+                                     value,
+                                     duration,
+                                     label));
                 }
             } else {
             
-                model->addPoint(RegionModel::Point(frame,
-                                                   value,
-                                                   duration,
-                                                   feature.label.c_str()));
+                model->add(Event(frame,
+                                 value,
+                                 duration,
+                                 feature.label.c_str()));
             }
         }
         
@@ -1160,13 +1122,6 @@
         if (model->isAbandoning()) abandon();
         model->setCompletion(completion, true);
         
-    } else if (isOutput<FlexiNoteModel>(n)) {
-
-        FlexiNoteModel *model = getConformingOutput<FlexiNoteModel>(n);
-        if (!model) return;
-        if (model->isAbandoning()) abandon();
-        model->setCompletion(completion, true);
-
     } else if (isOutput<RegionModel>(n)) {
 
         RegionModel *model = getConformingOutput<RegionModel>(n);
--- a/transform/ModelTransformerFactory.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/transform/ModelTransformerFactory.cpp	Thu May 16 11:07:47 2019 +0100
@@ -34,6 +34,7 @@
 #include <set>
 
 #include <QRegExp>
+#include <QMutexLocker>
 
 using std::vector;
 
@@ -59,6 +60,8 @@
                                                       sv_frame_t duration,
                                                       UserConfigurator *configurator)
 {
+    QMutexLocker locker(&m_mutex);
+    
     ModelTransformer::Input input(nullptr);
 
     if (candidateInputModels.empty()) return input;
@@ -213,6 +216,8 @@
 {
     SVDEBUG << "ModelTransformerFactory::transformMultiple: Constructing transformer with input model " << input.getModel() << endl;
     
+    QMutexLocker locker(&m_mutex);
+    
     ModelTransformer *t = createTransformer(transforms, input);
     if (!t) return vector<Model *>();
 
@@ -255,6 +260,8 @@
 void
 ModelTransformerFactory::transformerFinished()
 {
+    QMutexLocker locker(&m_mutex);
+    
     QObject *s = sender();
     ModelTransformer *transformer = dynamic_cast<ModelTransformer *>(s);
     
@@ -299,17 +306,21 @@
 {
     TransformerSet affected;
 
-    for (TransformerSet::iterator i = m_runningTransformers.begin();
-         i != m_runningTransformers.end(); ++i) {
+    {
+        QMutexLocker locker(&m_mutex);
+    
+        for (TransformerSet::iterator i = m_runningTransformers.begin();
+             i != m_runningTransformers.end(); ++i) {
 
-        ModelTransformer *t = *i;
+            ModelTransformer *t = *i;
 
-        if (t->getInputModel() == m) {
-            affected.insert(t);
-        } else {
-            vector<Model *> mm = t->getOutputModels();
-            for (int i = 0; i < (int)mm.size(); ++i) {
-                if (mm[i] == m) affected.insert(t);
+            if (t->getInputModel() == m) {
+                affected.insert(t);
+            } else {
+                vector<Model *> mm = t->getOutputModels();
+                for (int i = 0; i < (int)mm.size(); ++i) {
+                    if (mm[i] == m) affected.insert(t);
+                }
             }
         }
     }
@@ -327,3 +338,10 @@
     }
 }
 
+bool
+ModelTransformerFactory::haveRunningTransformers() const
+{
+    QMutexLocker locker(&m_mutex);
+    
+    return (!m_runningTransformers.empty());
+}
--- a/transform/ModelTransformerFactory.h	Tue May 07 15:54:15 2019 +0100
+++ b/transform/ModelTransformerFactory.h	Thu May 16 11:07:47 2019 +0100
@@ -25,6 +25,7 @@
 #include <vamp-hostsdk/PluginBase.h>
 
 #include <QMap>
+#include <QMutex>
 #include <map>
 #include <set>
 #include <vector>
@@ -145,6 +146,8 @@
                                            QString &message,
                                            AdditionalModelHandler *handler = 0);
 
+    bool haveRunningTransformers() const;
+    
 signals:
     void transformFailed(QString transformName, QString message);
                                                                                
@@ -157,6 +160,8 @@
     ModelTransformer *createTransformer(const Transforms &transforms,
                                         const ModelTransformer::Input &input);
 
+    mutable QMutex m_mutex;
+    
     typedef std::map<TransformId, QString> TransformerConfigurationMap;
     TransformerConfigurationMap m_lastConfigurations;
 
--- a/transform/RealTimeEffectModelTransformer.cpp	Tue May 07 15:54:15 2019 +0100
+++ b/transform/RealTimeEffectModelTransformer.cpp	Thu May 16 11:07:47 2019 +0100
@@ -266,8 +266,7 @@
             if (pointFrame > latency) pointFrame -= latency;
             else pointFrame = 0;
 
-            stvm->addPoint(SparseTimeValueModel::Point
-                           (pointFrame, value, ""));
+            stvm->add(Event(pointFrame, value, ""));
 
         } else if (wwfm) {