changeset 1007:ba404199345f tonioni

Merge from default branch
author Chris Cannam
date Mon, 10 Nov 2014 09:19:49 +0000
parents 6e6da0636e5e (current diff) d954e03274e8 (diff)
children c4898e57eea5
files
diffstat 21 files changed, 453 insertions(+), 114 deletions(-) [+]
line wrap: on
line diff
--- a/base/Debug.cpp	Tue Sep 09 16:36:21 2014 +0100
+++ b/base/Debug.cpp	Mon Nov 10 09:19:49 2014 +0000
@@ -43,8 +43,9 @@
     QDir logdir(QString("%1/%2").arg(pfx).arg("log"));
 
     if (!prefix) {
-        prefix = new char[20];
-        sprintf(prefix, "[%lu]", (unsigned long)QCoreApplication::applicationPid());
+        prefix = strdup(QString("[%1]")
+                        .arg(QCoreApplication::applicationPid())
+                        .toLatin1().data());
         //!!! what to do if mkpath fails?
 	if (!logdir.exists()) logdir.mkpath(logdir.path());
     }
--- a/base/ResourceFinder.cpp	Tue Sep 09 16:36:21 2014 +0100
+++ b/base/ResourceFinder.cpp	Mon Nov 10 09:19:49 2014 +0000
@@ -27,6 +27,10 @@
 #include <QProcess>
 #include <QCoreApplication>
 
+#if QT_VERSION >= 0x050000
+#include <QStandardPaths>
+#endif
+
 #include <cstdlib>
 #include <iostream>
 
@@ -88,10 +92,12 @@
     return list;
 }
 
-QString
-ResourceFinder::getUserResourcePrefix()
+static QString
+getOldStyleUserResourcePrefix()
 {
 #ifdef Q_OS_WIN32
+    // This is awkward and does not work correctly for non-ASCII home
+    // directory names, hence getNewStyleUserResourcePrefix() below
     char *homedrive = getenv("HOMEDRIVE");
     char *homepath = getenv("HOMEPATH");
     QString home;
@@ -114,7 +120,84 @@
         .arg(home)
         .arg(qApp->applicationName());
 #endif
-#endif    
+#endif
+}
+
+static QString
+getNewStyleUserResourcePrefix()
+{
+#if QT_VERSION >= 0x050000
+    // This is expected to be much more reliable than
+    // getOldStyleUserResourcePrefix(), but it returns a different
+    // directory because it includes the organisation name (which is
+    // fair enough). Hence migrateOldStyleResources() which moves
+    // across any resources found in the old-style path the first time
+    // we look for the new-style one
+    return QStandardPaths::writableLocation(QStandardPaths::DataLocation);
+#else
+    return getOldStyleUserResourcePrefix();
+#endif
+}
+
+static void
+migrateOldStyleResources()
+{
+    QString oldPath = getOldStyleUserResourcePrefix();
+    QString newPath = getNewStyleUserResourcePrefix();
+    
+    if (oldPath != newPath &&
+        QDir(oldPath).exists() &&
+        !QDir(newPath).exists()) {
+
+        QDir d(oldPath);
+        
+        if (!d.mkpath(newPath)) {
+            cerr << "WARNING: Failed to create new-style resource path \""
+                 << newPath << "\" to migrate old resources to" << endl;
+            return;
+        }
+
+        QDir target(newPath);
+
+        bool success = true;
+
+        QStringList entries
+            (d.entryList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot));
+
+        foreach (QString entry, entries) {
+            if (d.rename(entry, target.filePath(entry))) {
+                cerr << "NOTE: Successfully moved resource \""
+                     << entry << "\" from old resource path to new" << endl;
+            } else {
+                cerr << "WARNING: Failed to move old resource \""
+                     << entry << "\" from old location \""
+                     << oldPath << "\" to new location \""
+                     << newPath << "\"" << endl;
+                success = false;
+            }
+        }
+
+        if (success) {
+            if (!d.rmdir(oldPath)) {
+                cerr << "WARNING: Failed to remove old resource path \""
+                     << oldPath << "\" after migrating " << entries.size()
+                     << " resource(s) to new path \"" << newPath
+                     << "\" (directory not empty?)" << endl;
+            } else {
+                cerr << "NOTE: Successfully moved " << entries.size()
+                     << " resource(s) from old resource "
+                     << "path \"" << oldPath << "\" to new path \""
+                     << newPath << "\"" << endl;
+            }
+        }
+    }
+}
+
+QString
+ResourceFinder::getUserResourcePrefix()
+{
+    migrateOldStyleResources();
+    return getNewStyleUserResourcePrefix();
 }
 
 QStringList
--- a/data/fft/FFTDataServer.cpp	Tue Sep 09 16:36:21 2014 +0100
+++ b/data/fft/FFTDataServer.cpp	Mon Nov 10 09:19:49 2014 +0000
@@ -1428,18 +1428,14 @@
                                     int fftSize,
                                     bool polar)
 {
-    char buffer[200];
-
-    sprintf(buffer, "%u-%u-%u-%u-%u-%u%s",
-            (unsigned int)XmlExportable::getObjectExportId(model),
-            (unsigned int)(channel + 1),
-            (unsigned int)windowType,
-            (unsigned int)windowSize,
-            (unsigned int)windowIncrement,
-            (unsigned int)fftSize,
-            polar ? "-p" : "-r");
-
-    return buffer;
+    return QString("%1-%2-%3-%4-%5-%6%7")
+        .arg(XmlExportable::getObjectExportId(model))
+        .arg(channel + 1)
+        .arg((int)windowType)
+        .arg(windowSize)
+        .arg(windowIncrement)
+        .arg(fftSize)
+        .arg(polar ? "-p" : "-r");
 }
 
 void
--- a/data/fileio/AudioFileReader.h	Tue Sep 09 16:36:21 2014 +0100
+++ b/data/fileio/AudioFileReader.h	Mon Nov 10 09:19:49 2014 +0000
@@ -75,7 +75,9 @@
     /** 
      * Return interleaved samples for count frames from index start.
      * The resulting sample block will contain count *
-     * getChannelCount() samples (or fewer if end of file is reached).
+     * getChannelCount() samples (or fewer if end of file is
+     * reached). The caller does not need to allocate space and any
+     * existing content in the SampleBlock will be erased.
      *
      * The subclass implementations of this function must be
      * thread-safe -- that is, safe to call from multiple threads with
--- a/data/fileio/CSVFileReader.cpp	Tue Sep 09 16:36:21 2014 +0100
+++ b/data/fileio/CSVFileReader.cpp	Mon Nov 10 09:19:49 2014 +0000
@@ -101,6 +101,12 @@
         double time = numeric.toDouble(&ok);
         if (!ok) time = StringBits::stringToDoubleLocaleFree(numeric, &ok);
         calculatedFrame = int(time * sampleRate + 0.5);
+    
+    } else if (timeUnits == CSVFormat::TimeMilliseconds) {
+
+        double time = numeric.toDouble(&ok);
+        if (!ok) time = StringBits::stringToDoubleLocaleFree(numeric, &ok);
+        calculatedFrame = int((time / 1000.0) * sampleRate + 0.5);
         
     } else {
         
@@ -149,7 +155,8 @@
         } else {
             windowSize = 1;
         }
-	if (timeUnits == CSVFormat::TimeSeconds) {
+	if (timeUnits == CSVFormat::TimeSeconds ||
+            timeUnits == CSVFormat::TimeMilliseconds) {
 	    sampleRate = m_mainModelSampleRate;
 	}
     }
--- a/data/fileio/CSVFormat.cpp	Tue Sep 09 16:36:21 2014 +0100
+++ b/data/fileio/CSVFormat.cpp	Mon Nov 10 09:19:49 2014 +0000
@@ -103,7 +103,7 @@
     QStringList list = StringBits::split(line, m_separator[0], m_allowQuoting);
 
     int cols = list.size();
-    if (lineno == 0 || (cols < m_columnCount)) m_columnCount = cols;
+    if (lineno == 0 || (cols > m_columnCount)) m_columnCount = cols;
     if (cols != m_columnCount) m_variableColumnCount = true;
 
     // All columns are regarded as having these qualities until we see
--- a/data/fileio/CSVFormat.h	Tue Sep 09 16:36:21 2014 +0100
+++ b/data/fileio/CSVFormat.h	Mon Nov 10 09:19:49 2014 +0000
@@ -37,8 +37,9 @@
 
     enum TimeUnits {
 	TimeSeconds,
+        TimeMilliseconds,
 	TimeAudioFrames,
-	TimeWindows
+	TimeWindows,
     };
 
     enum ColumnPurpose {
--- a/data/fileio/FileSource.cpp	Tue Sep 09 16:36:21 2014 +0100
+++ b/data/fileio/FileSource.cpp	Mon Nov 10 09:19:49 2014 +0000
@@ -78,6 +78,7 @@
     m_reply(0),
     m_preferredContentType(preferredContentType),
     m_ok(false),
+    m_cancelled(false),
     m_lastStatus(0),
     m_resource(fileOrUrl.startsWith(':')),
     m_remote(isRemote(fileOrUrl)),
@@ -167,6 +168,7 @@
     m_localFile(0),
     m_reply(0),
     m_ok(false),
+    m_cancelled(false),
     m_lastStatus(0),
     m_resource(false),
     m_remote(isRemote(url.toString())),
@@ -199,6 +201,7 @@
     m_localFile(0),
     m_reply(0),
     m_ok(rf.m_ok),
+    m_cancelled(rf.m_cancelled),
     m_lastStatus(rf.m_lastStatus),
     m_resource(rf.m_resource),
     m_remote(rf.m_remote),
@@ -484,6 +487,7 @@
     m_done = true;
     if (m_reply) {
         QNetworkReply *r = m_reply;
+        disconnect(r, 0, this, 0);
         m_reply = 0;
         // Can only call abort() when there are no errors.
         if (r->error() == QNetworkReply::NoError) {
@@ -571,6 +575,12 @@
 }
 
 bool
+FileSource::wasCancelled() const
+{
+    return m_cancelled;
+}
+
+bool
 FileSource::isResource() const
 {
     return m_resource;
@@ -710,6 +720,7 @@
     cleanup();
 
     m_ok = false;
+    m_cancelled = true;
     m_errorString = tr("Download cancelled");
 }
 
--- a/data/fileio/FileSource.h	Tue Sep 09 16:36:21 2014 +0100
+++ b/data/fileio/FileSource.h	Mon Nov 10 09:19:49 2014 +0000
@@ -101,10 +101,10 @@
     void waitForData();
 
     /**
-     * Return true if the FileSource object is valid and no error
-     * occurred in looking up the file or remote URL.  Non-existence
-     * of the file or URL is not an error -- call isAvailable() to
-     * test for that.
+     * Return true if the FileSource object is valid and neither error
+     * nor cancellation occurred while retrieving the file or remote
+     * URL.  Non-existence of the file or URL is not an error -- call
+     * isAvailable() to test for that.
      */
     bool isOK() const;
 
@@ -122,6 +122,14 @@
     bool isDone() const;
 
     /**
+     * Return true if the operation was cancelled by the user through
+     * the ProgressReporter interface. Note that the cancelled()
+     * signal will have been emitted, and isOK() will also return
+     * false in this case.
+     */
+    bool wasCancelled() const;
+
+    /**
      * Return true if this FileSource is referring to a QRC resource.
      */
     bool isResource() const;
@@ -227,6 +235,7 @@
     QString m_contentType;
     QString m_preferredContentType;
     bool m_ok;
+    bool m_cancelled;
     int m_lastStatus;
     bool m_resource;
     bool m_remote;
--- a/data/fileio/MIDIFileWriter.cpp	Tue Sep 09 16:36:21 2014 +0100
+++ b/data/fileio/MIDIFileWriter.cpp	Mon Nov 10 09:19:49 2014 +0000
@@ -317,7 +317,6 @@
     m_numberOfTracks = 1;
 
     int track = 0;
-    int midiChannel = 0;
 
     MIDIEvent *event;
 
@@ -349,10 +348,14 @@
         int duration = i->duration;
         int pitch = i->midiPitch;
         int velocity = i->velocity;
+        int channel = i->channel;
 
         if (pitch < 0) pitch = 0;
         if (pitch > 127) pitch = 127;
 
+        if (channel < 0) channel = 0;
+        if (channel > 15) channel = 0;
+
         // Convert frame to MIDI time
 
         double seconds = double(frame) / double(m_sampleRate);
@@ -370,13 +373,13 @@
         // in place).
 
         event = new MIDIEvent(midiTime,
-                              MIDI_NOTE_ON | midiChannel,
+                              MIDI_NOTE_ON | channel,
                               pitch,
                               velocity);
         m_midiComposition[track].push_back(event);
 
         event = new MIDIEvent(endTime,
-                              MIDI_NOTE_OFF | midiChannel,
+                              MIDI_NOTE_OFF | channel,
                               pitch,
                               127); // loudest silence you can muster
 
--- a/data/model/DenseThreeDimensionalModel.h	Tue Sep 09 16:36:21 2014 +0100
+++ b/data/model/DenseThreeDimensionalModel.h	Mon Nov 10 09:19:49 2014 +0000
@@ -172,10 +172,10 @@
     }
 
     virtual long getFrameForRow(int row) const {
-        return row * getSampleRate();
+        return row * getResolution();
     }
     virtual int getRowForFrame(long frame) const {
-        return frame / getSampleRate();
+        return frame / getResolution();
     }
 
 protected:
--- a/data/model/NoteData.h	Tue Sep 09 16:36:21 2014 +0100
+++ b/data/model/NoteData.h	Mon Nov 10 09:19:49 2014 +0000
@@ -23,14 +23,15 @@
 {
     NoteData(int _start, int _dur, int _mp, int _vel) :
 	start(_start), duration(_dur), midiPitch(_mp), frequency(0),
-	isMidiPitchQuantized(true), velocity(_vel) { };
+	isMidiPitchQuantized(true), velocity(_vel), channel(0) { };
             
-    int start;     // audio sample frame
-    int duration;  // in audio sample frames
-    int midiPitch; // 0-127
+    int start;       // audio sample frame
+    int 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 velocity;    // MIDI-style 0-127
+    int channel;     // MIDI 0-15
 
     float getFrequency() const {
         if (isMidiPitchQuantized) {
--- a/rdf/RDFFeatureWriter.cpp	Tue Sep 09 16:36:21 2014 +0100
+++ b/rdf/RDFFeatureWriter.cpp	Mon Nov 10 09:19:49 2014 +0000
@@ -15,9 +15,6 @@
 
 #include <fstream>
 
-#include "vamp-hostsdk/PluginHostAdapter.h"
-#include "vamp-hostsdk/PluginLoader.h"
-
 #include "base/Exceptions.h"
 
 #include "RDFFeatureWriter.h"
@@ -36,7 +33,8 @@
 RDFFeatureWriter::RDFFeatureWriter() :
     FileFeatureWriter(SupportOneFilePerTrackTransform |
                       SupportOneFilePerTrack |
-                      SupportOneFileTotal,
+                      SupportOneFileTotal |
+                      SupportStdOut,
                       "n3"),
     m_plain(false),
     m_network(false),
@@ -49,6 +47,12 @@
 {
 }
 
+string
+RDFFeatureWriter::getDescription() const
+{
+    return "Write output in Audio Features Ontology RDF/Turtle format.";
+}
+
 RDFFeatureWriter::ParameterList
 RDFFeatureWriter::getSupportedParameters() const
 {
@@ -679,29 +683,45 @@
 
         stream << "\n:feature_timeline_" << featureNumber << " a tl:DiscreteTimeLine .\n\n";
 
-        int stepSize = transform.getStepSize();
-        if (stepSize == 0) {
-            cerr << "RDFFeatureWriter: INTERNAL ERROR: writing dense features without having set the step size properly!" << endl;
-            return;
-        }
+        float sampleRate;
+        int stepSize, blockSize;
 
-        int blockSize = transform.getBlockSize();
-        if (blockSize == 0) {
-            cerr << "RDFFeatureWriter: INTERNAL ERROR: writing dense features without having set the block size properly!" << endl;
-            return;
-        }
+        // If the output is FixedSampleRate, we need to draw the
+        // sample rate and step size from the output descriptor;
+        // otherwise they come from the transform
 
-        float sampleRate = transform.getSampleRate();
-        if (sampleRate == 0.f) {
-            cerr << "RDFFeatureWriter: INTERNAL ERROR: writing dense features without having set the sample rate properly!" << endl;
-            return;
+        if (od.sampleType == Plugin::OutputDescriptor::FixedSampleRate) {
+
+            sampleRate = od.sampleRate;
+            stepSize = 1;
+            blockSize = 1;
+
+        } else {
+
+            sampleRate = transform.getSampleRate();
+            if (sampleRate == 0.f) {
+                cerr << "RDFFeatureWriter: INTERNAL ERROR: writing dense features without having set the sample rate properly!" << endl;
+                return;
+            }
+
+            stepSize = transform.getStepSize();
+            if (stepSize == 0) {
+                cerr << "RDFFeatureWriter: INTERNAL ERROR: writing dense features without having set the step size properly!" << endl;
+                return;
+            }
+
+            blockSize = transform.getBlockSize();
+            if (blockSize == 0) {
+                cerr << "RDFFeatureWriter: INTERNAL ERROR: writing dense features without having set the block size properly!" << endl;
+                return;
+            }
         }
 
         stream << ":feature_timeline_map_" << featureNumber
                << " a tl:UniformSamplingWindowingMap ;\n"
                << "    tl:rangeTimeLine :feature_timeline_" << featureNumber << " ;\n"
                << "    tl:domainTimeLine " << timelineURI << " ;\n"
-               << "    tl:sampleRate \"" << int(sampleRate) << "\"^^xsd:int ;\n"
+               << "    tl:sampleRate \"" << sampleRate << "\"^^xsd:float ;\n"
                << "    tl:windowLength \"" << blockSize << "\"^^xsd:int ;\n"
                << "    tl:hopSize \"" << stepSize << "\"^^xsd:int .\n\n";
 
--- a/rdf/RDFFeatureWriter.h	Tue Sep 09 16:36:21 2014 +0100
+++ b/rdf/RDFFeatureWriter.h	Mon Nov 10 09:19:49 2014 +0000
@@ -44,6 +44,8 @@
     RDFFeatureWriter();
     virtual ~RDFFeatureWriter();
 
+    virtual string getDescription() const;
+
     virtual ParameterList getSupportedParameters() const;
     virtual void setParameters(map<string, string> &params);
 
--- a/rdf/RDFTransformFactory.cpp	Tue Sep 09 16:36:21 2014 +0100
+++ b/rdf/RDFTransformFactory.cpp	Mon Nov 10 09:19:49 2014 +0000
@@ -212,7 +212,8 @@
             "window_type",
             "sample_rate",
             "start", 
-            "duration"
+            "duration",
+            "plugin_version"
         };
         
         for (int j = 0; j < int(sizeof(optionals)/sizeof(optionals[0])); ++j) {
@@ -241,11 +242,16 @@
             } else if (optional == "sample_rate") {
                 transform.setSampleRate(onode.value.toFloat());
             } else if (optional == "start") {
-                transform.setStartTime
-                    (RealTime::fromXsdDuration(onode.value.toStdString()));
+                RealTime start = RealTime::fromXsdDuration(onode.value.toStdString());
+                transform.setStartTime(start);
             } else if (optional == "duration") {
-                transform.setDuration
-                    (RealTime::fromXsdDuration(onode.value.toStdString()));
+                RealTime duration = RealTime::fromXsdDuration(onode.value.toStdString());
+                transform.setDuration(duration);
+                if (duration == RealTime::zeroTime) {
+                    cerr << "\nRDFTransformFactory: WARNING: Duration is specified as \"" << onode.value << "\" in RDF file,\n    but this evaluates to zero when parsed as an xsd:duration datatype.\n    The duration property will therefore be ignored.\n    To specify start time and duration use the xsd:duration format,\n    for example \"PT2.5S\"^^xsd:duration (for 2.5 seconds).\n\n";
+                }
+            } else if (optional == "plugin_version") {
+                transform.setPluginVersion(onode.value);
             } else {
                 cerr << "RDFTransformFactory: ERROR: Inconsistent optionals lists (unexpected optional \"" << optional << "\"" << endl;
             }
@@ -394,6 +400,11 @@
     if (transform.getBlockSize() != 0) {
         s << "    vamp:block_size \"" << transform.getBlockSize() << "\"^^xsd:int ; " << endl;
     }
+    if (transform.getWindowType() != HanningWindow) {
+        s << "    vamp:window_type \"" <<
+            Window<float>::getNameForType(transform.getWindowType()).c_str()
+          << "\" ; " << endl;
+    }
     if (transform.getStartTime() != RealTime::zeroTime) {
         s << "    vamp:start \"" << transform.getStartTime().toXsdDuration().c_str() << "\"^^xsd:duration ; " << endl;
     }
@@ -403,6 +414,9 @@
     if (transform.getSampleRate() != 0) {
         s << "    vamp:sample_rate \"" << transform.getSampleRate() << "\"^^xsd:float ; " << endl;
     }
+    if (transform.getPluginVersion() != "") {
+        s << "    vamp:plugin_version \"\"\"" << transform.getPluginVersion() << "\"\"\" ; " << endl;
+    }
     
     QString program = transform.getProgram();
     if (program != "") {
--- a/transform/CSVFeatureWriter.cpp	Tue Sep 09 16:36:21 2014 +0100
+++ b/transform/CSVFeatureWriter.cpp	Mon Nov 10 09:19:49 2014 +0000
@@ -29,10 +29,14 @@
 
 CSVFeatureWriter::CSVFeatureWriter() :
     FileFeatureWriter(SupportOneFilePerTrackTransform |
-                      SupportOneFileTotal,
+                      SupportOneFileTotal |
+                      SupportStdOut,
                       "csv"),
     m_separator(","),
-    m_sampleTiming(false)
+    m_sampleTiming(false),
+    m_endTimes(false),
+    m_forceEnd(false),
+    m_omitFilename(false)
 {
 }
 
@@ -40,6 +44,12 @@
 {
 }
 
+string
+CSVFeatureWriter::getDescription() const
+{
+    return "Write features in comma-separated (CSV) format. If transforms are being written to a single file or to stdout, the first column in the output will contain the input audio filename, or an empty string if the feature hails from the same audio file as its predecessor. If transforms are being written to multiple files, the audio filename column will be omitted. Subsequent columns will contain the feature timestamp, then any or all of duration, values, and label.";
+}
+
 CSVFeatureWriter::ParameterList
 CSVFeatureWriter::getSupportedParameters() const
 {
@@ -51,10 +61,25 @@
     p.hasArg = true;
     pl.push_back(p);
     
+    p.name = "omit-filename";
+    p.description = "Omit the filename column. May result in confusion if sending more than one audio file's features to the same CSV output.";
+    p.hasArg = false;
+    pl.push_back(p);
+    
     p.name = "sample-timing";
     p.description = "Show timings as sample frame counts instead of in seconds.";
     p.hasArg = false;
     pl.push_back(p);
+    
+    p.name = "end-times";
+    p.description = "Show start and end time instead of start and duration, for features with duration.";
+    p.hasArg = false;
+    pl.push_back(p);
+
+    p.name = "fill-ends";
+    p.description = "Include durations (or end times) even for features without duration, by using the gap to the next feature instead.";
+    p.hasArg = false;
+    pl.push_back(p);
 
     return pl;
 }
@@ -70,8 +95,18 @@
         cerr << i->first << " -> " << i->second << endl;
         if (i->first == "separator") {
             m_separator = i->second.c_str();
+            cerr << "m_separator = " << m_separator << endl;
+            if (m_separator == "\\t") {
+                m_separator = QChar::Tabulation;
+            }
         } else if (i->first == "sample-timing") {
             m_sampleTiming = true;
+        } else if (i->first == "end-times") {
+            m_endTimes = true;
+        } else if (i->first == "fill-ends") {
+            m_forceEnd = true;
+        } else if (i->first == "omit-filename") {
+            m_omitFilename = true;
         }
     }
 }
@@ -83,18 +118,83 @@
                         const Plugin::FeatureList& features,
                         std::string summaryType)
 {
+    TransformId transformId = transform.getIdentifier();
+
     // Select appropriate output file for our track/transform
     // combination
 
-    QTextStream *sptr = getOutputStream(trackId, transform.getIdentifier());
+    QTextStream *sptr = getOutputStream(trackId, transformId);
     if (!sptr) {
-        throw FailedToOpenOutputStream(trackId, transform.getIdentifier());
+        throw FailedToOpenOutputStream(trackId, transformId);
     }
 
     QTextStream &stream = *sptr;
 
-    for (unsigned int i = 0; i < features.size(); ++i) {
+    int n = features.size();
 
+    if (n == 0) return;
+
+    DataId tt(trackId, transform);
+
+    if (m_pending.find(tt) != m_pending.end()) {
+        writeFeature(tt,
+                     stream,
+                     m_pending[tt],
+                     &features[0],
+                     m_pendingSummaryTypes[tt]);
+        m_pending.erase(tt);
+        m_pendingSummaryTypes.erase(tt);
+    }
+
+    if (m_forceEnd) {
+        // can't write final feature until we know its end time
+        --n;
+        m_pending[tt] = features[n];
+        m_pendingSummaryTypes[tt] = summaryType;
+    }
+
+    for (int i = 0; i < n; ++i) {
+        writeFeature(tt, 
+                     stream,
+                     features[i], 
+                     m_forceEnd ? &features[i+1] : 0,
+                     summaryType);
+    }
+}
+
+void
+CSVFeatureWriter::finish()
+{
+    for (PendingFeatures::const_iterator i = m_pending.begin();
+         i != m_pending.end(); ++i) {
+        DataId tt = i->first;
+        Plugin::Feature f = i->second;
+        QTextStream *sptr = getOutputStream(tt.first, tt.second.getIdentifier());
+        if (!sptr) {
+            throw FailedToOpenOutputStream(tt.first, tt.second.getIdentifier());
+        }
+        QTextStream &stream = *sptr;
+        // final feature has its own time as end time (we can't
+        // reliably determine the end of audio file, and because of
+        // the nature of block processing, the feature could even
+        // start beyond that anyway)
+        writeFeature(tt, stream, f, &f, m_pendingSummaryTypes[tt]);
+    }
+
+    m_pending.clear();
+}
+
+void
+CSVFeatureWriter::writeFeature(DataId tt,
+                               QTextStream &stream,
+                               const Plugin::Feature &f,
+                               const Plugin::Feature *optionalNextFeature,
+                               std::string summaryType)
+{
+    QString trackId = tt.first;
+    Transform transform = tt.second;
+
+    if (!m_omitFilename) {
         if (m_stdout || m_singleFileName != "") {
             if (trackId != m_prevPrintedTrackId) {
                 stream << "\"" << trackId << "\"" << m_separator;
@@ -103,45 +203,68 @@
                 stream << m_separator;
             }
         }
+    }
 
-        if (m_sampleTiming) {
+    Vamp::RealTime duration;
+    bool haveDuration = true;
+    
+    if (f.hasDuration) {
+        duration = f.duration;
+    } else if (optionalNextFeature) {
+        duration = optionalNextFeature->timestamp - f.timestamp;
+    } else {
+        haveDuration = false;
+    }
 
-            stream << Vamp::RealTime::realTime2Frame
-                (features[i].timestamp, transform.getSampleRate());
+    if (m_sampleTiming) {
 
-            if (features[i].hasDuration) {
-                stream << m_separator;
+        float rate = transform.getSampleRate();
+
+        stream << Vamp::RealTime::realTime2Frame(f.timestamp, rate);
+
+        if (haveDuration) {
+            stream << m_separator;
+            if (m_endTimes) {
                 stream << Vamp::RealTime::realTime2Frame
-                    (features[i].duration, transform.getSampleRate());
+                    (f.timestamp + duration, rate);
+            } else {
+                stream << Vamp::RealTime::realTime2Frame(duration, rate);
             }
-
-        } else {
-
-            QString timestamp = features[i].timestamp.toString().c_str();
-            timestamp.replace(QRegExp("^ +"), "");
-            stream << timestamp;
-
-            if (features[i].hasDuration) {
-                QString duration = features[i].duration.toString().c_str();
-                duration.replace(QRegExp("^ +"), "");
-                stream << m_separator << duration;
-            }            
         }
 
-        if (summaryType != "") {
-            stream << m_separator << summaryType.c_str();
-        }
+    } else {
 
-        for (unsigned int j = 0; j < features[i].values.size(); ++j) {
-            stream << m_separator << features[i].values[j];
-        }
+        QString timestamp = f.timestamp.toString().c_str();
+        timestamp.replace(QRegExp("^ +"), "");
+        stream << timestamp;
 
-        if (features[i].label != "") {
-            stream << m_separator << "\"" << features[i].label.c_str() << "\"";
-        }
+        if (haveDuration) {
+            if (m_endTimes) {
+                QString endtime =
+                    (f.timestamp + duration).toString().c_str();
+                endtime.replace(QRegExp("^ +"), "");
+                stream << m_separator << endtime;
+            } else {
+                QString d = duration.toString().c_str();
+                d.replace(QRegExp("^ +"), "");
+                stream << m_separator << d;
+            }
+        }            
+    }
 
-        stream << "\n";
+    if (summaryType != "") {
+        stream << m_separator << summaryType.c_str();
     }
+    
+    for (unsigned int j = 0; j < f.values.size(); ++j) {
+        stream << m_separator << f.values[j];
+    }
+    
+    if (f.label != "") {
+        stream << m_separator << "\"" << f.label.c_str() << "\"";
+    }
+    
+    stream << "\n";
 }
 
 
--- a/transform/CSVFeatureWriter.h	Tue Sep 09 16:36:21 2014 +0100
+++ b/transform/CSVFeatureWriter.h	Mon Nov 10 09:19:49 2014 +0000
@@ -40,6 +40,8 @@
     CSVFeatureWriter();
     virtual ~CSVFeatureWriter();
 
+    virtual string getDescription() const;
+
     virtual ParameterList getSupportedParameters() const;
     virtual void setParameters(map<string, string> &params);
 
@@ -49,12 +51,29 @@
                        const Vamp::Plugin::FeatureList &features,
                        std::string summaryType = "");
 
+    virtual void finish();
+
     virtual QString getWriterTag() const { return "csv"; }
 
 private:
     QString m_separator;
     bool m_sampleTiming;
+    bool m_endTimes;
+    bool m_forceEnd;
+    bool m_omitFilename;
     QString m_prevPrintedTrackId;
+
+    typedef pair<QString, Transform> DataId; // track id, transform
+    typedef map<DataId, Vamp::Plugin::Feature> PendingFeatures;
+    typedef map<DataId, std::string> PendingSummaryTypes;
+    PendingFeatures m_pending;
+    PendingSummaryTypes m_pendingSummaryTypes;
+
+    void writeFeature(DataId,
+                      QTextStream &,
+                      const Vamp::Plugin::Feature &f,
+                      const Vamp::Plugin::Feature *optionalNextFeature,
+                      std::string summaryType);
 };
 
 #endif
--- a/transform/FeatureWriter.h	Tue Sep 09 16:36:21 2014 +0100
+++ b/transform/FeatureWriter.h	Mon Nov 10 09:19:49 2014 +0000
@@ -39,6 +39,8 @@
 public:
     virtual ~FeatureWriter() { }
 
+    virtual string getDescription() const = 0;
+
     struct Parameter { // parameter of the writer, not the plugin
         string name;
         string description;
@@ -77,8 +79,15 @@
         QString m_transformId;
     };
 
+    /**
+     * Notify the writer that we are about to start extraction for
+     * input file N of M (where N is 1..M). May be useful when writing
+     * multiple outputs into a single file where some syntactic
+     * element is needed to connect them.
+     */
+    virtual void setNofM(int /* N */, int /* M */) { }
+
     // may throw FailedToOpenFile or other exceptions
-
     virtual void write(QString trackid,
                        const Transform &transform,
                        const Vamp::Plugin::OutputDescriptor &output,
--- a/transform/FileFeatureWriter.cpp	Tue Sep 09 16:36:21 2014 +0100
+++ b/transform/FileFeatureWriter.cpp	Mon Nov 10 09:19:49 2014 +0000
@@ -75,14 +75,14 @@
     Parameter p;
 
     p.name = "basedir";
-    p.description = "Base output directory path.  (The default is the same directory as the input file.)";
+    p.description = "Base output directory path. (The default is the same directory as the input file.) The directory must exist already.";
     p.hasArg = true;
     pl.push_back(p);
 
     if (m_support & SupportOneFilePerTrackTransform &&
         m_support & SupportOneFilePerTrack) {
         p.name = "many-files";
-        p.description = "Create a separate output file for every combination of input file and transform.  The output file names will be based on the input file names.  (The default is to create one output file per input audio file, and write all transform results for that input into it.)";
+        p.description = "Create a separate output file for every combination of input file and transform. The output file names will be based on the input file names. (The default is to create one output file per input audio file, and write all transform results for that input into it.)";
         p.hasArg = false;
         pl.push_back(p);
     }
@@ -91,13 +91,15 @@
         if (m_support & ~SupportOneFileTotal) { // not only option
             p.name = "one-file";
             if (m_support & SupportOneFilePerTrack) {
-                p.description = "Write all transform results for all input files into the single named output file.  (The default is to create one output file per input audio file, and write all transform results for that input into it.)";
+                p.description = "Write all transform results for all input files into the single named output file. (The default is to create one output file per input audio file, and write all transform results for that input into it.)";
             } else {
-                p.description = "Write all transform results for all input files into the single named output file.  (The default is to create a separate output file for each combination of input audio file and transform.)";
+                p.description = "Write all transform results for all input files into the single named output file. (The default is to create a separate output file for each combination of input audio file and transform.)";
             }                
             p.hasArg = true;
             pl.push_back(p);
         }
+    }
+    if (m_support & SupportStdOut) {
         p.name = "stdout";
         p.description = "Write all transform results directly to standard output.";
         p.hasArg = false;
@@ -149,7 +151,7 @@
                 }
             }
         } else if (i->first == "stdout") {
-            if (m_support & SupportOneFileTotal) {
+            if (m_support & SupportStdOut) {
                 if (m_singleFileName != "") {
                     SVDEBUG << "FileFeatureWriter::setParameters: WARNING: Both stdout and one-file provided, ignoring stdout" << endl;
                 } else {
@@ -165,8 +167,8 @@
 }
 
 QString
-FileFeatureWriter::getOutputFilename(QString trackId,
-                                     TransformId transformId)
+FileFeatureWriter::createOutputFilename(QString trackId,
+                                        TransformId transformId)
 {
     if (m_singleFileName != "") {
         if (QFileInfo(m_singleFileName).exists() && !(m_force || m_append)) {
@@ -177,7 +179,9 @@
         return m_singleFileName;
     }
 
-    if (m_stdout) return "";
+    if (m_stdout) {
+        return "";
+    }
     
     QUrl url(trackId, QUrl::StrictMode);
     QString scheme = url.scheme().toLower();
@@ -235,29 +239,49 @@
     // leave it to it
     if (m_stdout || m_singleFileName != "") return;
 
-    QString filename = getOutputFilename(trackId, transformId);
+    QString filename = createOutputFilename(trackId, transformId);
     if (filename == "") {
         throw FailedToOpenOutputStream(trackId, transformId);
     }
 }
 
+FileFeatureWriter::TrackTransformPair
+FileFeatureWriter::getFilenameKey(QString trackId,
+                                  TransformId transformId)
+{
+    TrackTransformPair key;
+
+    if (m_singleFileName != "") {
+        key = TrackTransformPair("", "");
+    } else if (m_manyFiles) {
+        key = TrackTransformPair(trackId, transformId);
+    } else {
+        key = TrackTransformPair(trackId, "");
+    }
+
+    return key;
+}    
+
+QString
+FileFeatureWriter::getOutputFilename(QString trackId,
+                                     TransformId transformId)
+{
+    TrackTransformPair key = getFilenameKey(trackId, transformId);
+    if (m_filenames.find(key) == m_filenames.end()) {
+        m_filenames[key] = createOutputFilename(trackId, transformId);
+    }
+    return m_filenames[key];
+}
+
 QFile *
 FileFeatureWriter::getOutputFile(QString trackId,
                                  TransformId transformId)
 {
-    pair<QString, TransformId> key;
-
-    if (m_singleFileName != "") {
-        key = pair<QString, TransformId>("", "");
-    } else if (m_manyFiles) {
-        key = pair<QString, TransformId>(trackId, transformId);
-    } else {
-        key = pair<QString, TransformId>(trackId, "");
-    }
+    TrackTransformPair key = getFilenameKey(trackId, transformId);
 
     if (m_files.find(key) == m_files.end()) {
 
-        QString filename = getOutputFilename(trackId, transformId);
+        QString filename = createOutputFilename(trackId, transformId);
 
         if (filename == "") { // stdout or failure
             return 0;
--- a/transform/FileFeatureWriter.h	Tue Sep 09 16:36:21 2014 +0100
+++ b/transform/FileFeatureWriter.h	Mon Nov 10 09:19:49 2014 +0000
@@ -50,20 +50,34 @@
     enum FileWriteSupport {
         SupportOneFilePerTrackTransform = 1,
         SupportOneFilePerTrack = 2,
-        SupportOneFileTotal = 4
+        SupportOneFileTotal = 4,
+        SupportStdOut = 8
     };
 
     FileFeatureWriter(int support, QString extension);
     QTextStream *getOutputStream(QString, TransformId);
 
     typedef pair<QString, TransformId> TrackTransformPair;
+    typedef map<TrackTransformPair, QString> FileNameMap;
     typedef map<TrackTransformPair, QFile *> FileMap;
     typedef map<QFile *, QTextStream *> FileStreamMap;
     FileMap m_files;
+    FileNameMap m_filenames;
     FileStreamMap m_streams;
     QTextStream *m_prevstream;
 
+    TrackTransformPair getFilenameKey(QString, TransformId);
+
+    // Come up with a suitable output filename for the given track ID - 
+    // transform ID combo. Fail if it already exists, etc.
+    QString createOutputFilename(QString, TransformId);
+
+    // Look up and return the output filename for the given track ID -
+    // transform ID combo.
     QString getOutputFilename(QString, TransformId);
+
+    // Look up and return the output file handle for the given track
+    // ID - transform ID combo. Return 0 if it could not be opened.
     QFile *getOutputFile(QString, TransformId);
     
     // subclass can implement this to be called before file is opened for append
--- a/transform/Transform.cpp	Tue Sep 09 16:36:21 2014 +0100
+++ b/transform/Transform.cpp	Mon Nov 10 09:19:49 2014 +0000
@@ -520,7 +520,7 @@
     }
 
     if (attrs.value("duration") != "") {
-        setStartTime(RealTime::fromString(attrs.value("duration").toStdString()));
+        setDuration(RealTime::fromString(attrs.value("duration").toStdString()));
     }
     
     if (attrs.value("sampleRate") != "") {