From 310f76786810dab59fb13fddefda0fac86babc09 Mon Sep 17 00:00:00 2001 From: Alexander Novikov Date: Thu, 6 Aug 2026 17:33:20 +0200 Subject: [PATCH 1/6] Force-include arm_acle.h on aarch64 macOS to work around Qt qyieldcpu.h Qt's qyieldcpu.h calls the ACLE intrinsic __yield() under __has_builtin(__yield) without including . Apple clang 21 reports the builtin as available but makes the resulting implicit declaration an error, so every translation unit reaching fails to compile. Co-Authored-By: Claude Opus 5 --- meson.build | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/meson.build b/meson.build index 41c9392d..f2a0175c 100644 --- a/meson.build +++ b/meson.build @@ -191,6 +191,18 @@ elif system == 'darwin' general_link_args += [ '-mmacosx-version-min=10.15' ] + + # Qt's qyieldcpu.h (through 6.9.x at least) calls the ACLE intrinsic + # __yield() under __has_builtin(__yield) without including + # . Apple clang 21 reports the builtin as available but + # makes the resulting implicit declaration an error, so every + # translation unit that reaches fails to compile. + # Force-including the ACLE header supplies the declaration. + if architecture == 'aarch64' and meson.get_compiler('cpp').has_header('arm_acle.h') + general_defines += [ + '-include', 'arm_acle.h' + ] + endif feature_defines = [ '-DHAVE_BZ2', From 379f8c88c38426b6af27f223b4fbcb7888196174 Mon Sep 17 00:00:00 2001 From: Alexander Novikov Date: Thu, 6 Aug 2026 17:43:02 +0200 Subject: [PATCH 2/6] Add PreviewChunk helper for analysing a recording as it is made Pure functions deciding which region of a growing recording to analyse next, and discarding transform output that falls outside the region asked for. Adjacent, non-overlapping, monotone ranges, so results can be concatenated with nothing to merge. Adds a test target for main/, run by meson test alongside the svcore suites. Co-Authored-By: Claude Opus 5 --- main/PreviewChunk.cpp | 62 +++++++++++ main/PreviewChunk.h | 77 ++++++++++++++ main/test/TestPreviewChunk.h | 195 +++++++++++++++++++++++++++++++++++ main/test/tony-main-test.cpp | 46 +++++++++ meson.build | 34 ++++++ 5 files changed, 414 insertions(+) create mode 100644 main/PreviewChunk.cpp create mode 100644 main/PreviewChunk.h create mode 100644 main/test/TestPreviewChunk.h create mode 100644 main/test/tony-main-test.cpp diff --git a/main/PreviewChunk.cpp b/main/PreviewChunk.cpp new file mode 100644 index 00000000..8adee1d2 --- /dev/null +++ b/main/PreviewChunk.cpp @@ -0,0 +1,62 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + This file copyright 2006-2012 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. +*/ + +#include "PreviewChunk.h" + +using namespace sv; + +namespace PreviewChunk { + +std::optional +nextRange(sv_frame_t analysedTo, + sv_frame_t recordedTo, + sv_frame_t minFrames, + sv_frame_t maxFrames) +{ + if (analysedTo < 0) analysedTo = 0; + + if (recordedTo <= analysedTo) { + return {}; + } + + if (recordedTo - analysedTo < minFrames) { + return {}; + } + + sv_frame_t to = recordedTo; + + if (maxFrames > 0 && to - analysedTo > maxFrames) { + to = analysedTo + maxFrames; + } + + return Range { analysedTo, to }; +} + +EventVector +withinRange(const EventVector &events, const Range &range) +{ + EventVector result; + result.reserve(events.size()); + + for (const auto &e: events) { + if (e.getFrame() >= range.from && e.getFrame() < range.to) { + result.push_back(e); + } + } + + return result; +} + +} diff --git a/main/PreviewChunk.h b/main/PreviewChunk.h new file mode 100644 index 00000000..0bc7f227 --- /dev/null +++ b/main/PreviewChunk.h @@ -0,0 +1,77 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + This file copyright 2006-2012 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 TONY_PREVIEW_CHUNK_H +#define TONY_PREVIEW_CHUNK_H + +#include "base/BaseTypes.h" +#include "base/Event.h" + +#include + +/** + * Pure helpers for previewing analysis of a recording that is still + * being made. These have no dependency on the document, models or UI, + * so they can be tested directly. + */ +namespace PreviewChunk { + +struct Range { + sv::sv_frame_t from; + sv::sv_frame_t to; + + sv::sv_frame_t length() const { return to - from; } + + bool operator==(const Range &r) const { + return from == r.from && to == r.to; + } +}; + +/** + * Return the next region of a growing recording to analyse, given how + * far analysis has already reached and how far the recording has got. + * + * Successive ranges are adjacent and never overlap, so their results + * can simply be concatenated: there is nothing to merge or de-duplicate. + * The range never runs backwards, so a duration notification that + * arrives out of order cannot cause the preview to rewrite itself. + * + * Returns nothing if there is less than minFrames of new audio, so that + * we don't spend more time starting transforms than running them. A + * range is clamped to maxFrames (when positive) so that a long stall + * results in several ordinary chunks rather than one huge one. + */ +std::optional nextRange(sv::sv_frame_t analysedTo, + sv::sv_frame_t recordedTo, + sv::sv_frame_t minFrames, + sv::sv_frame_t maxFrames); + +/** + * Return only those events whose frame lies within the given range. + * + * A transform output is expected to be expressed in absolute frames of + * the source audio, but that depends on the plugin: a Vamp plugin that + * derives its timestamps from a frame counter rather than from the + * timestamps the host supplies will produce output relative to the + * start of the region instead. Discarding anything outside the region + * we asked for means such an output shows up as a preview that stays + * empty, rather than as points scattered across the recording. + */ +sv::EventVector withinRange(const sv::EventVector &events, + const Range &range); + +} + +#endif diff --git a/main/test/TestPreviewChunk.h b/main/test/TestPreviewChunk.h new file mode 100644 index 00000000..7382f99b --- /dev/null +++ b/main/test/TestPreviewChunk.h @@ -0,0 +1,195 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + 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_PREVIEW_CHUNK_H +#define TEST_PREVIEW_CHUNK_H + +#include "../PreviewChunk.h" + +#include +#include + +using namespace sv; + +class TestPreviewChunk : public QObject +{ + Q_OBJECT + +private: + static const sv_frame_t MIN = 11025; + static const sv_frame_t MAX = 220500; + + static Event pitch(sv_frame_t frame, float value) { + return Event(frame, value, QString()); + } + +private slots: + + // ---- nextRange -------------------------------------------------- + + void nothingRecordedYet() { + + QCOMPARE(PreviewChunk::nextRange(0, 0, MIN, MAX).has_value(), false); + } + + void tooLittleNewAudio() { + + // Duration notifications arrive far more often than it is worth + // starting a transform + QCOMPARE(PreviewChunk::nextRange(0, MIN - 1, MIN, MAX).has_value(), + false); + } + + void exactlyEnoughNewAudio() { + + auto r = PreviewChunk::nextRange(0, MIN, MIN, MAX); + QVERIFY(r.has_value()); + QCOMPARE(r->from, sv_frame_t(0)); + QCOMPARE(r->to, MIN); + } + + void rangeStartsWhereTheLastOneEnded() { + + // Adjacent and non-overlapping, so results can simply be + // concatenated with nothing to merge + auto first = PreviewChunk::nextRange(0, 50000, MIN, MAX); + QVERIFY(first.has_value()); + + auto second = PreviewChunk::nextRange(first->to, 100000, MIN, MAX); + QVERIFY(second.has_value()); + + QCOMPARE(second->from, first->to); + QVERIFY(second->from >= first->to); + } + + void neverRunsBackwards() { + + // A duration notification that arrives out of order, or a + // recording that restarts, must not make the preview rewrite + // what it has already drawn + QCOMPARE(PreviewChunk::nextRange(100000, 50000, MIN, MAX).has_value(), + false); + } + + void longStallIsBrokenIntoChunks() { + + // If analysis falls a long way behind we want several ordinary + // chunks rather than one enormous one + auto r = PreviewChunk::nextRange(0, MAX * 3, MIN, MAX); + QVERIFY(r.has_value()); + QCOMPARE(r->from, sv_frame_t(0)); + QCOMPARE(r->to, MAX); + QCOMPARE(r->length(), MAX); + } + + void chunksEventuallyCatchUp() { + + // Repeatedly applying nextRange against a fixed end must + // terminate, having covered the whole span exactly once + const sv_frame_t recordedTo = MAX * 3 + 5000; + + sv_frame_t at = 0; + int iterations = 0; + + while (auto r = PreviewChunk::nextRange(at, recordedTo, MIN, MAX)) { + QCOMPARE(r->from, at); + QVERIFY(r->to > r->from); + at = r->to; + QVERIFY(++iterations < 100); + } + + // Whatever is left is below the minimum chunk size + QVERIFY(recordedTo - at < MIN); + } + + void unboundedWhenMaxIsZero() { + + auto r = PreviewChunk::nextRange(0, 10 * MAX, MIN, 0); + QVERIFY(r.has_value()); + QCOMPARE(r->to, 10 * MAX); + } + + void negativeStartIsClamped() { + + auto r = PreviewChunk::nextRange(-500, 50000, MIN, MAX); + QVERIFY(r.has_value()); + QCOMPARE(r->from, sv_frame_t(0)); + } + + // ---- withinRange ------------------------------------------------ + + void withinRangeKeepsEventsInside() { + + EventVector events { + pitch(1000, 440.f), + pitch(1500, 441.f), + pitch(1999, 442.f) + }; + + auto kept = PreviewChunk::withinRange(events, { 1000, 2000 }); + QCOMPARE(int(kept.size()), 3); + } + + void withinRangeIsHalfOpen() { + + EventVector events { + pitch(999, 440.f), // before + pitch(1000, 441.f), // first frame, kept + pitch(2000, 442.f) // one past the end, dropped + }; + + auto kept = PreviewChunk::withinRange(events, { 1000, 2000 }); + QCOMPARE(int(kept.size()), 1); + QCOMPARE(kept[0].getFrame(), sv_frame_t(1000)); + } + + void withinRangeRejectsRelativeFrames() { + + // A plugin whose output is relative to the start of the analysed + // region rather than absolute would land near frame zero. Those + // events must be discarded rather than drawn in the wrong place: + // an empty preview is a far better failure than a pitch track + // scattered across the recording. + EventVector relative { + pitch(0, 440.f), + pitch(256, 441.f), + pitch(512, 442.f) + }; + + auto kept = PreviewChunk::withinRange(relative, { 500000, 550000 }); + QCOMPARE(int(kept.size()), 0); + } + + void withinRangeRejectsDoubledFrames() { + + // The specific failure this guards against: frames offset by the + // region start a second time, landing at twice the true position + const sv_frame_t from = 500000, to = 550000; + + EventVector doubled { + pitch(from * 2, 440.f), + pitch(from * 2 + 256, 441.f) + }; + + QCOMPARE(int(PreviewChunk::withinRange(doubled, { from, to }).size()), 0); + } + + void withinRangeOfEmptyIsEmpty() { + + QCOMPARE(int(PreviewChunk::withinRange(EventVector(), + { 0, 1000 }).size()), 0); + } +}; + +#endif diff --git a/main/test/tony-main-test.cpp b/main/test/tony-main-test.cpp new file mode 100644 index 00000000..442aa305 --- /dev/null +++ b/main/test/tony-main-test.cpp @@ -0,0 +1,46 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + 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 "TestPreviewChunk.h" + +#include "system/Init.h" + +#include + +int main(int argc, char *argv[]) +{ + int good = 0, bad = 0; + + svSystemSpecificInitialisation(); + + QCoreApplication app(argc, argv); + app.setOrganizationName("sonic-visualiser"); + app.setApplicationName("test-tony-main"); + + { + TestPreviewChunk t; + if (QTest::qExec(&t, argc, argv) == 0) ++good; + else ++bad; + } + + (void)good; + + if (bad > 0) { + SVCERR << "\n********* " << bad << " test suite(s) failed!\n" << endl; + return 1; + } else { + SVCERR << "All tests passed" << endl; + return 0; + } +} diff --git a/meson.build b/meson.build index f2a0175c..c5e61ed7 100644 --- a/meson.build +++ b/meson.build @@ -1035,12 +1035,20 @@ tony_main_files = [ 'main/Analyser.cpp', 'main/MainWindow.cpp', 'main/NetworkPermissionTester.cpp', + 'main/PreviewChunk.cpp', + 'main/RecordingPreview.cpp', ] tony_main_moc_files = qt.preprocess( moc_headers: [ 'main/MainWindow.h', 'main/Analyser.h', + 'main/RecordingPreview.h', +]) + +tony_main_test_moc_files = qt.preprocess( + moc_headers: [ + 'main/test/TestPreviewChunk.h', ]) qt_resource_files = qt.preprocess( @@ -1211,6 +1219,31 @@ svcore_data_fileio_test_exe = executable( win_subsystem: 'console' ) +tony_main_test_exe = executable( + 'test-tony-main', + tony_main_test_moc_files, + 'main/PreviewChunk.cpp', + 'main/test/tony-main-test.cpp', + include_directories: [ + 'main', + ], + dependencies: [ + svcore_dep, + qt_dep, + feature_dependencies, + dl_dep, + ], + cpp_args: [ + feature_defines, + general_defines, + ], + link_args: [ + feature_additional_libs, + general_link_args, + ], + win_subsystem: 'console' +) + test('svcore-base', svcore_base_test_exe) test('svcore-system', svcore_system_test_exe) test('svcore-data-model', svcore_data_model_test_exe) @@ -1218,6 +1251,7 @@ test('svcore-data-fileio', svcore_data_fileio_test_exe, args: [ '--testdir', meson.current_source_dir() / 'svcore/data/fileio/test' ]) +test('tony-main', tony_main_test_exe) summary({'prefix': get_option('prefix'), 'bindir': get_option('bindir'), From da05f174715002acfe4c74f2f9c53c021acda5ed Mon Sep 17 00:00:00 2001 From: Alexander Novikov Date: Thu, 6 Aug 2026 17:43:12 +0200 Subject: [PATCH 3/6] Show the pitch track filling in while a recording is made Off by default, from a new item in the Analysis menu. When enabled, the pitch track is filled in as the recording proceeds, one region at a time, so that a performer can see something as they go. This is only ever a preview: everything it adds is removed again when recording stops, and the pitch and note layers are then regenerated in full from the completed audio as they always were. The recordCompleted path is untouched. The transform output model comes from ModelTransformerFactory rather than Document, so it belongs to us: no layer is created for it, it is not registered with the document, and nothing reaches the undo history. Co-Authored-By: Claude Opus 5 --- main/Analyser.cpp | 47 ++++++- main/Analyser.h | 24 ++++ main/MainWindow.cpp | 59 +++++++- main/MainWindow.h | 5 + main/RecordingPreview.cpp | 275 ++++++++++++++++++++++++++++++++++++++ main/RecordingPreview.h | 132 ++++++++++++++++++ 6 files changed, 540 insertions(+), 2 deletions(-) create mode 100644 main/RecordingPreview.cpp create mode 100644 main/RecordingPreview.h diff --git a/main/Analyser.cpp b/main/Analyser.cpp index 957bfe63..8af07774 100644 --- a/main/Analyser.cpp +++ b/main/Analyser.cpp @@ -14,6 +14,7 @@ */ #include "Analyser.h" +#include "RecordingPreview.h" #include "transform/TransformFactory.h" #include "transform/ModelTransformer.h" @@ -47,10 +48,14 @@ Analyser::Analyser() : m_document(0), m_paneStack(0), m_pane(0), + m_recordingPreview(new RecordingPreview(this)), m_currentCandidate(-1), m_candidatesVisible(false), m_currentAsyncHandle(0) { + connect(m_recordingPreview, SIGNAL(previewUpdated()), + this, SIGNAL(layersChanged())); + QSettings settings; settings.beginGroup("LayerDefaults"); settings.setValue @@ -77,7 +82,8 @@ Analyser::getAnalysisSettings() return { { "precision-analysis", false }, { "lowamp-analysis", true }, { "onset-analysis", true }, - { "prune-analysis", true } + { "prune-analysis", true }, + { "record-preview", false } }; } @@ -126,6 +132,44 @@ Analyser::analyseExistingFile() return doAllAnalyses(true); } +QString +Analyser::beginRecordingPreview() +{ + if (!m_document) return "Internal error: Analyser::beginRecordingPreview() called with no document present"; + + if (!m_pane) return "Internal error: Analyser::beginRecordingPreview() called with no pane present"; + + if (m_fileModel.isNone()) return "Internal error: Analyser::beginRecordingPreview() called with no model present"; + + // The preview draws into the pitch track, so we need one. If + // auto-analysis is switched off there won't be one yet, but asking + // to see analysis while recording is a clear enough request for it. + if (!m_layers[PitchTrack]) { + QString error = addAnalyses(); + if (error != "") return error; + } + + TimeValueLayer *pitchLayer = + qobject_cast(m_layers[PitchTrack]); + if (!pitchLayer) { + return "Internal error: Analyser::beginRecordingPreview() has no pitch track layer"; + } + + return m_recordingPreview->begin(m_fileModel, pitchLayer); +} + +void +Analyser::recordingPreviewReachedFrame(sv_frame_t frame) +{ + m_recordingPreview->recordedTo(frame); +} + +void +Analyser::endRecordingPreview() +{ + m_recordingPreview->end(); +} + QString Analyser::doAllAnalyses(bool withPitchTrack) { @@ -170,6 +214,7 @@ void Analyser::fileClosed() { cerr << "Analyser::fileClosed" << endl; + m_recordingPreview->abandon(); m_layers.clear(); m_reAnalysisCandidates.clear(); m_currentCandidate = -1; diff --git a/main/Analyser.h b/main/Analyser.h index 918d239c..0dc13d34 100644 --- a/main/Analyser.h +++ b/main/Analyser.h @@ -36,6 +36,8 @@ class TimeValueLayer; class Layer; } +class RecordingPreview; + class Analyser : public QObject, public sv::Document::LayerCreationHandler { @@ -56,6 +58,26 @@ class Analyser : public QObject, // layers; return "" on success or error string on failure QString analyseExistingFile(); + /** + * Start filling in the pitch track as a recording is made, so that + * the performer can see something as they go. Everything this adds + * is removed again by endRecordingPreview(), because the pitch and + * note layers are regenerated in full when recording stops. Returns + * "" on success or an error string on failure. + */ + QString beginRecordingPreview(); + + /** + * Note that the recording being previewed has reached the given + * frame. + */ + void recordingPreviewReachedFrame(sv::sv_frame_t frame); + + /** + * Stop previewing and remove everything the preview added. + */ + void endRecordingPreview(); + // Discard any layers etc associated with the current document void fileClosed(); @@ -245,6 +267,8 @@ protected slots: mutable std::map m_layers; + RecordingPreview *m_recordingPreview; + sv::Clipboard m_preAnalysis; sv::Selection m_reAnalysingSelection; FrequencyRange m_reAnalysingRange; diff --git a/main/MainWindow.cpp b/main/MainWindow.cpp index cd9fa4af..98e52790 100644 --- a/main/MainWindow.cpp +++ b/main/MainWindow.cpp @@ -774,6 +774,12 @@ MainWindow::setupAnalysisMenu() connect(m_autoAnalyse, SIGNAL(triggered()), this, SLOT(autoAnalysisToggled())); menu->addAction(m_autoAnalyse); + m_recordPreview = new QAction(tr("Show Pitch Track While &Recording"), this); + m_recordPreview->setStatusTip(tr("Fill in the pitch track as a recording is made. The analysis is redone in full when recording stops.")); + m_recordPreview->setCheckable(true); + connect(m_recordPreview, SIGNAL(triggered()), this, SLOT(recordPreviewToggled())); + menu->addAction(m_recordPreview); + action = new QAction(tr("&Analyse Now!"), this); action->setStatusTip(tr("Trigger analysis of pitches and notes. (This will delete all existing pitches and notes.)")); connect(action, SIGNAL(triggered()), this, SLOT(analyseNow())); @@ -846,7 +852,8 @@ MainWindow::updateAnalyseStates() { "precision-analysis", m_precise }, { "lowamp-analysis", m_lowamp }, { "onset-analysis", m_onset }, - { "prune-analysis", m_prune } + { "prune-analysis", m_prune }, + { "record-preview", m_recordPreview } }; auto keyMap = Analyser::getAnalysisSettings(); @@ -881,6 +888,54 @@ MainWindow::autoAnalysisToggled() updateAnalyseStates(); } +void +MainWindow::recordPreviewToggled() +{ + QAction *a = qobject_cast(sender()); + if (!a) return; + + QSettings settings; + settings.beginGroup("Analyser"); + settings.setValue("record-preview", a->isChecked()); + settings.endGroup(); + + updateAnalyseStates(); +} + +void +MainWindow::recordStatusChanged(bool recording) +{ + if (recording) { + + QSettings settings; + settings.beginGroup("Analyser"); + bool preview = settings.value("record-preview", false).toBool(); + settings.endGroup(); + + if (!preview) return; + + QString error = m_analyser->beginRecordingPreview(); + if (error != "") { + SVCERR << "MainWindow::recordStatusChanged: " << error << endl; + } + + } else { + + // Everything the preview added is removed here. The pitch and + // note layers are then regenerated in full from the completed + // recording, exactly as they are without this feature. + m_analyser->endRecordingPreview(); + } +} + +void +MainWindow::recordDurationChanged(sv_frame_t frame, sv_samplerate_t rate) +{ + MainWindowBase::recordDurationChanged(frame, rate); + + m_analyser->recordingPreviewReachedFrame(frame); +} + void MainWindow::precisionAnalysisToggled() { @@ -1078,6 +1133,8 @@ MainWindow::setupToolbars() connect(recordAction, SIGNAL(triggered()), this, SLOT(record())); connect(m_recordTarget, SIGNAL(recordStatusChanged(bool)), recordAction, SLOT(setChecked(bool))); + connect(m_recordTarget, SIGNAL(recordStatusChanged(bool)), + this, SLOT(recordStatusChanged(bool))); connect(m_recordTarget, SIGNAL(recordCompleted()), this, SLOT(analyseNow())); connect(this, SIGNAL(canRecord(bool)), diff --git a/main/MainWindow.h b/main/MainWindow.h index f14d5ca9..394215f0 100644 --- a/main/MainWindow.h +++ b/main/MainWindow.h @@ -89,6 +89,8 @@ protected slots: virtual void analyseNow(); virtual void resetAnalyseOptions(); virtual void autoAnalysisToggled(); + virtual void recordPreviewToggled(); + virtual void recordStatusChanged(bool); virtual void precisionAnalysisToggled(); virtual void lowampAnalysisToggled(); virtual void onsetAnalysisToggled(); @@ -205,6 +207,7 @@ protected slots: bool m_intelligentActionOn; // GF: !!! temporary QAction *m_autoAnalyse; + QAction *m_recordPreview; QAction *m_precise; QAction *m_lowamp; QAction *m_onset; @@ -254,6 +257,8 @@ protected slots: bool checkSaveModified(); bool waitForInitialAnalysis(); + virtual void recordDurationChanged(sv::sv_frame_t, sv::sv_samplerate_t); + virtual void updateVisibleRangeDisplay(sv::Pane *p) const; virtual void updatePositionStatusDisplays() const; diff --git a/main/RecordingPreview.cpp b/main/RecordingPreview.cpp new file mode 100644 index 00000000..f6b3d63f --- /dev/null +++ b/main/RecordingPreview.cpp @@ -0,0 +1,275 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + This file copyright 2006-2012 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. +*/ + +#include "RecordingPreview.h" + +#include "base/Debug.h" +#include "base/RealTime.h" +#include "transform/Transform.h" +#include "transform/TransformFactory.h" +#include "transform/ModelTransformerFactory.h" +#include "data/model/WaveFileModel.h" +#include "data/model/SparseTimeValueModel.h" +#include "layer/TimeValueLayer.h" + +using namespace sv; + +RecordingPreview::RecordingPreview(QObject *parent) : + QObject(parent), + m_active(false), + m_sampleRate(0), + m_currentRange({ 0, 0 }), + m_analysedTo(0), + m_recordedTo(0) +{ +} + +RecordingPreview::~RecordingPreview() +{ + cancelTransform(); +} + +QString +RecordingPreview::begin(ModelId sourceModel, TimeValueLayer *targetLayer) +{ + abandon(); + + if (!targetLayer) { + return "Internal error: RecordingPreview::begin() called with no target layer"; + } + + auto source = ModelById::getAs(sourceModel); + if (!source) { + return "Internal error: RecordingPreview::begin() called with no source model"; + } + + QString transformId = QString("%1%2").arg(PYIN_TRANSFORM_BASE).arg(PYIN_F0_OUTPUT); + + if (!TransformFactory::getInstance()->haveTransform(transformId)) { + return tr("Transform \"%1\" not found. Unable to preview analysis while " + "recording.

Is the pYIN Vamp plugin correctly installed?") + .arg(transformId); + } + + m_sourceModel = sourceModel; + m_sampleRate = source->getSampleRate(); + m_targetLayer = targetLayer; + m_targetModel = targetLayer->getModel(); + m_analysedTo = 0; + m_recordedTo = 0; + m_added.clear(); + m_active = true; + + SVDEBUG << "RecordingPreview::begin: previewing into model " + << m_targetModel << endl; + + return ""; +} + +void +RecordingPreview::recordedTo(sv_frame_t frame) +{ + if (!m_active) return; + + if (frame > m_recordedTo) { + m_recordedTo = frame; + } + + if (!m_transformOutput.isNone()) { + // A chunk is already running; it will pick up the new mark when + // it finishes + return; + } + + auto range = PreviewChunk::nextRange(m_analysedTo, m_recordedTo, + MIN_CHUNK_FRAMES, MAX_CHUNK_FRAMES); + if (range) { + startChunk(*range); + } +} + +void +RecordingPreview::startChunk(const PreviewChunk::Range &range) +{ + auto source = ModelById::getAs(m_sourceModel); + if (!source) { + abandon(); + return; + } + + QString transformId = QString("%1%2").arg(PYIN_TRANSFORM_BASE).arg(PYIN_F0_OUTPUT); + + Transform transform = TransformFactory::getInstance()-> + getDefaultTransformFor(transformId, m_sampleRate); + + transform.setStepSize(PYIN_STEP_SIZE); + transform.setBlockSize(PYIN_BLOCK_SIZE); + + transform.setStartTime(RealTime::frame2RealTime(range.from, m_sampleRate)); + transform.setDuration(RealTime::frame2RealTime(range.length(), m_sampleRate)); + + QString message; + + // Not Document::createDerivedLayer: we want the output model only, + // with no layer, no registration with the document and nothing added + // to the undo history. The model returned here belongs to us. + ModelId output = ModelTransformerFactory::getInstance()-> + transform(transform, ModelTransformer::Input(m_sourceModel), message); + + if (output.isNone()) { + SVDEBUG << "RecordingPreview::startChunk: transform failed: " + << message << endl; + // Move past this region rather than retrying it forever + m_analysedTo = range.to; + return; + } + + m_transformOutput = output; + m_currentRange = range; + + auto model = ModelById::get(output); + if (!model) { + m_transformOutput = {}; + return; + } + + connect(model.get(), SIGNAL(completionChanged(ModelId)), + this, SLOT(transformCompletionChanged(ModelId))); + + // The transform may have finished already, in which case the signal + // has been and gone and nothing further would arrive + if (model->getCompletion() == 100) { + collectChunk(); + } +} + +void +RecordingPreview::transformCompletionChanged(ModelId modelId) +{ + if (modelId != m_transformOutput) return; + + auto model = ModelById::get(modelId); + if (!model || model->getCompletion() != 100) return; + + collectChunk(); +} + +void +RecordingPreview::collectChunk() +{ + auto output = ModelById::getAs(m_transformOutput); + auto target = ModelById::getAs(m_targetModel); + + if (output && target && m_targetLayer && + m_targetLayer->getModel() == m_targetModel) { + + // pYIN's smoothedpitchtrack output carries the host's block + // timestamps, so these frames are already absolute. withinRange + // discards anything that isn't, rather than trusting it. + EventVector events = PreviewChunk::withinRange + (output->getAllEvents(), m_currentRange); + + for (const Event &e: events) { + target->add(e); + m_added.push_back(e); + } + + if (!events.empty()) { + emit previewUpdated(); + } + } + + m_analysedTo = m_currentRange.to; + + cancelTransform(); + + // Pick up anything that arrived while this chunk was running + auto range = PreviewChunk::nextRange(m_analysedTo, m_recordedTo, + MIN_CHUNK_FRAMES, MAX_CHUNK_FRAMES); + if (range) { + startChunk(*range); + } +} + +void +RecordingPreview::cancelTransform() +{ + if (m_transformOutput.isNone()) return; + + ModelId output = m_transformOutput; + m_transformOutput = {}; + + auto model = ModelById::get(output); + if (model) { + disconnect(model.get(), SIGNAL(completionChanged(ModelId)), + this, SLOT(transformCompletionChanged(ModelId))); + } + + // cancel() waits for the transform's thread to exit, so the model is + // no longer in use by the time we release it + ModelTransformerFactory::getInstance()->cancel(output); + ModelById::release(output); +} + +void +RecordingPreview::removeAddedEvents() +{ + if (m_added.empty()) return; + + // Only if the model we wrote into is still the one the layer is + // using. If the full re-analysis has already replaced it, our points + // went with it and there is nothing to undo. + if (m_targetLayer && m_targetLayer->getModel() == m_targetModel) { + auto target = ModelById::getAs(m_targetModel); + if (target) { + for (const Event &e: m_added) { + target->remove(e); + } + } + } + + m_added.clear(); +} + +void +RecordingPreview::end() +{ + if (!m_active) return; + + SVDEBUG << "RecordingPreview::end: removing " << m_added.size() + << " preview point(s)" << endl; + + cancelTransform(); + removeAddedEvents(); + + m_active = false; + m_targetLayer = nullptr; + m_targetModel = {}; + m_sourceModel = {}; +} + +void +RecordingPreview::abandon() +{ + cancelTransform(); + + m_added.clear(); + m_active = false; + m_targetLayer = nullptr; + m_targetModel = {}; + m_sourceModel = {}; + m_analysedTo = 0; + m_recordedTo = 0; +} diff --git a/main/RecordingPreview.h b/main/RecordingPreview.h new file mode 100644 index 00000000..802a5521 --- /dev/null +++ b/main/RecordingPreview.h @@ -0,0 +1,132 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + This file copyright 2006-2012 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 TONY_RECORDING_PREVIEW_H +#define TONY_RECORDING_PREVIEW_H + +#include "PreviewChunk.h" + +#include "base/BaseTypes.h" +#include "data/model/Model.h" + +#include +#include + +namespace sv { +class TimeValueLayer; +} + +/** + * Fills in the pitch track as a recording is made, so that the + * performer can see something while singing or playing. + * + * This is only ever a preview. When the recording stops, the pitch and + * note layers are regenerated in full from the completed audio (by + * Document::replaceModel, via MainWindowBase's refreshModel call), so + * nothing produced here survives into the saved session. Accordingly + * this class removes everything it added when the recording finishes, + * leaving the pitch track exactly as it would have been without it. + * + * Each chunk is analysed by running a pYIN transform over one region of + * the recording so far. The regions are adjacent and never overlap, so + * the results are simply concatenated. Only one transform runs at a + * time; duration updates that arrive while one is running raise the + * mark for the next chunk rather than starting another. + * + * The transform output model is obtained from ModelTransformerFactory + * rather than from Document, so it belongs to us: no layer is created + * for it, it is not registered with the document, and nothing is added + * to the undo history. + */ +class RecordingPreview : public QObject +{ + Q_OBJECT + +public: + explicit RecordingPreview(QObject *parent = nullptr); + virtual ~RecordingPreview(); + + /** + * Begin previewing into the given pitch layer, analysing the given + * source (recording) model. Returns "" on success or an error + * string on failure. + */ + QString begin(sv::ModelId sourceModel, sv::TimeValueLayer *targetLayer); + + /** + * Note that the recording has reached the given frame. Starts a + * chunk if one is not already running and there is enough new audio. + */ + void recordedTo(sv::sv_frame_t frame); + + /** + * The recording has finished: cancel any running analysis and + * remove every point this preview added. + */ + void end(); + + /** + * Abandon the preview without touching the target, for use when the + * target is being replaced anyway. + */ + void abandon(); + + bool isActive() const { return m_active; } + +signals: + void previewUpdated(); + +protected slots: + void transformCompletionChanged(sv::ModelId); + +protected: + void startChunk(const PreviewChunk::Range &range); + void collectChunk(); + void cancelTransform(); + void removeAddedEvents(); + + // ~0.25s at 44.1kHz: long enough that starting a transform is worth + // it, short enough to feel responsive + static const sv::sv_frame_t MIN_CHUNK_FRAMES = 11025; + + // Bound the work in any single chunk, so that a stall produces + // several ordinary chunks rather than one very long one + static const sv::sv_frame_t MAX_CHUNK_FRAMES = 220500; + + static const int PYIN_STEP_SIZE = 256; + static const int PYIN_BLOCK_SIZE = 2048; + + static constexpr const char *PYIN_TRANSFORM_BASE = "vamp:pyin:pyin:"; + static constexpr const char *PYIN_F0_OUTPUT = "smoothedpitchtrack"; + + bool m_active; + + sv::ModelId m_sourceModel; + sv::sv_samplerate_t m_sampleRate; + + QPointer m_targetLayer; + sv::ModelId m_targetModel; + + sv::ModelId m_transformOutput; + PreviewChunk::Range m_currentRange; + + sv::sv_frame_t m_analysedTo; + sv::sv_frame_t m_recordedTo; + + // Exactly what we added, so that we can take exactly that away again + sv::EventVector m_added; +}; + +#endif From 6e76a7d0e58499a0d1f7a302a9dcf1a1c0beaa10 Mon Sep 17 00:00:00 2001 From: Alexander Novikov Date: Sun, 9 Aug 2026 10:56:36 +0200 Subject: [PATCH 4/6] Start the recording preview on the first duration update recordStatusChanged(true) is emitted from startRecording(), before record() has closed the old session, created the new document, set the main model and built the analysis layers. Starting the preview there attached it to the session about to be discarded, so fileClosed() abandoned it and nothing was ever drawn. The first duration update arrives 200ms later, by which time all of that has happened, so start there instead. One attempt per recording, so a failure is not retried on every update. Co-Authored-By: Claude Opus 5 --- main/Analyser.cpp | 6 ++++++ main/Analyser.h | 5 +++++ main/MainWindow.cpp | 46 ++++++++++++++++++++++++++++++--------------- main/MainWindow.h | 4 ++++ 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/main/Analyser.cpp b/main/Analyser.cpp index 8af07774..90d9ea61 100644 --- a/main/Analyser.cpp +++ b/main/Analyser.cpp @@ -164,6 +164,12 @@ Analyser::recordingPreviewReachedFrame(sv_frame_t frame) m_recordingPreview->recordedTo(frame); } +bool +Analyser::isRecordingPreviewActive() const +{ + return m_recordingPreview->isActive(); +} + void Analyser::endRecordingPreview() { diff --git a/main/Analyser.h b/main/Analyser.h index 0dc13d34..294c7172 100644 --- a/main/Analyser.h +++ b/main/Analyser.h @@ -73,6 +73,11 @@ class Analyser : public QObject, */ void recordingPreviewReachedFrame(sv::sv_frame_t frame); + /** + * Return true if a recording preview is currently running. + */ + bool isRecordingPreviewActive() const; + /** * Stop previewing and remove everything the preview added. */ diff --git a/main/MainWindow.cpp b/main/MainWindow.cpp index 98e52790..28867685 100644 --- a/main/MainWindow.cpp +++ b/main/MainWindow.cpp @@ -122,7 +122,8 @@ MainWindow::MainWindow(AudioMode audioMode, m_keyReference(new KeyReference()), m_selectionAnchor(0), m_withSonification(withSonification), - m_withSpectrogram(withSpectrogram) + m_withSpectrogram(withSpectrogram), + m_recordPreviewAttempted(false) { setWindowTitle(QApplication::applicationName()); @@ -906,6 +907,32 @@ void MainWindow::recordStatusChanged(bool recording) { if (recording) { + m_recordPreviewAttempted = false; + return; + } + + // Everything the preview added is removed here. The pitch and note + // layers are then regenerated in full from the completed recording, + // exactly as they are without this feature. + m_analyser->endRecordingPreview(); +} + +void +MainWindow::recordDurationChanged(sv_frame_t frame, sv_samplerate_t rate) +{ + MainWindowBase::recordDurationChanged(frame, rate); + + if (!m_analyser->isRecordingPreviewActive()) { + + if (m_recordPreviewAttempted) return; + m_recordPreviewAttempted = true; + + // Not started on recordStatusChanged(true): that is emitted from + // startRecording(), before record() has closed the old session, + // created the new document, set the main model and built the + // analysis layers. Starting there would attach the preview to + // the session that is about to be discarded. By the time the + // first duration update arrives all of that has happened. QSettings settings; settings.beginGroup("Analyser"); @@ -916,22 +943,11 @@ MainWindow::recordStatusChanged(bool recording) QString error = m_analyser->beginRecordingPreview(); if (error != "") { - SVCERR << "MainWindow::recordStatusChanged: " << error << endl; + SVCERR << "MainWindow::recordDurationChanged: unable to preview: " + << error << endl; + return; } - - } else { - - // Everything the preview added is removed here. The pitch and - // note layers are then regenerated in full from the completed - // recording, exactly as they are without this feature. - m_analyser->endRecordingPreview(); } -} - -void -MainWindow::recordDurationChanged(sv_frame_t frame, sv_samplerate_t rate) -{ - MainWindowBase::recordDurationChanged(frame, rate); m_analyser->recordingPreviewReachedFrame(frame); } diff --git a/main/MainWindow.h b/main/MainWindow.h index 394215f0..1f6bb65e 100644 --- a/main/MainWindow.h +++ b/main/MainWindow.h @@ -234,6 +234,10 @@ protected slots: bool m_withSonification; bool m_withSpectrogram; + // Preview is started on the first duration update rather than when + // recording starts; this stops us retrying every update if it fails + bool m_recordPreviewAttempted; + Analyser::FrequencyRange m_pendingConstraint; QString exportToSVL(QString path, sv::Layer *layer); From 565e245fa4161983a2a32be5a91078ce1d3ca788 Mon Sep 17 00:00:00 2001 From: Alexander Novikov Date: Sun, 9 Aug 2026 11:50:44 +0200 Subject: [PATCH 5/6] Preview notes as well as the pitch track while recording Notes have duration, so a region boundary falling inside one would cut it in two. Each pass now reaches back over a short tail of the previous one and replaces the preview's contents there, giving the note tracker the surrounding audio so it can emit the note whole. Replacing rather than merging is possible because everything drawn is the preview's own: it is a delete followed by an add, with no heuristic deciding between overlapping notes. Both outputs come from a single run of the plugin. Note frames need shifting and pitch frames do not, because pYIN's notes output derives its timestamps from a frame index counting from zero while its smoothedpitchtrack output carries the host's block timestamps. Co-Authored-By: Claude Opus 5 --- main/Analyser.cpp | 5 +- main/PreviewChunk.cpp | 8 +- main/PreviewChunk.h | 31 ++-- main/RecordingPreview.cpp | 299 +++++++++++++++++++++++------------ main/RecordingPreview.h | 98 +++++++----- main/test/TestPreviewChunk.h | 75 +++++++-- 6 files changed, 354 insertions(+), 162 deletions(-) diff --git a/main/Analyser.cpp b/main/Analyser.cpp index 90d9ea61..8d6789ab 100644 --- a/main/Analyser.cpp +++ b/main/Analyser.cpp @@ -155,7 +155,10 @@ Analyser::beginRecordingPreview() return "Internal error: Analyser::beginRecordingPreview() has no pitch track layer"; } - return m_recordingPreview->begin(m_fileModel, pitchLayer); + FlexiNoteLayer *noteLayer = + qobject_cast(m_layers[Notes]); + + return m_recordingPreview->begin(m_fileModel, pitchLayer, noteLayer); } void diff --git a/main/PreviewChunk.cpp b/main/PreviewChunk.cpp index 8adee1d2..c09ca827 100644 --- a/main/PreviewChunk.cpp +++ b/main/PreviewChunk.cpp @@ -23,7 +23,8 @@ std::optional nextRange(sv_frame_t analysedTo, sv_frame_t recordedTo, sv_frame_t minFrames, - sv_frame_t maxFrames) + sv_frame_t maxFrames, + sv_frame_t revisitFrames) { if (analysedTo < 0) analysedTo = 0; @@ -41,7 +42,10 @@ nextRange(sv_frame_t analysedTo, to = analysedTo + maxFrames; } - return Range { analysedTo, to }; + sv_frame_t from = analysedTo - revisitFrames; + if (from < 0) from = 0; + + return Range { from, to }; } EventVector diff --git a/main/PreviewChunk.h b/main/PreviewChunk.h index 0bc7f227..376e2b34 100644 --- a/main/PreviewChunk.h +++ b/main/PreviewChunk.h @@ -43,20 +43,33 @@ struct Range { * Return the next region of a growing recording to analyse, given how * far analysis has already reached and how far the recording has got. * - * Successive ranges are adjacent and never overlap, so their results - * can simply be concatenated: there is nothing to merge or de-duplicate. - * The range never runs backwards, so a duration notification that - * arrives out of order cannot cause the preview to rewrite itself. + * The region starts revisitFrames before the end of the previous one, + * so each pass re-analyses a short tail of what it did last time. The + * caller is expected to discard its earlier results for that tail and + * replace them wholesale. * - * Returns nothing if there is less than minFrames of new audio, so that - * we don't spend more time starting transforms than running them. A - * range is clamped to maxFrames (when positive) so that a long stall - * results in several ordinary chunks rather than one huge one. + * That matters for notes, which have duration and would otherwise be + * chopped in two wherever a region boundary fell in the middle of one. + * Revisiting gives the plugin's note tracker the surrounding audio and + * lets it produce the note whole. Because a preview owns everything it + * draws, replacing a region is just a delete and an add -- there is + * nothing to merge, and no heuristic deciding which of two overlapping + * notes to believe. + * + * The region never starts later than the previous one ended, so a + * duration notification arriving out of order cannot make the preview + * rewrite material it has already settled. + * + * Returns nothing if there is less than minFrames of new audio, so we + * don't spend more time starting transforms than running them. The new + * audio is clamped to maxFrames (when positive) so that a long stall + * results in several ordinary passes rather than one huge one. */ std::optional nextRange(sv::sv_frame_t analysedTo, sv::sv_frame_t recordedTo, sv::sv_frame_t minFrames, - sv::sv_frame_t maxFrames); + sv::sv_frame_t maxFrames, + sv::sv_frame_t revisitFrames); /** * Return only those events whose frame lies within the given range. diff --git a/main/RecordingPreview.cpp b/main/RecordingPreview.cpp index f6b3d63f..25fe603d 100644 --- a/main/RecordingPreview.cpp +++ b/main/RecordingPreview.cpp @@ -22,7 +22,11 @@ #include "transform/ModelTransformerFactory.h" #include "data/model/WaveFileModel.h" #include "data/model/SparseTimeValueModel.h" +#include "data/model/NoteModel.h" #include "layer/TimeValueLayer.h" +#include "layer/FlexiNoteLayer.h" + +#include using namespace sv; @@ -38,16 +42,24 @@ RecordingPreview::RecordingPreview(QObject *parent) : RecordingPreview::~RecordingPreview() { - cancelTransform(); + releaseTransforms(); +} + +sv_frame_t +RecordingPreview::toFrames(double seconds) const +{ + return sv_frame_t(seconds * m_sampleRate); } QString -RecordingPreview::begin(ModelId sourceModel, TimeValueLayer *targetLayer) +RecordingPreview::begin(ModelId sourceModel, + TimeValueLayer *targetPitchLayer, + FlexiNoteLayer *targetNoteLayer) { abandon(); - if (!targetLayer) { - return "Internal error: RecordingPreview::begin() called with no target layer"; + if (!targetPitchLayer && !targetNoteLayer) { + return "Internal error: RecordingPreview::begin() called with no target layers"; } auto source = ModelById::getAs(sourceModel); @@ -55,25 +67,35 @@ RecordingPreview::begin(ModelId sourceModel, TimeValueLayer *targetLayer) return "Internal error: RecordingPreview::begin() called with no source model"; } - QString transformId = QString("%1%2").arg(PYIN_TRANSFORM_BASE).arg(PYIN_F0_OUTPUT); + TransformFactory *tf = TransformFactory::getInstance(); + + QString f0Id = QString("%1%2").arg(PYIN_TRANSFORM_BASE).arg(PYIN_F0_OUTPUT); + QString noteId = QString("%1%2").arg(PYIN_TRANSFORM_BASE).arg(PYIN_NOTE_OUTPUT); - if (!TransformFactory::getInstance()->haveTransform(transformId)) { + if (!tf->haveTransform(f0Id) || !tf->haveTransform(noteId)) { return tr("Transform \"%1\" not found. Unable to preview analysis while " "recording.

Is the pYIN Vamp plugin correctly installed?") - .arg(transformId); + .arg(f0Id); } m_sourceModel = sourceModel; m_sampleRate = source->getSampleRate(); - m_targetLayer = targetLayer; - m_targetModel = targetLayer->getModel(); + + m_targetPitchLayer = targetPitchLayer; + m_targetPitchModel = targetPitchLayer ? targetPitchLayer->getModel() : ModelId(); + + m_targetNoteLayer = targetNoteLayer; + m_targetNoteModel = targetNoteLayer ? targetNoteLayer->getModel() : ModelId(); + m_analysedTo = 0; m_recordedTo = 0; - m_added.clear(); + m_addedPitch.clear(); + m_addedNotes.clear(); m_active = true; - SVDEBUG << "RecordingPreview::begin: previewing into model " - << m_targetModel << endl; + SVDEBUG << "RecordingPreview::begin: previewing into pitch model " + << m_targetPitchModel << " and note model " << m_targetNoteModel + << endl; return ""; } @@ -87,21 +109,23 @@ RecordingPreview::recordedTo(sv_frame_t frame) m_recordedTo = frame; } - if (!m_transformOutput.isNone()) { - // A chunk is already running; it will pick up the new mark when + if (!m_pitchOutput.isNone() || !m_noteOutput.isNone()) { + // A pass is already running; it will pick up the new mark when // it finishes return; } auto range = PreviewChunk::nextRange(m_analysedTo, m_recordedTo, - MIN_CHUNK_FRAMES, MAX_CHUNK_FRAMES); + toFrames(MIN_CHUNK_SECONDS), + toFrames(MAX_CHUNK_SECONDS), + toFrames(REVISIT_SECONDS)); if (range) { - startChunk(*range); + startPass(*range); } } void -RecordingPreview::startChunk(const PreviewChunk::Range &range) +RecordingPreview::startPass(const PreviewChunk::Range &range) { auto source = ModelById::getAs(m_sourceModel); if (!source) { @@ -109,138 +133,199 @@ RecordingPreview::startChunk(const PreviewChunk::Range &range) return; } - QString transformId = QString("%1%2").arg(PYIN_TRANSFORM_BASE).arg(PYIN_F0_OUTPUT); + TransformFactory *tf = TransformFactory::getInstance(); - Transform transform = TransformFactory::getInstance()-> - getDefaultTransformFor(transformId, m_sampleRate); + QString f0Id = QString("%1%2").arg(PYIN_TRANSFORM_BASE).arg(PYIN_F0_OUTPUT); + Transform transform = tf->getDefaultTransformFor(f0Id, m_sampleRate); transform.setStepSize(PYIN_STEP_SIZE); transform.setBlockSize(PYIN_BLOCK_SIZE); - transform.setStartTime(RealTime::frame2RealTime(range.from, m_sampleRate)); transform.setDuration(RealTime::frame2RealTime(range.length(), m_sampleRate)); + // Both outputs from a single run of the plugin: transformMultiple + // requires transforms differing only in output identifier + Transforms transforms; + transforms.push_back(transform); + transform.setOutput(PYIN_NOTE_OUTPUT); + transforms.push_back(transform); + QString message; - // Not Document::createDerivedLayer: we want the output model only, - // with no layer, no registration with the document and nothing added - // to the undo history. The model returned here belongs to us. - ModelId output = ModelTransformerFactory::getInstance()-> - transform(transform, ModelTransformer::Input(m_sourceModel), message); + // Not Document::createDerivedLayers: we want the output models only, + // with no layers, no registration with the document and nothing + // added to the undo history. The models returned here belong to us. + std::vector outputs = ModelTransformerFactory::getInstance()-> + transformMultiple(transforms, ModelTransformer::Input(m_sourceModel), + message); - if (output.isNone()) { - SVDEBUG << "RecordingPreview::startChunk: transform failed: " + if (outputs.size() < 2) { + SVDEBUG << "RecordingPreview::startPass: transform failed: " << message << endl; + for (ModelId id: outputs) { + ModelTransformerFactory::getInstance()->cancel(id); + ModelById::release(id); + } // Move past this region rather than retrying it forever m_analysedTo = range.to; return; } - m_transformOutput = output; + m_pitchOutput = outputs[0]; + m_noteOutput = outputs[1]; m_currentRange = range; - auto model = ModelById::get(output); - if (!model) { - m_transformOutput = {}; - return; - } + bool complete = true; - connect(model.get(), SIGNAL(completionChanged(ModelId)), - this, SLOT(transformCompletionChanged(ModelId))); + for (ModelId id: { m_pitchOutput, m_noteOutput }) { + auto model = ModelById::get(id); + if (!model) { + complete = false; + continue; + } + connect(model.get(), SIGNAL(completionChanged(ModelId)), + this, SLOT(transformCompletionChanged(ModelId))); + if (model->getCompletion() != 100) complete = false; + } - // The transform may have finished already, in which case the signal - // has been and gone and nothing further would arrive - if (model->getCompletion() == 100) { - collectChunk(); + // The transforms may have finished already, in which case the + // signals have been and gone and nothing further would arrive + if (complete) { + collectPass(); } } void -RecordingPreview::transformCompletionChanged(ModelId modelId) +RecordingPreview::transformCompletionChanged(ModelId) { - if (modelId != m_transformOutput) return; + if (m_pitchOutput.isNone() || m_noteOutput.isNone()) return; - auto model = ModelById::get(modelId); - if (!model || model->getCompletion() != 100) return; + for (ModelId id: { m_pitchOutput, m_noteOutput }) { + auto model = ModelById::get(id); + if (!model || model->getCompletion() != 100) return; + } - collectChunk(); + collectPass(); } void -RecordingPreview::collectChunk() +RecordingPreview::collectPass() { - auto output = ModelById::getAs(m_transformOutput); - auto target = ModelById::getAs(m_targetModel); + const PreviewChunk::Range range = m_currentRange; - if (output && target && m_targetLayer && - m_targetLayer->getModel() == m_targetModel) { + auto pitchOut = ModelById::getAs(m_pitchOutput); + auto noteOut = ModelById::getAs(m_noteOutput); - // pYIN's smoothedpitchtrack output carries the host's block - // timestamps, so these frames are already absolute. withinRange - // discards anything that isn't, rather than trusting it. - EventVector events = PreviewChunk::withinRange - (output->getAllEvents(), m_currentRange); + EventVector pitchEvents, noteEvents; - for (const Event &e: events) { - target->add(e); - m_added.push_back(e); + if (pitchOut) { + // pYIN's smoothedpitchtrack is a fixed-sample-rate output whose + // features carry the host's block timestamps, so these frames + // are already absolute. + pitchEvents = PreviewChunk::withinRange(pitchOut->getAllEvents(), range); + } + + if (noteOut) { + // The notes output is different: it is variable-sample-rate and + // derives its timestamps from a frame index counting from zero, + // ignoring the host's block timestamps, so these frames are + // relative to the start of the analysed region. + EventVector relative = noteOut->getAllEvents(); + noteEvents.reserve(relative.size()); + for (const Event &e: relative) { + noteEvents.push_back(e.withFrame(e.getFrame() + range.from)); } + noteEvents = PreviewChunk::withinRange(noteEvents, range); + } + + releaseTransforms(); + + // Replace, rather than merge: drop whatever we put in this region + // last time before adding what we have now + removeAddedFrom(range.from); - if (!events.empty()) { - emit previewUpdated(); + auto pitchTarget = ModelById::getAs(m_targetPitchModel); + if (pitchTarget && m_targetPitchLayer && + m_targetPitchLayer->getModel() == m_targetPitchModel) { + for (const Event &e: pitchEvents) { + pitchTarget->add(e); + m_addedPitch.push_back(e); } } - m_analysedTo = m_currentRange.to; + auto noteTarget = ModelById::getAs(m_targetNoteModel); + if (noteTarget && m_targetNoteLayer && + m_targetNoteLayer->getModel() == m_targetNoteModel) { + for (const Event &e: noteEvents) { + noteTarget->add(e); + m_addedNotes.push_back(e); + } + } - cancelTransform(); + m_analysedTo = range.to; - // Pick up anything that arrived while this chunk was running - auto range = PreviewChunk::nextRange(m_analysedTo, m_recordedTo, - MIN_CHUNK_FRAMES, MAX_CHUNK_FRAMES); - if (range) { - startChunk(*range); + if (!pitchEvents.empty() || !noteEvents.empty()) { + emit previewUpdated(); + } + + // Pick up anything that arrived while this pass was running + auto next = PreviewChunk::nextRange(m_analysedTo, m_recordedTo, + toFrames(MIN_CHUNK_SECONDS), + toFrames(MAX_CHUNK_SECONDS), + toFrames(REVISIT_SECONDS)); + if (next) { + startPass(*next); } } void -RecordingPreview::cancelTransform() +RecordingPreview::releaseTransforms() { - if (m_transformOutput.isNone()) return; + for (ModelId *id: { &m_pitchOutput, &m_noteOutput }) { - ModelId output = m_transformOutput; - m_transformOutput = {}; + if (id->isNone()) continue; - auto model = ModelById::get(output); - if (model) { - disconnect(model.get(), SIGNAL(completionChanged(ModelId)), - this, SLOT(transformCompletionChanged(ModelId))); - } + ModelId output = *id; + *id = {}; + + auto model = ModelById::get(output); + if (model) { + disconnect(model.get(), SIGNAL(completionChanged(ModelId)), + this, SLOT(transformCompletionChanged(ModelId))); + } - // cancel() waits for the transform's thread to exit, so the model is - // no longer in use by the time we release it - ModelTransformerFactory::getInstance()->cancel(output); - ModelById::release(output); + // cancel() waits for the transform's thread to exit, so the + // model is no longer in use by the time we release it + ModelTransformerFactory::getInstance()->cancel(output); + ModelById::release(output); + } } void -RecordingPreview::removeAddedEvents() +RecordingPreview::removeAddedFrom(sv_frame_t frame) { - if (m_added.empty()) return; - - // Only if the model we wrote into is still the one the layer is - // using. If the full re-analysis has already replaced it, our points - // went with it and there is nothing to undo. - if (m_targetLayer && m_targetLayer->getModel() == m_targetModel) { - auto target = ModelById::getAs(m_targetModel); - if (target) { - for (const Event &e: m_added) { - target->remove(e); + auto pitchTarget = ModelById::getAs(m_targetPitchModel); + bool pitchUsable = (pitchTarget && m_targetPitchLayer && + m_targetPitchLayer->getModel() == m_targetPitchModel); + + auto noteTarget = ModelById::getAs(m_targetNoteModel); + bool noteUsable = (noteTarget && m_targetNoteLayer && + m_targetNoteLayer->getModel() == m_targetNoteModel); + + auto prune = [frame](EventVector &added, bool usable, auto target) { + auto split = std::stable_partition + (added.begin(), added.end(), + [frame](const Event &e) { return e.getFrame() < frame; }); + if (usable) { + for (auto i = split; i != added.end(); ++i) { + target->remove(*i); } } - } + added.erase(split, added.end()); + }; - m_added.clear(); + prune(m_addedPitch, pitchUsable, pitchTarget); + prune(m_addedNotes, noteUsable, noteTarget); } void @@ -248,27 +333,35 @@ RecordingPreview::end() { if (!m_active) return; - SVDEBUG << "RecordingPreview::end: removing " << m_added.size() - << " preview point(s)" << endl; + SVDEBUG << "RecordingPreview::end: removing " << m_addedPitch.size() + << " preview pitch point(s) and " << m_addedNotes.size() + << " preview note(s)" << endl; + + releaseTransforms(); - cancelTransform(); - removeAddedEvents(); + // Everything, from frame zero + removeAddedFrom(0); m_active = false; - m_targetLayer = nullptr; - m_targetModel = {}; + m_targetPitchLayer = nullptr; + m_targetNoteLayer = nullptr; + m_targetPitchModel = {}; + m_targetNoteModel = {}; m_sourceModel = {}; } void RecordingPreview::abandon() { - cancelTransform(); + releaseTransforms(); - m_added.clear(); + m_addedPitch.clear(); + m_addedNotes.clear(); m_active = false; - m_targetLayer = nullptr; - m_targetModel = {}; + m_targetPitchLayer = nullptr; + m_targetNoteLayer = nullptr; + m_targetPitchModel = {}; + m_targetNoteModel = {}; m_sourceModel = {}; m_analysedTo = 0; m_recordedTo = 0; diff --git a/main/RecordingPreview.h b/main/RecordingPreview.h index 802a5521..70ee32a3 100644 --- a/main/RecordingPreview.h +++ b/main/RecordingPreview.h @@ -26,10 +26,11 @@ namespace sv { class TimeValueLayer; +class FlexiNoteLayer; } /** - * Fills in the pitch track as a recording is made, so that the + * Fills in the pitch track and notes as a recording is made, so that the * performer can see something while singing or playing. * * This is only ever a preview. When the recording stops, the pitch and @@ -37,18 +38,26 @@ class TimeValueLayer; * Document::replaceModel, via MainWindowBase's refreshModel call), so * nothing produced here survives into the saved session. Accordingly * this class removes everything it added when the recording finishes, - * leaving the pitch track exactly as it would have been without it. + * leaving the layers exactly as they would have been without it. * - * Each chunk is analysed by running a pYIN transform over one region of - * the recording so far. The regions are adjacent and never overlap, so - * the results are simply concatenated. Only one transform runs at a - * time; duration updates that arrive while one is running raise the - * mark for the next chunk rather than starting another. + * Each pass runs a pYIN transform over one region of the recording so + * far, and the regions overlap: each pass revisits a short tail of the + * previous one and replaces the preview's contents there. Notes have + * duration and would otherwise be cut in two wherever a region boundary + * fell inside one; revisiting lets the note tracker see the surrounding + * audio and emit the note whole. Because everything drawn here belongs + * to the preview, replacing a region is a delete followed by an add, + * with nothing to merge and no heuristic choosing between overlapping + * notes. * - * The transform output model is obtained from ModelTransformerFactory - * rather than from Document, so it belongs to us: no layer is created - * for it, it is not registered with the document, and nothing is added - * to the undo history. + * Only one transform runs at a time. Duration updates arriving while one + * is running raise the mark for the next pass rather than starting + * another. + * + * The output models come from ModelTransformerFactory rather than from + * Document, so they belong to us: no layer is created for them, they are + * not registered with the document, and nothing is added to the undo + * history. */ class RecordingPreview : public QObject { @@ -59,27 +68,30 @@ class RecordingPreview : public QObject virtual ~RecordingPreview(); /** - * Begin previewing into the given pitch layer, analysing the given - * source (recording) model. Returns "" on success or an error - * string on failure. + * Begin previewing into the given layers, analysing the given source + * (recording) model. Either target layer may be null, in which case + * that part of the preview is skipped. Returns "" on success or an + * error string on failure. */ - QString begin(sv::ModelId sourceModel, sv::TimeValueLayer *targetLayer); + QString begin(sv::ModelId sourceModel, + sv::TimeValueLayer *targetPitchLayer, + sv::FlexiNoteLayer *targetNoteLayer); /** - * Note that the recording has reached the given frame. Starts a - * chunk if one is not already running and there is enough new audio. + * Note that the recording has reached the given frame. Starts a pass + * if one is not already running and there is enough new audio. */ void recordedTo(sv::sv_frame_t frame); /** - * The recording has finished: cancel any running analysis and - * remove every point this preview added. + * The recording has finished: cancel any running analysis and remove + * everything this preview added. */ void end(); /** - * Abandon the preview without touching the target, for use when the - * target is being replaced anyway. + * Abandon the preview without touching the targets, for use when + * they are being replaced anyway. */ void abandon(); @@ -92,41 +104,53 @@ protected slots: void transformCompletionChanged(sv::ModelId); protected: - void startChunk(const PreviewChunk::Range &range); - void collectChunk(); - void cancelTransform(); - void removeAddedEvents(); + void startPass(const PreviewChunk::Range &range); + void collectPass(); + void releaseTransforms(); + void removeAddedFrom(sv::sv_frame_t frame); + + // Enough new audio to be worth starting a transform for, but short + // enough to feel responsive + static constexpr double MIN_CHUNK_SECONDS = 0.25; - // ~0.25s at 44.1kHz: long enough that starting a transform is worth - // it, short enough to feel responsive - static const sv::sv_frame_t MIN_CHUNK_FRAMES = 11025; + // Bound the work in a single pass, so a stall produces several + // ordinary passes rather than one very long one + static constexpr double MAX_CHUNK_SECONDS = 5.0; - // Bound the work in any single chunk, so that a stall produces - // several ordinary chunks rather than one very long one - static const sv::sv_frame_t MAX_CHUNK_FRAMES = 220500; + // How much of the previous pass to re-analyse and replace. Needs to + // comfortably exceed the length of a note for notes to come out + // whole across a boundary. + static constexpr double REVISIT_SECONDS = 1.5; static const int PYIN_STEP_SIZE = 256; static const int PYIN_BLOCK_SIZE = 2048; static constexpr const char *PYIN_TRANSFORM_BASE = "vamp:pyin:pyin:"; static constexpr const char *PYIN_F0_OUTPUT = "smoothedpitchtrack"; + static constexpr const char *PYIN_NOTE_OUTPUT = "notes"; + + sv::sv_frame_t toFrames(double seconds) const; bool m_active; sv::ModelId m_sourceModel; sv::sv_samplerate_t m_sampleRate; - QPointer m_targetLayer; - sv::ModelId m_targetModel; + QPointer m_targetPitchLayer; + sv::ModelId m_targetPitchModel; + sv::EventVector m_addedPitch; + + QPointer m_targetNoteLayer; + sv::ModelId m_targetNoteModel; + sv::EventVector m_addedNotes; + + sv::ModelId m_pitchOutput; + sv::ModelId m_noteOutput; - sv::ModelId m_transformOutput; PreviewChunk::Range m_currentRange; sv::sv_frame_t m_analysedTo; sv::sv_frame_t m_recordedTo; - - // Exactly what we added, so that we can take exactly that away again - sv::EventVector m_added; }; #endif diff --git a/main/test/TestPreviewChunk.h b/main/test/TestPreviewChunk.h index 7382f99b..eb5a0944 100644 --- a/main/test/TestPreviewChunk.h +++ b/main/test/TestPreviewChunk.h @@ -40,20 +40,20 @@ private slots: void nothingRecordedYet() { - QCOMPARE(PreviewChunk::nextRange(0, 0, MIN, MAX).has_value(), false); + QCOMPARE(PreviewChunk::nextRange(0, 0, MIN, MAX, 0).has_value(), false); } void tooLittleNewAudio() { // Duration notifications arrive far more often than it is worth // starting a transform - QCOMPARE(PreviewChunk::nextRange(0, MIN - 1, MIN, MAX).has_value(), + QCOMPARE(PreviewChunk::nextRange(0, MIN - 1, MIN, MAX, 0).has_value(), false); } void exactlyEnoughNewAudio() { - auto r = PreviewChunk::nextRange(0, MIN, MIN, MAX); + auto r = PreviewChunk::nextRange(0, MIN, MIN, MAX, 0); QVERIFY(r.has_value()); QCOMPARE(r->from, sv_frame_t(0)); QCOMPARE(r->to, MIN); @@ -63,10 +63,10 @@ private slots: // Adjacent and non-overlapping, so results can simply be // concatenated with nothing to merge - auto first = PreviewChunk::nextRange(0, 50000, MIN, MAX); + auto first = PreviewChunk::nextRange(0, 50000, MIN, MAX, 0); QVERIFY(first.has_value()); - auto second = PreviewChunk::nextRange(first->to, 100000, MIN, MAX); + auto second = PreviewChunk::nextRange(first->to, 100000, MIN, MAX, 0); QVERIFY(second.has_value()); QCOMPARE(second->from, first->to); @@ -78,7 +78,7 @@ private slots: // A duration notification that arrives out of order, or a // recording that restarts, must not make the preview rewrite // what it has already drawn - QCOMPARE(PreviewChunk::nextRange(100000, 50000, MIN, MAX).has_value(), + QCOMPARE(PreviewChunk::nextRange(100000, 50000, MIN, MAX, 0).has_value(), false); } @@ -86,7 +86,7 @@ private slots: // If analysis falls a long way behind we want several ordinary // chunks rather than one enormous one - auto r = PreviewChunk::nextRange(0, MAX * 3, MIN, MAX); + auto r = PreviewChunk::nextRange(0, MAX * 3, MIN, MAX, 0); QVERIFY(r.has_value()); QCOMPARE(r->from, sv_frame_t(0)); QCOMPARE(r->to, MAX); @@ -102,7 +102,7 @@ private slots: sv_frame_t at = 0; int iterations = 0; - while (auto r = PreviewChunk::nextRange(at, recordedTo, MIN, MAX)) { + while (auto r = PreviewChunk::nextRange(at, recordedTo, MIN, MAX, 0)) { QCOMPARE(r->from, at); QVERIFY(r->to > r->from); at = r->to; @@ -115,18 +115,73 @@ private slots: void unboundedWhenMaxIsZero() { - auto r = PreviewChunk::nextRange(0, 10 * MAX, MIN, 0); + auto r = PreviewChunk::nextRange(0, 10 * MAX, MIN, 0, 0); QVERIFY(r.has_value()); QCOMPARE(r->to, 10 * MAX); } void negativeStartIsClamped() { - auto r = PreviewChunk::nextRange(-500, 50000, MIN, MAX); + auto r = PreviewChunk::nextRange(-500, 50000, MIN, MAX, 0); QVERIFY(r.has_value()); QCOMPARE(r->from, sv_frame_t(0)); } + // ---- revisiting ------------------------------------------------- + + void revisitReachesBackIntoThepreviousRegion() { + + // Notes have duration and would be cut in two at a region + // boundary. Reaching back lets the note tracker see the + // surrounding audio and emit the note whole; the caller replaces + // its earlier results for the revisited part. + const sv_frame_t revisit = 66150; // 1.5s at 44.1kHz + + auto r = PreviewChunk::nextRange(200000, 250000, MIN, MAX, revisit); + QVERIFY(r.has_value()); + QCOMPARE(r->from, sv_frame_t(200000 - revisit)); + QCOMPARE(r->to, sv_frame_t(250000)); + } + + void revisitDoesNotChangeHowFarWeGet() { + + // Only the start moves back: the region must still end at the + // recording head, or analysis would never catch up + auto plain = PreviewChunk::nextRange(200000, 250000, MIN, MAX, 0); + auto revisited = PreviewChunk::nextRange(200000, 250000, MIN, MAX, 66150); + + QVERIFY(plain.has_value() && revisited.has_value()); + QCOMPARE(revisited->to, plain->to); + QVERIFY(revisited->from < plain->from); + } + + void revisitIsClampedAtTheStartOfTheRecording() { + + auto r = PreviewChunk::nextRange(20000, 50000, MIN, MAX, 66150); + QVERIFY(r.has_value()); + QCOMPARE(r->from, sv_frame_t(0)); + } + + void revisitStillTerminates() { + + // Revisiting must not stop analysis advancing: the end still + // moves forward every pass even though the start moves back + const sv_frame_t recordedTo = MAX * 3 + 5000; + const sv_frame_t revisit = 66150; + + sv_frame_t at = 0; + int iterations = 0; + + while (auto r = PreviewChunk::nextRange(at, recordedTo, MIN, MAX, + revisit)) { + QVERIFY(r->to > at); + at = r->to; + QVERIFY(++iterations < 100); + } + + QVERIFY(recordedTo - at < MIN); + } + // ---- withinRange ------------------------------------------------ void withinRangeKeepsEventsInside() { From 124c5303986237a9b62f6c9f68bff060ca12c8df Mon Sep 17 00:00:00 2001 From: Alexander Novikov Date: Sun, 9 Aug 2026 12:03:40 +0200 Subject: [PATCH 6/6] Prune the preview's record of added events by search, not by scan Each pass removed what it had previously drawn in the revisited region with a stable_partition across the whole record of added events. That record grows at around 170 events a second, and Event holds QStrings, so the cost was quadratic in the length of the take: after several minutes the passes were doing enough work on the GUI thread to make the application progressively less responsive. The record is sorted by frame, because each pass prunes from its region start and then appends that region in order, and the region start only moves forward. So the cut point can be found by binary search. Also make the completion connection queued: completionChanged is emitted from the transform's own thread and the handler releases the model that emitted it. Co-Authored-By: Claude Opus 5 --- main/RecordingPreview.cpp | 24 ++++++++++++++++++------ main/test/TestPreviewChunk.h | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/main/RecordingPreview.cpp b/main/RecordingPreview.cpp index 25fe603d..92c405e3 100644 --- a/main/RecordingPreview.cpp +++ b/main/RecordingPreview.cpp @@ -183,8 +183,13 @@ RecordingPreview::startPass(const PreviewChunk::Range &range) complete = false; continue; } + // Queued: completionChanged is emitted from the transform's own + // thread, and the handler releases the model that emitted it. + // Going through the event loop keeps us from doing that while + // the signal is still being delivered. connect(model.get(), SIGNAL(completionChanged(ModelId)), - this, SLOT(transformCompletionChanged(ModelId))); + this, SLOT(transformCompletionChanged(ModelId)), + Qt::QueuedConnection); if (model->getCompletion() != 100) complete = false; } @@ -294,8 +299,10 @@ RecordingPreview::releaseTransforms() this, SLOT(transformCompletionChanged(ModelId))); } - // cancel() waits for the transform's thread to exit, so the - // model is no longer in use by the time we release it + // Unconditionally, including when the transform has already + // reported completion: reaching 100 happens inside run(), so the + // thread may still be tearing down. cancel() waits for it to + // exit, which is what makes it safe to release the model here. ModelTransformerFactory::getInstance()->cancel(output); ModelById::release(output); } @@ -312,10 +319,15 @@ RecordingPreview::removeAddedFrom(sv_frame_t frame) bool noteUsable = (noteTarget && m_targetNoteLayer && m_targetNoteLayer->getModel() == m_targetNoteModel); + // Each pass prunes from its region start and then appends that + // region's events in order, and the region start only ever moves + // forward, so these stay sorted by frame. That lets us find the + // cut point rather than scanning: this runs on every pass, and the + // vectors reach six figures over a long take. auto prune = [frame](EventVector &added, bool usable, auto target) { - auto split = std::stable_partition - (added.begin(), added.end(), - [frame](const Event &e) { return e.getFrame() < frame; }); + auto split = std::lower_bound + (added.begin(), added.end(), frame, + [](const Event &e, sv_frame_t f) { return e.getFrame() < f; }); if (usable) { for (auto i = split; i != added.end(); ++i) { target->remove(*i); diff --git a/main/test/TestPreviewChunk.h b/main/test/TestPreviewChunk.h index eb5a0944..3929f435 100644 --- a/main/test/TestPreviewChunk.h +++ b/main/test/TestPreviewChunk.h @@ -182,6 +182,42 @@ private slots: QVERIFY(recordedTo - at < MIN); } + void addedEventsStaySortedAcrossPasses() { + + // RecordingPreview relies on this: it prunes its record of added + // events from the region start and then appends that region's + // events, so the record stays sorted by frame and can be pruned + // by binary search rather than by scanning. That matters because + // the record reaches six figures over a long recording and is + // pruned on every pass. + const sv_frame_t revisit = 66150; + const sv_frame_t step = 20000; + + sv_frame_t analysedTo = 0; + sv_frame_t recordedTo = 0; + sv_frame_t lastFrom = -1; + + for (int pass = 0; pass < 40; ++pass) { + + recordedTo += step; + + auto r = PreviewChunk::nextRange(analysedTo, recordedTo, + MIN, MAX, revisit); + if (!r) continue; + + // The region start must never go backwards, or a prune + // would leave later events in front of earlier ones + QVERIFY(r->from >= lastFrom); + lastFrom = r->from; + + // and must not exceed where we had reached, or a pass would + // leave a gap it never fills + QVERIFY(r->from <= analysedTo); + + analysedTo = r->to; + } + } + // ---- withinRange ------------------------------------------------ void withinRangeKeepsEventsInside() {