changeset 1651:7a56bb85030f single-point

Introduce deferred notifier, + start converting sparse time-value model (perhaps we should rename it too)
author Chris Cannam
date Mon, 18 Mar 2019 14:17:20 +0000
parents bbfb5a1e4b84
children 08bed13d3a26
files data/fileio/CSVFileReader.cpp data/model/AlignmentModel.cpp data/model/DeferredNotifier.h data/model/NoteModel.h data/model/RegionModel.h data/model/SparseTimeValueModel.h data/model/test/TestSparseModels.h files.pri rdf/RDFExporter.cpp rdf/RDFImporter.cpp transform/FeatureExtractionModelTransformer.cpp transform/RealTimeEffectModelTransformer.cpp
diffstat 12 files changed, 405 insertions(+), 272 deletions(-) [+]
line wrap: on
line diff
--- a/data/fileio/CSVFileReader.cpp	Mon Mar 18 09:37:46 2019 +0000
+++ b/data/fileio/CSVFileReader.cpp	Mon Mar 18 14:17:20 2019 +0000
@@ -442,8 +442,8 @@
 
             } 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) {
 
--- a/data/model/AlignmentModel.cpp	Mon Mar 18 09:37:46 2019 +0000
+++ b/data/model/AlignmentModel.cpp	Mon Mar 18 14:17:20 2019 +0000
@@ -238,12 +238,11 @@
         
     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));
     }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/data/model/DeferredNotifier.h	Mon Mar 18 14:17:20 2019 +0000
@@ -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/NoteModel.h	Mon Mar 18 09:37:46 2019 +0000
+++ b/data/model/NoteModel.h	Mon Mar 18 14:17:20 2019 +0000
@@ -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
@@ -19,6 +18,7 @@
 #include "Model.h"
 #include "TabularModel.h"
 #include "EventCommands.h"
+#include "DeferredNotifier.h"
 #include "base/UnitDatabase.h"
 #include "base/EventSeries.h"
 #include "base/NoteData.h"
@@ -57,9 +57,10 @@
         m_valueQuantization(0),
         m_units(""),
         m_extendTo(0),
-        m_notifyOnAdd(notifyOnAdd),
-        m_sinceLastNotifyMin(-1),
-        m_sinceLastNotifyMax(-1),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
         m_completion(0) {
         if (subtype == FLEXI_NOTE) {
             m_valueMinimum = 33.f;
@@ -81,9 +82,10 @@
         m_valueQuantization(0),
         m_units(""),
         m_extendTo(0),
-        m_notifyOnAdd(notifyOnAdd),
-        m_sinceLastNotifyMin(-1),
-        m_sinceLastNotifyMax(-1),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
         m_completion(0) {
         PlayParameterRepository::getInstance()->addPlayable(this);
     }
@@ -122,46 +124,22 @@
 
     void setCompletion(int completion, bool update = true) {
 
-        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
-                    emitGeneralModelChanged = true;
-
-                } else if (!m_notifyOnAdd) {
-
-                    if (update &&
-                        m_sinceLastNotifyMin >= 0 &&
-                        m_sinceLastNotifyMax >= 0) {
-                        emitRegionChanged = true;
-                    }
-                }
-            }
+        {   QMutexLocker locker(&m_mutex);
+            if (m_completion == completion) return;
+            m_completion = completion;
         }
 
-        if (emitCompletionChanged) {
-            emit completionChanged();
+        if (update) {
+            m_notifier.makeDeferredNotifications();
         }
-        if (emitGeneralModelChanged) {
+        
+        emit completionChanged();
+
+        if (completion == 100) {
+            // henceforth:
+            m_notifier.switchMode(DeferredNotifier::NOTIFY_ALWAYS);
             emit modelChanged();
         }
-        if (emitRegionChanged) {
-            emit modelChangedWithin(m_sinceLastNotifyMin, m_sinceLastNotifyMax);
-            m_sinceLastNotifyMin = m_sinceLastNotifyMax = -1;
-        }        
     }
 
     /**
@@ -203,7 +181,6 @@
         {
             QMutexLocker locker(&m_mutex);
             m_events.add(e);
-//!!!???        if (point.getLabel() != "") m_hasTextLabels = true;
 
             float v = e.getValue();
             if (!ISNAN(v) && !ISINF(v)) {
@@ -215,23 +192,10 @@
                 }
                 m_haveExtents = true;
             }
-            
-            sv_frame_t f = e.getFrame();
-
-            if (!m_notifyOnAdd) {
-                if (m_sinceLastNotifyMin == -1 || f < m_sinceLastNotifyMin) {
-                    m_sinceLastNotifyMin = f;
-                }
-                if (m_sinceLastNotifyMax == -1 || f > m_sinceLastNotifyMax) {
-                    m_sinceLastNotifyMax = f;
-                }
-            }
         }
         
-        if (m_notifyOnAdd) {
-            emit modelChangedWithin(e.getFrame(),
-                                    e.getFrame() + e.getDuration() + m_resolution);
-        }
+        m_notifier.update(e.getFrame(), e.getDuration() + m_resolution);
+
         if (allChange) {
             emit modelChanged();
         }
@@ -388,13 +352,14 @@
                      "valueQuantization=\"%5\" minimum=\"%6\" maximum=\"%7\" "
                      "units=\"%8\" %9")
              .arg(m_resolution)
-             .arg(m_notifyOnAdd ? "true" : "false")
+             .arg("true") // always true after model reaches 100% -
+                          // subsequent events are always notified
              .arg(getObjectExportId(&m_events))
              .arg(m_subtype == FLEXI_NOTE ? "flexinote" : "note")
              .arg(m_valueQuantization)
              .arg(m_valueMinimum)
              .arg(m_valueMaximum)
-             .arg(m_units)
+             .arg(encodeEntities(m_units))
              .arg(extraAttributes));
         
         m_events.toXml(out, indent, QString("dimensions=\"3\""));
@@ -410,17 +375,12 @@
     bool m_haveExtents;
     float m_valueQuantization;
     QString m_units;
-    
     sv_frame_t m_extendTo;
-
-    bool m_notifyOnAdd;
-    sv_frame_t m_sinceLastNotifyMin;
-    sv_frame_t m_sinceLastNotifyMax;
+    DeferredNotifier m_notifier;
+    int m_completion;
 
     EventSeries m_events;
 
-    int m_completion;
-
     mutable QMutex m_mutex;
 
     //!!! do we have general docs for ownership and synchronisation of models?
--- a/data/model/RegionModel.h	Mon Mar 18 09:37:46 2019 +0000
+++ b/data/model/RegionModel.h	Mon Mar 18 14:17:20 2019 +0000
@@ -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
@@ -19,6 +18,7 @@
 #include "EventCommands.h"
 #include "TabularModel.h"
 #include "Model.h"
+#include "DeferredNotifier.h"
 
 #include "base/RealTime.h"
 #include "base/EventSeries.h"
@@ -49,9 +49,10 @@
         m_haveExtents(false),
         m_valueQuantization(0),
         m_haveDistinctValues(false),
-        m_notifyOnAdd(notifyOnAdd),
-        m_sinceLastNotifyMin(-1),
-        m_sinceLastNotifyMax(-1),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
         m_completion(0) {
     }
 
@@ -65,9 +66,10 @@
         m_haveExtents(false),
         m_valueQuantization(0),
         m_haveDistinctValues(false),
-        m_notifyOnAdd(notifyOnAdd),
-        m_sinceLastNotifyMin(-1),
-        m_sinceLastNotifyMax(-1),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
         m_completion(0) {
     }
 
@@ -100,46 +102,22 @@
 
     void setCompletion(int completion, bool update = true) {
 
-        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
-                    emitGeneralModelChanged = true;
-
-                } else if (!m_notifyOnAdd) {
-
-                    if (update &&
-                        m_sinceLastNotifyMin >= 0 &&
-                        m_sinceLastNotifyMax >= 0) {
-                        emitRegionChanged = true;
-                    }
-                }
-            }
+        {   QMutexLocker locker(&m_mutex);
+            if (m_completion == completion) return;
+            m_completion = completion;
         }
 
-        if (emitCompletionChanged) {
-            emit completionChanged();
+        if (update) {
+            m_notifier.makeDeferredNotifications();
         }
-        if (emitGeneralModelChanged) {
+        
+        emit completionChanged();
+
+        if (completion == 100) {
+            // henceforth:
+            m_notifier.switchMode(DeferredNotifier::NOTIFY_ALWAYS);
             emit modelChanged();
         }
-        if (emitRegionChanged) {
-            emit modelChangedWithin(m_sinceLastNotifyMin, m_sinceLastNotifyMax);
-            m_sinceLastNotifyMin = m_sinceLastNotifyMax = -1;
-        }        
     }
 
     /**
@@ -197,23 +175,10 @@
             if (e.hasValue() && e.getValue() != 0.f) {
                 m_haveDistinctValues = true;
             }
-            
-            sv_frame_t f = e.getFrame();
-
-            if (!m_notifyOnAdd) {
-                if (m_sinceLastNotifyMin == -1 || f < m_sinceLastNotifyMin) {
-                    m_sinceLastNotifyMin = f;
-                }
-                if (m_sinceLastNotifyMax == -1 || f > m_sinceLastNotifyMax) {
-                    m_sinceLastNotifyMax = f;
-                }
-            }
         }
         
-        if (m_notifyOnAdd) {
-            emit modelChangedWithin(e.getFrame(),
-                                    e.getFrame() + e.getDuration() + m_resolution);
-        }
+        m_notifier.update(e.getFrame(), e.getDuration() + m_resolution);
+
         if (allChange) {
             emit modelChanged();
         }
@@ -269,6 +234,11 @@
         }
     }
 
+    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()) {
@@ -311,12 +281,6 @@
         return command->finish();
     }
 
-    SortType getSortType(int column) const override
-    {
-        if (column == 4) return SortAlphabetical;
-        return SortNumeric;
-    }
-
     
     /**
      * XmlExportable methods.
@@ -331,14 +295,16 @@
              QString("type=\"sparse\" dimensions=\"3\" resolution=\"%1\" "
                      "notifyOnAdd=\"%2\" dataset=\"%3\" subtype=\"%4\" "
                      "valueQuantization=\"%5\" minimum=\"%6\" maximum=\"%7\" "
-                     "%8")
+                     "units=\"%8\" %9")
              .arg(m_resolution)
-             .arg(m_notifyOnAdd ? "true" : "false")
+             .arg("true") // always true after model reaches 100% -
+                          // subsequent events are always notified
              .arg(getObjectExportId(&m_events))
              .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\""));
@@ -354,15 +320,11 @@
     float m_valueQuantization;
     bool m_haveDistinctValues;
     QString m_units;
-    
-    bool m_notifyOnAdd;
-    sv_frame_t m_sinceLastNotifyMin;
-    sv_frame_t m_sinceLastNotifyMax;
+    DeferredNotifier m_notifier;
+    int m_completion;
 
     EventSeries m_events;
 
-    int m_completion;
-
     mutable QMutex m_mutex;
 };
 
--- a/data/model/SparseTimeValueModel.h	Mon Mar 18 09:37:46 2019 +0000
+++ b/data/model/SparseTimeValueModel.h	Mon Mar 18 14:17:20 2019 +0000
@@ -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(0) {
         // Model is playable, but may not sound (if units not Hz or
         // range unsuitable)
         PlayParameterRepository::getInstance()->addPlayable(this);
@@ -94,36 +60,172 @@
     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(false),
+        m_haveTextLabels(false),
+        m_notifier(this,
+                   notifyOnAdd ?
+                   DeferredNotifier::NOTIFY_ALWAYS :
+                   DeferredNotifier::NOTIFY_DEFERRED),
+        m_completion(0) {
         // 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 isOK() const override { return true; }
+    sv_frame_t getStartFrame() const override { return m_events.getStartFrame(); }
+    sv_frame_t getEndFrame() const override { return m_events.getEndFrame(); }
+    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 { 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 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 getEventsCovering(sv_frame_t f) const {
+        return m_events.getEventsCovering(f);
+    }
+    
+    /**
+     * Editing methods.
+     */
+    void add(Event e) override {
+
+        bool allChange = false;
+           
+        {
+            QMutexLocker locker(&m_mutex);
+            m_events.add(e);
+
+            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 {
+        // 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");
@@ -133,59 +235,91 @@
         }
     }
 
-    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(getObjectExportId(&m_events))
+             .arg(m_valueMinimum)
+             .arg(m_valueMaximum)
+             .arg(encodeEntities(m_units))
+             .arg(extraAttributes));
+        
+        m_events.toXml(out, indent, QString("dimensions=\"2\""));
     }
+  
+protected:
+    sv_samplerate_t m_sampleRate;
+    int m_resolution;
 
-    SortType getSortType(int column) const override
-    {
-        if (column == 3) return SortAlphabetical;
-        return SortNumeric;
-    }
+    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/test/TestSparseModels.h	Mon Mar 18 09:37:46 2019 +0000
+++ b/data/model/test/TestSparseModels.h	Mon Mar 18 14:17:20 2019 +0000
@@ -205,8 +205,13 @@
         QTextStream str(&xml, QIODevice::WriteOnly);
         m.toXml(str);
         str.flush();
+
+        //!!! This is not guaranteed - object export ids are in order
+        //!!! of model pointer value, which is not trustworthy -
+        //!!! replace them with something else
+        
         QString expected =
-            "<model id='3' name='' sampleRate='100' start='20' end='80' type='sparse' dimensions='3' resolution='10' notifyOnAdd='false' dataset='2' subtype='note' valueQuantization='0' minimum='123.4' maximum='126.3' units='Hz' />\n"
+            "<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"
--- a/files.pri	Mon Mar 18 09:37:46 2019 +0000
+++ b/files.pri	Mon Mar 18 14:17:20 2019 +0000
@@ -80,6 +80,7 @@
            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 \
--- a/rdf/RDFExporter.cpp	Mon Mar 18 09:37:46 2019 +0000
+++ b/rdf/RDFExporter.cpp	Mon Mar 18 14:17:20 2019 +0000
@@ -136,13 +136,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;
--- a/rdf/RDFImporter.cpp	Mon Mar 18 09:37:46 2019 +0000
+++ b/rdf/RDFImporter.cpp	Mon Mar 18 14:17:20 2019 +0000
@@ -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);
@@ -728,9 +728,8 @@
     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;
     }
 
--- a/transform/FeatureExtractionModelTransformer.cpp	Mon Mar 18 09:37:46 2019 +0000
+++ b/transform/FeatureExtractionModelTransformer.cpp	Mon Mar 18 14:17:20 2019 +0000
@@ -1004,8 +1004,7 @@
 //                          << " for output " << n << " bin " << i << std::endl;
             }
 
-            targetModel->addPoint
-                (SparseTimeValueModel::Point(frame, value, label));
+            targetModel->add(Event(frame, value, label));
         }
 
     } else if (isOutput<NoteModel>(n) || isOutput<RegionModel>(n)) {
--- a/transform/RealTimeEffectModelTransformer.cpp	Mon Mar 18 09:37:46 2019 +0000
+++ b/transform/RealTimeEffectModelTransformer.cpp	Mon Mar 18 14:17:20 2019 +0000
@@ -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) {