From a5a6c72b0f99a650c5bee832776ccf7b1e0b208b Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 00:51:24 +0200 Subject: [PATCH 01/10] File loudness readings under the bytes' hash, and stamp take rows with the file's modification time A loudness reading lived in the slot table's memory and died with the window: the next connect showed a dash for every slot (#140). A reading is a pure function of a take's bytes, and the history already names takes by exactly that. Store version 9 adds loudness_readings, keyed by the 32-byte content hash and by nothing else - no slot, no operation, no foreign key to blobs_meta - since a reading holds for bytes slot_audio only names, kept or released, and for bytes read off the card that no operation archived. The raw numbers go in: integrated LUFS (NULL when unmeasurable), sample peak, true peak in dBTP, the count of impossible samples, and when it was measured. Never the verdict: the target moves in Settings, and describeReading judges an old number against today's target instead of keeping an old opinion. The same hash again replaces. A hash of the wrong length, a negative count and a value that is not a number are refused - SQLite would file a NaN as NULL, which here reads "unmeasurable". slot_audio gains the take's modification time off the card's directory entry, on every row that says what a slot holds: landed by a write, found in place after one, photographed by the first sighting. With the name and the size it is what #141 needs to tell whether the file in the slot is still the one that was measured, without reading it. The stamp is read through JUCE: file_time_type has no portable conversion to the system clock across the three standard libraries the app builds with, and #141 compares two sightings for equality. NULL only on rows older than the column, and on archived takes, which are leaving their slot. The hash function moves to ContentHash.h so the player's read pass can compute the same key without linking the store; HistoryStore::contentHash keeps its name for every caller that knows the store. --- app/history/ContentHash.h | 30 ++++++ app/history/FileTime.h | 42 ++++++++ app/history/HistoryRecorder.cpp | 19 +++- app/history/HistoryRecorder.h | 5 + app/history/HistoryStore.cpp | 79 +++++++++++--- app/history/HistoryStore.h | 54 ++++++++-- app/history/Schema.h | 35 ++++++- app/history/Sqlite.h | 11 ++ tests/card_timeline_tests.cpp | 12 +-- tests/first_seen_tests.cpp | 2 +- tests/forget_slot_tests.cpp | 2 +- tests/history_recorder_tests.cpp | 58 +++++++++++ tests/history_schema_tests.cpp | 75 ++++++++++++-- tests/history_store_tests.cpp | 171 ++++++++++++++++++++++++++++++- tests/retention_store_tests.cpp | 10 +- 15 files changed, 553 insertions(+), 52 deletions(-) create mode 100644 app/history/ContentHash.h create mode 100644 app/history/FileTime.h diff --git a/app/history/ContentHash.h b/app/history/ContentHash.h new file mode 100644 index 0000000..118c849 --- /dev/null +++ b/app/history/ContentHash.h @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE. +// SPDX-License-Identifier: AGPL-3.0-or-later + +#pragma once + +#include + +#include +#include + +//============================================================================== +// loopercat::history::contentHash — the history's one name for a take's +// bytes: SHA-256, raw, 32 bytes. It keys blobs_meta and blobs, it is what +// slot_audio.hash names, and it is what a loudness reading is filed under +// (#140). Every place that hashes bytes for the history comes here — the +// recorder archiving a take, the player's read pass metering one, a loudness +// check reading one — so the same bytes get the same key wherever they were +// read, and a reading taken in one place is found from another. +//============================================================================== +namespace loopercat::history +{ + +inline std::string contentHash(std::string_view bytes) +{ + const juce::SHA256 sha(bytes.data(), bytes.size()); + const juce::MemoryBlock raw = sha.getRawData(); + return std::string(static_cast(raw.getData()), raw.getSize()); +} + +} // namespace loopercat::history diff --git a/app/history/FileTime.h b/app/history/FileTime.h new file mode 100644 index 0000000..7b09f80 --- /dev/null +++ b/app/history/FileTime.h @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE. +// SPDX-License-Identifier: AGPL-3.0-or-later + +#pragma once + +#include "Sqlite.h" + +#include + +#include + +#include +#include + +//============================================================================== +// loopercat::history::modifiedMs — a file's modification time as its +// directory entry carries it, in milliseconds since the epoch: the clock +// every other time in the store is kept in (ops.at, blobs_meta.created). +// +// Read through JUCE rather than std::filesystem: file_time_type has no +// portable conversion to the system clock across the three standard +// libraries the app builds with, and the usual two-now() workaround can be a +// millisecond off between two readings. #141 compares two sightings of one +// unchanged file for equality, so the number has to be the platform's own +// stamp, exactly, every time. +// +// A file that cannot be read is an error, not a null: slot_audio.modified is +// NULL only in rows written before the store asked (Schema.h), and a null +// that meant "could not tell" would read as one of those. +//============================================================================== +namespace loopercat::history +{ + +inline std::int64_t modifiedMs(const std::filesystem::path& file) +{ + const juce::File entry(juce::String::fromUTF8(sqlite::utf8(file).c_str())); + if (!entry.existsAsFile()) + throw Error("cannot read the modification time of " + file.string() + ": no such file"); + return entry.getLastModificationTime().toMilliseconds(); +} + +} // namespace loopercat::history diff --git a/app/history/HistoryRecorder.cpp b/app/history/HistoryRecorder.cpp index d027cef..3646ea7 100644 --- a/app/history/HistoryRecorder.cpp +++ b/app/history/HistoryRecorder.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later #include "HistoryRecorder.h" +#include "FileTime.h" #include "../OperationId.h" namespace loopercat::history @@ -104,7 +105,7 @@ int HistoryRecorder::snapshotStep(const Snapshot& snapshot, int slot) // Leave room for SQLite's record header and hash. Refuse before allocating. if (std::filesystem::file_size(file) > static_cast(std::max(0, limit - 1024))) throw Error("take " + name + " exceeds the history store's size limit"); - takes.push_back({ track, name, commands::readFileBytes(file) }); + takes.push_back({ track, name, commands::readFileBytes(file), modifiedMs(file) }); } store().snapshotSlot(snapshot.op, slot, rc0::slotBody(memory, slot), takes, clock_()); } catch (const std::exception& error) { @@ -182,12 +183,17 @@ void HistoryRecorder::subject(const std::string& opId, int slot) store().recordSubject(found->second.row, slot); } -std::int64_t HistoryRecorder::opRow(const std::string& opId) const +const HistoryRecorder::Operation& HistoryRecorder::operation(const std::string& opId) const { const auto found = ops_.find(opId); if (found == ops_.end()) throw Error("operation " + opId + " reported to the history without having begun"); - return found->second.row; + return found->second; +} + +std::int64_t HistoryRecorder::opRow(const std::string& opId) const +{ + return operation(opId).row; } void HistoryRecorder::keepAudio(const std::string& opId, int slot, const std::string& fileName, @@ -205,7 +211,9 @@ void HistoryRecorder::bodies(const std::string& opId, void HistoryRecorder::landed(const std::string& opId, int slot, const std::string& fileName, std::string_view bytes) { - store().recordLanded(opRow(opId), slot, kTrack, fileName, bytes); + const Operation& op = operation(opId); + store().recordLanded(op.row, slot, kTrack, fileName, bytes, + modifiedMs(volume::wavDir(op.volume, slot) / fileName)); } void HistoryRecorder::recordWhatSlotsHold(const Operation& op) @@ -225,7 +233,8 @@ void HistoryRecorder::recordWhatSlotsHold(const Operation& op) if (ec) throw Error("cannot measure " + (dir / name).string()); store().recordPresentAudio(op.row, slot, kTrack, name, size, - store().hashHeldBefore(op.row, from, name, size)); + store().hashHeldBefore(op.row, from, name, size), + modifiedMs(dir / name)); } } } diff --git a/app/history/HistoryRecorder.h b/app/history/HistoryRecorder.h index aea8991..f1cf6c2 100644 --- a/app/history/HistoryRecorder.h +++ b/app/history/HistoryRecorder.h @@ -62,6 +62,9 @@ class HistoryRecorder void keepAudio(const std::string& opId, int slot, const std::string& fileName, std::string_view bytes); void bodies(const std::string& opId, const std::vector& changes); + // A take has landed on the card: its row carries the hash of the bytes + // the core handed over and the modification time the card's directory + // entry now shows for the file (#141). void landed(const std::string& opId, int slot, const std::string& fileName, std::string_view bytes); // `error` empty = the job succeeded, and `note` is the line the job wrote @@ -118,6 +121,8 @@ class HistoryRecorder std::filesystem::path volume; }; + // An operation that has begun and not finished; refused by name otherwise. + const Operation& operation(const std::string& opId) const; std::int64_t opRow(const std::string& opId) const; // The status of a baseline operation, or nothing when the row is gone: // clearing a slot's history can take the snapshot with it. diff --git a/app/history/HistoryStore.cpp b/app/history/HistoryStore.cpp index f3c4253..b6b43e5 100644 --- a/app/history/HistoryStore.cpp +++ b/app/history/HistoryStore.cpp @@ -3,14 +3,14 @@ #include "HistoryStore.h" +#include "ContentHash.h" #include "Schema.h" #include "Undo.h" #include -#include - #include +#include #include namespace loopercat::history @@ -100,9 +100,7 @@ HistoryStore::HistoryStore(const std::filesystem::path& dir) std::string HistoryStore::contentHash(std::string_view bytes) { - const juce::SHA256 sha(bytes.data(), bytes.size()); - const juce::MemoryBlock raw = sha.getRawData(); - return std::string(static_cast(raw.getData()), raw.getSize()); + return history::contentHash(bytes); } std::int64_t HistoryStore::card(const std::string& markerId, const std::string& model, @@ -162,7 +160,7 @@ void HistoryStore::snapshotSlot(std::int64_t op, int slot, const std::string& bo const auto hash = contentHash(take.bytes); keepBlob(hash, take.bytes, nowMs); recordPresentAudio(op, slot, take.track, take.name, - static_cast(take.bytes.size()), hash); + static_cast(take.bytes.size()), hash, take.modifiedMs); } tx.commit(); } @@ -250,13 +248,13 @@ void HistoryStore::recordBodies(std::int64_t op, const std::vector(bytes.size())).bindBlob(6, hash).run(); + .bind(5, static_cast(bytes.size())).bindBlob(6, hash).bind(7, modifiedMs).run(); } void HistoryStore::finishOp(std::int64_t op, OpStatus status, const std::string& note) @@ -277,18 +275,73 @@ void HistoryStore::finishOp(std::int64_t op, OpStatus status, const std::string& void HistoryStore::recordPresentAudio(std::int64_t op, int slot, int track, const std::string& name, std::int64_t size, - const std::optional& hash) + const std::optional& hash, + std::int64_t modifiedMs) { - sqlite::Statement row(db_, "INSERT INTO slot_audio(op, slot, side, track, name, size, hash) " - "VALUES (?1, ?2, 'after', ?3, ?4, ?5, ?6)"); + sqlite::Statement row(db_, "INSERT INTO slot_audio(op, slot, side, track, name, size, hash, modified) " + "VALUES (?1, ?2, 'after', ?3, ?4, ?5, ?6, ?7)"); row.bind(1, op).bind(2, slot).bind(3, track).bindText(4, name).bind(5, size); if (hash) row.bindBlob(6, *hash); else row.bindNull(6); + row.bind(7, modifiedMs); row.run(); } +void HistoryStore::recordReading(const std::string& hash, const wav::LoudnessReading& reading, + std::int64_t nowMs) +{ + if (hash.size() != 32) + throw Error("a content hash is 32 bytes, not " + std::to_string(hash.size())); + if (reading.wildSamples < 0) + throw Error("a count of impossible samples cannot be negative, got " + + std::to_string(reading.wildSamples)); + // A NaN would go in as NULL and come back out as "unmeasurable": refused + // here, where it still has a name. -Inf (digital silence) is a value. + if (std::isnan(reading.samplePeak) || std::isnan(reading.truePeakDb) + || (reading.integratedLufs.has_value() && std::isnan(*reading.integratedLufs))) + throw Error("a loudness reading with a value that is not a number is not a reading"); + sqlite::Statement put(db_, "INSERT INTO loudness_readings" + "(hash, integrated_lufs, sample_peak, true_peak_dbtp, wild_samples, measured) " + "VALUES (?1, ?2, ?3, ?4, ?5, ?6) " + "ON CONFLICT(hash) DO UPDATE SET " + "integrated_lufs = excluded.integrated_lufs, " + "sample_peak = excluded.sample_peak, " + "true_peak_dbtp = excluded.true_peak_dbtp, " + "wild_samples = excluded.wild_samples, " + "measured = excluded.measured"); + put.bindBlob(1, hash); + if (reading.integratedLufs.has_value()) + put.bindReal(2, *reading.integratedLufs); + else + put.bindNull(2); + put.bindReal(3, static_cast(reading.samplePeak)) + .bindReal(4, reading.truePeakDb) + .bind(5, reading.wildSamples) + .bind(6, nowMs) + .run(); +} + +std::optional HistoryStore::readingFor(const std::string& hash) +{ + if (hash.size() != 32) + throw Error("a content hash is 32 bytes, not " + std::to_string(hash.size())); + sqlite::Statement read(db_, "SELECT integrated_lufs, sample_peak, true_peak_dbtp, wild_samples, measured " + "FROM loudness_readings WHERE hash = ?1"); + read.bindBlob(1, hash); + if (!read.step()) + return std::nullopt; + StoredReading stored; + if (!read.isNull(0)) + stored.reading.integratedLufs = read.real(0); + stored.reading.samplePeak = static_cast(read.real(1)); + stored.reading.truePeakDb = read.real(2); + stored.reading.wildSamples = read.integer(3); + stored.measuredMs = read.integer(4); + return stored; +} + void HistoryStore::recordSubject(std::int64_t op, int slot) { if (slot < 1 || slot > 99) diff --git a/app/history/HistoryStore.h b/app/history/HistoryStore.h index eeac480..fac4491 100644 --- a/app/history/HistoryStore.h +++ b/app/history/HistoryStore.h @@ -28,9 +28,12 @@ // keepAudio a take about to be replaced: its bytes and the row naming // them commit together, or neither does // recordBodies the slot bodies the write is about to change, before/after -// recordLanded a take that landed: name, size and hash — not the bytes, -// which are on the card and are archived when replaced +// recordLanded a take that landed: name, size, hash and the file's +// modification time — not the bytes, which are on the card +// and are archived when replaced // finishOp done or failed +// recordReading what a take's bytes measure (#140), by their hash: a fact +// about bytes, outside any operation // // Opening the store turns every op still pending into `interrupted`: the app // stopped between two of those steps, and the history says so rather than @@ -51,7 +54,9 @@ class HistoryStore // rather than reading it on a guess. explicit HistoryStore(const std::filesystem::path& dir); - // SHA-256 of the bytes, raw (32 bytes): the audio store's key. + // SHA-256 of the bytes, raw (32 bytes): the audio store's key + // (history::contentHash, ContentHash.h — the one function, kept here by + // name for every caller that knows the store). static std::string contentHash(std::string_view bytes); // --- where and when --- @@ -63,7 +68,14 @@ class HistoryStore // One baseline per marker. Resuming keeps the operation and committed slots. std::int64_t firstSeen(std::int64_t session, const std::string& opId, std::int64_t nowMs); - struct SnapshotTake { int track; std::string name; std::string bytes; }; + // A take as the first sighting found it on the card: its bytes, and the + // modification time its directory entry carried (ms since the epoch). + struct SnapshotTake { + int track; + std::string name; + std::string bytes; + std::int64_t modifiedMs; + }; void snapshotSlot(std::int64_t op, int slot, const std::string& body, const std::vector& takes, std::int64_t nowMs); void snapshotFailed(std::int64_t op, int slot, const std::string& reason); @@ -77,16 +89,44 @@ class HistoryStore void keepAudio(std::int64_t op, int slot, int track, const std::string& name, std::string_view bytes, std::int64_t nowMs); void recordBodies(std::int64_t op, const std::vector& changes); + // `modifiedMs`: the landed file's modification time off the card's + // directory entry, ms since the epoch — every row that says what a slot + // holds carries one from here on; only such rows older than store + // version 9 have none (#141). An archived take's row (keepAudio) names + // bytes on their way out of the slot and carries none. void recordLanded(std::int64_t op, int slot, int track, const std::string& name, - std::string_view bytes); + std::string_view bytes, std::int64_t modifiedMs); void finishOp(std::int64_t op, OpStatus status, const std::string& note); // The audio a slot holds AFTER an operation — the rows that make each // slot's timeline readable on its own. `hash` is absent when the store // has never seen those bytes: the file is then known by name and size, - // and no take can be fetched for it. + // and no take can be fetched for it. `modifiedMs` as in recordLanded. void recordPresentAudio(std::int64_t op, int slot, int track, const std::string& name, - std::int64_t size, const std::optional& hash); + std::int64_t size, const std::optional& hash, + std::int64_t modifiedMs); + + // --- loudness readings (#140): facts about bytes, under their hash --- + + // The raw reading of a take's bytes, filed under their content hash and + // nothing else: no slot, no operation, no foreign key to blobs_meta — a + // reading holds for bytes the history has only named (a slot_audio row + // with no kept blob) and for bytes read off the card that no operation + // archived. The same hash again replaces: the bytes did not change, so + // a newer measurement is the same fact, dated anew. Refused: a hash that + // is not 32 bytes, a negative count, and a value that is not a number — + // SQLite would file a NaN as NULL, which here reads "unmeasurable". + // What the numbers mean against today's target is the reader's business + // (MainComponent::describeReading); the store keeps the numbers. + void recordReading(const std::string& hash, const wav::LoudnessReading& reading, + std::int64_t nowMs); + struct StoredReading { + wav::LoudnessReading reading; + std::int64_t measuredMs = 0; // when the reading was taken + }; + // Absent when these bytes were never measured. A hash of the wrong + // length is refused rather than reported as never measured. + std::optional readingFor(const std::string& hash); // --- what an operation was about (#144) --- diff --git a/app/history/Schema.h b/app/history/Schema.h index cbf81ba..a9e9dfe 100644 --- a/app/history/Schema.h +++ b/app/history/Schema.h @@ -40,6 +40,21 @@ // writes no slot_changes row, and still was about that slot. // A subject names no state, so it never makes an operation // restorable, nor an Undo target. +// loudness_readings +// what a take's bytes measure (#140), by their content hash +// and nothing else: the raw BS.1770 numbers, never the +// verdict, since the target they are judged against moves +// in Settings. No foreign key on purpose — a reading holds +// for bytes slot_audio only names, kept or released, and for +// bytes read off the card that no operation ever archived. +// slot_audio.modified +// the take's modification time as the card's directory entry +// carries it, on every `after` row from version 9 on: with +// the name and the size it tells a later connect whether the +// file in the slot is still the one that was measured, +// without reading it (#141). NULL on `after` rows older than +// the column, and on `before` rows: an archived take is +// leaving its slot, and no connect will meet it there. // // A rollback journal (DELETE), not WAL, and one file rather than two. A row // and the bytes it names must land together or not at all; inside one file @@ -63,7 +78,7 @@ namespace loopercat::history::schema { -inline constexpr std::int64_t kVersion = 8; +inline constexpr std::int64_t kVersion = 9; // Version 5 is the first supported store. Future steps append to this array; // kSteps[N] creates version kBaseVersion + N in the same transaction. @@ -183,6 +198,24 @@ CREATE TABLE op_subjects( ) STRICT; CREATE INDEX op_subjects_by_slot ON op_subjects(slot, op); )sql", +R"sql( +-- A loudness reading is a fact about bytes (#140): keyed by their hash and by +-- nothing else, so it outlives the slot, the operation and the kept copy. +-- The raw reading, not the verdict. integrated_lufs is NULL for silence or +-- under one 400 ms gating block; true_peak_dbtp is -Inf for digital silence. +CREATE TABLE loudness_readings( + hash BLOB PRIMARY KEY CHECK (length(hash) = 32), + integrated_lufs REAL, + sample_peak REAL NOT NULL, + true_peak_dbtp REAL NOT NULL, + wild_samples INTEGER NOT NULL CHECK (wild_samples >= 0), + measured INTEGER NOT NULL +) STRICT; +-- The take's modification time off the card's directory entry (#141), on +-- the rows that say what a slot holds. NULL on those written before this +-- version, and on the rows of archived takes. +ALTER TABLE slot_audio ADD COLUMN modified INTEGER; +)sql", }; static_assert(sizeof(kSteps) / sizeof(kSteps[0]) == kVersion - kBaseVersion + 1, diff --git a/app/history/Sqlite.h b/app/history/Sqlite.h index 3b5406f..077ebf9 100644 --- a/app/history/Sqlite.h +++ b/app/history/Sqlite.h @@ -98,6 +98,16 @@ class Statement return *this; } + // A double, under its own name: an overload of bind() would make every + // `bind(2, slot)` with an int ambiguous between the two conversions. + // SQLite keeps infinities as REAL and turns NaN into NULL — a caller that + // means a number checks for NaN first. + Statement& bindReal(int index, double value) + { + check(sqlite3_bind_double(stmt_.get(), index, value), index); + return *this; + } + Statement& bindText(int index, std::string_view text) { check(sqlite3_bind_text64(stmt_.get(), index, text.data(), text.size(), @@ -151,6 +161,7 @@ class Statement return sqlite3_column_type(stmt_.get(), column) == SQLITE_NULL; } std::int64_t integer(int column) const { return sqlite3_column_int64(stmt_.get(), column); } + double real(int column) const { return sqlite3_column_double(stmt_.get(), column); } std::string text(int column) const { const auto* p = reinterpret_cast(sqlite3_column_text(stmt_.get(), column)); diff --git a/tests/card_timeline_tests.cpp b/tests/card_timeline_tests.cpp index 1cb947e..e9fd370 100644 --- a/tests/card_timeline_tests.cpp +++ b/tests/card_timeline_tests.cpp @@ -104,18 +104,18 @@ int main() const auto push = r.begin("push"); r.store.recordBodies(push, { { 12, "b12-0", "b12-1" } }); - r.store.recordLanded(push, 12, 1, "a.wav", bytesA); + r.store.recordLanded(push, 12, 1, "a.wav", bytesA, 2500); r.store.finishOp(push, OpStatus::done, ""); const auto trim = r.begin("trim"); r.store.keepAudio(trim, 12, 1, "a.wav", bytesA, ++r.clock); r.store.recordBodies(trim, { { 12, "b12-1", "b12-2" } }); - r.store.recordLanded(trim, 12, 1, "a.wav", bytesB); + r.store.recordLanded(trim, 12, 1, "a.wav", bytesB, 2600); r.store.finishOp(trim, OpStatus::done, ""); const auto swap = r.begin("swap"); r.store.recordBodies(swap, { { 12, "b12-2", "b43-0" }, { 43, "b43-0", "b12-2" } }); - r.store.recordPresentAudio(swap, 43, 1, "a.wav", 3000, HistoryStore::contentHash(bytesB)); + r.store.recordPresentAudio(swap, 43, 1, "a.wav", 3000, HistoryStore::contentHash(bytesB), 2600); r.store.finishOp(swap, OpStatus::done, ""); const auto failed = r.begin("clear"); // refused before it touched the card @@ -233,7 +233,7 @@ int main() const std::string bytes = take(4000, 5); const auto push = r.begin("push"); r.store.recordBodies(push, { { 9, "b9-0", "b9-1" } }); - r.store.recordLanded(push, 9, 1, "take.wav", bytes); + r.store.recordLanded(push, 9, 1, "take.wav", bytes, 2500); r.store.finishOp(push, OpStatus::done, ""); const auto clear = r.begin("clear"); r.store.keepAudio(clear, 9, 1, "take.wav", bytes, ++r.clock); // archived on its way out @@ -242,7 +242,7 @@ int main() const auto undone = r.begin("undo"); r.store.setReverts(undone, clear); r.store.recordBodies(undone, { { 9, "b9-0", "b9-1" } }); - r.store.recordLanded(undone, 9, 1, "take.wav", bytes); + r.store.recordLanded(undone, 9, 1, "take.wav", bytes, 2700); r.store.finishOp(undone, OpStatus::done, "clear"); const auto entries = r.store.cardTimeline(); @@ -305,7 +305,7 @@ int main() const std::string bytes = take(3000, 7); const auto push = r.begin("push"); r.store.recordBodies(push, { { 4, "b4-0", "b4-1" } }); - r.store.recordLanded(push, 4, 1, "b.wav", bytes); // the take it left + r.store.recordLanded(push, 4, 1, "b.wav", bytes, 2500); // the take it left r.store.finishOp(push, OpStatus::done, ""); const auto clear = r.begin("clear"); r.store.keepAudio(clear, 4, 1, "b.wav", bytes, ++r.clock); // the take it archived diff --git a/tests/first_seen_tests.cpp b/tests/first_seen_tests.cpp index e5f8ff5..7fb8e73 100644 --- a/tests/first_seen_tests.cpp +++ b/tests/first_seen_tests.cpp @@ -375,7 +375,7 @@ static int runTests() const auto card = cardAt(tmp.path); const auto baseline = newSighting(*rec, card); CHECK_THROWS(rec->store().snapshotSlot(baseline.op, 1, "body", - { { 1, "good.wav", "bytes" }, { 0, "bad.wav", "other bytes" } }, 2000), "CHECK"); + { { 1, "good.wav", "bytes", 1500 }, { 0, "bad.wav", "other bytes", 1500 } }, 2000), "CHECK"); CHECK(rec->store().touchedSlots(baseline.op).empty()); CHECK_EQ(rec->store().usage().audioBytes, 0); CHECK_EQ(number(rec->store(), "SELECT count(*) FROM blobs_meta"), 0); diff --git a/tests/forget_slot_tests.cpp b/tests/forget_slot_tests.cpp index c9cbc45..ff7b1ab 100644 --- a/tests/forget_slot_tests.cpp +++ b/tests/forget_slot_tests.cpp @@ -95,7 +95,7 @@ static int runTests() const std::string only(3 * 1024 * 1024, 'x'); const std::string shared(1024 * 1024, 'y'); f.store.keepAudio(op, 4, 1, "one.wav", only, 10); - f.store.recordPresentAudio(op, 4, 1, "one.wav", static_cast(only.size()), HistoryStore::contentHash(only)); + f.store.recordPresentAudio(op, 4, 1, "one.wav", static_cast(only.size()), HistoryStore::contentHash(only), 4000); f.store.keepAudio(op, 4, 2, "two.wav", shared, 10); const auto nine = f.op({9}); f.store.keepAudio(nine, 9, 1, "same.wav", shared, 10); diff --git a/tests/history_recorder_tests.cpp b/tests/history_recorder_tests.cpp index bb8c951..bc88c6a 100644 --- a/tests/history_recorder_tests.cpp +++ b/tests/history_recorder_tests.cpp @@ -16,6 +16,8 @@ // - an op cut off mid-way reads as interrupted, and its take is still kept // - an op names the slot it is about once it has begun, and keeps it when // it then writes nothing (#144); maintenance is about no slot +// - every take row carries the stamp the card's directory entry showed +// (#141) #include "support.hpp" @@ -25,6 +27,7 @@ #include #include +#include #include #include #include @@ -70,6 +73,13 @@ void putWav(const fs::path& volume, int slot, const std::string& name, int frame bytes.size())); } +// A file's modification time as the OS reports it, ms since the epoch — the +// oracle the rows are compared against. +std::int64_t fileStamp(const fs::path& file) +{ + return juce::File(juce::String(file.string())).getLastModificationTime().toMilliseconds(); +} + std::map volumeBytes(const fs::path& volume) { std::map map; @@ -585,6 +595,54 @@ int main() CHECK_EQ(rec->store().slotTimeline(7).size(), 1u); } + // --- every take row carries the stamp the card's directory entry showed (#141) --- + // + // Theory: whatever way a take reaches a row — photographed by the first + // sighting, found in place after an operation, landed by a write — the + // row says what the directory entry said, exactly, so a later connect + // can compare the two without reading the file. + { + TempDir tmp; + const fs::path volume = makePedal(tmp.path); + putWav(volume, 4, "take.wav", 132300); + const fs::path takeFile = volume::wavDir(volume, 4) / "take.wav"; + // a stamp of the test's choosing, with milliseconds, years in the past + const std::int64_t stamped = 1'600'000'000'123; + CHECK(juce::File(juce::String(takeFile.string())).setLastModificationTime(juce::Time(stamped))); + CHECK_EQ(fileStamp(takeFile), stamped); // the file system kept it to the millisecond + auto rec = recorderAt(tmp.path / "history"); + CHECK_EQ(run(*rec, "op-rename", "rename", volume, [&] { + commands::rename(volume, 4, "Stamped", options(rec, "op-rename")); + }), + std::string()); + sqlite::Db& db = rec->store().db(); + // the first sighting photographed the take with its stamp... + CHECK_EQ(count(db, "SELECT modified FROM slot_audio a JOIN ops o ON o.seq = a.op " + "WHERE o.kind = 'snapshot' AND a.slot = 4"), + stamped); + // ...and the rename, which did not touch the file, wrote the same stamp down again + CHECK_EQ(count(db, "SELECT modified FROM slot_audio a JOIN ops o ON o.seq = a.op " + "WHERE o.id = 'op-rename' AND a.slot = 4"), + stamped); + // a trim lands a new file: its row carries the new entry's stamp — the + // write's own time, not the old file's + const std::int64_t before = juce::Time::currentTimeMillis(); + CHECK_EQ(run(*rec, "op-trim", "trim", volume, [&] { + commands::trim(volume, 4, 0, 66150, { .write = options(rec, "op-trim") }); + }), + std::string()); + const std::int64_t after = juce::Time::currentTimeMillis(); + const std::int64_t landed = count(db, "SELECT modified FROM slot_audio a JOIN ops o ON o.seq = a.op " + "WHERE o.id = 'op-trim' AND a.side = 'after'"); + CHECK(landed != stamped); + CHECK(landed >= before - 2000 && landed <= after + 2000); // a file system may round to whole seconds + CHECK_EQ(landed, fileStamp(takeFile)); + // every row that says what the slot holds has its stamp; the archived + // take's row names bytes on their way out, and a connect never meets them + CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio WHERE side = 'after' AND modified IS NULL"), 0); + CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio WHERE side = 'after'"), 3); // snapshot, rename, trim + } + // --- the wiring refuses to be built without what it needs --- { TempDir tmp; diff --git a/tests/history_schema_tests.cpp b/tests/history_schema_tests.cpp index 0b0a706..8cca4e5 100644 --- a/tests/history_schema_tests.cpp +++ b/tests/history_schema_tests.cpp @@ -183,11 +183,11 @@ int main() { HistoryStore fresh(tmp.path); auto& db = fresh.db(); - CHECK_EQ(schema::kVersion, 8); + CHECK_EQ(schema::kVersion, 9); CHECK_EQ(count(db, "SELECT [notnull] FROM pragma_table_info('cards') WHERE name = 'marker_id'"), 1); CHECK_THROWS(db.exec("INSERT INTO cards(model, label, first_seen, last_seen, marker_id) VALUES ('RC-5', '', 0, 0, NULL)"), "NOT NULL"); - CHECK_EQ(schema::pragmaInteger(db, "user_version"), 8); - CHECK_EQ(count(db, "SELECT count(*) FROM sqlite_master WHERE type = 'table'"), 10); + CHECK_EQ(schema::pragmaInteger(db, "user_version"), 9); + CHECK_EQ(count(db, "SELECT count(*) FROM sqlite_master WHERE type = 'table'"), 11); CHECK_EQ(count(db, "SELECT count(*) FROM sqlite_master WHERE name = 'legacy_files'"), 0); const auto session = fresh.openSession(fresh.card("test-RC-5", "RC-5", "Card", 1000), 1000); const auto op = fresh.beginOp(session, "first", "clear", 1000); @@ -203,7 +203,7 @@ int main() CHECK_THROWS(db.exec("INSERT INTO system_changes VALUES (1, 'CTL', 'c', 'd')"), "UNIQUE"); } HistoryStore reopened(tmp.path); - CHECK_EQ(schema::pragmaInteger(reopened.db(), "user_version"), 8); + CHECK_EQ(schema::pragmaInteger(reopened.db(), "user_version"), 9); CHECK_EQ(count(reopened.db(), "SELECT count(*) FROM ops WHERE pinned = 1"), 1); CHECK(reopened.takeBytes(HistoryStore::contentHash("take bytes")) == "take bytes"); CHECK_EQ(count(reopened.db(), "SELECT count(*) FROM system_changes"), 1); @@ -231,7 +231,7 @@ int main() db.exec("INSERT INTO slot_changes VALUES (1, 3, x'6265666f7265', x'6166746572')"); } HistoryStore migrated(tmp.path); - CHECK_EQ(schema::pragmaInteger(migrated.db(), "user_version"), 8); + CHECK_EQ(schema::pragmaInteger(migrated.db(), "user_version"), 9); CHECK_EQ(count(migrated.db(), "SELECT count(*) FROM cards WHERE marker_id IS NULL"), 0); CHECK_EQ(count(migrated.db(), "SELECT [notnull] FROM pragma_table_info('cards') WHERE name = 'marker_id'"), 1); CHECK_THROWS(migrated.db().exec("UPDATE cards SET marker_id = NULL"), "NOT NULL"); @@ -266,7 +266,7 @@ int main() } HistoryStore migrated(tmp.path); migrated.selectCard(1); - CHECK_EQ(schema::pragmaInteger(migrated.db(), "user_version"), 8); + CHECK_EQ(schema::pragmaInteger(migrated.db(), "user_version"), 9); CHECK_EQ(count(migrated.db(), "SELECT undo_floor FROM cards"), 0); CHECK_EQ(count(migrated.db(), "SELECT count(*) FROM forgotten_slots"), 0); CHECK(migrated.offeredTargets().undo == 1); @@ -274,7 +274,7 @@ int main() CHECK_EQ(migrated.planForgetSlot(1, 4).rowsRemoved, 1); } { - // A real v7 file opens at v8 with an empty subjects table and every + // A real v7 file opens at the current version with an empty subjects table and every // old row intact: no operation of the past gains a subject by // migration, and the new table holds the same rules as its siblings. TempDir tmp; @@ -294,7 +294,7 @@ int main() HistoryStore migrated(tmp.path); migrated.selectCard(1); auto& db = migrated.db(); - CHECK_EQ(schema::pragmaInteger(db, "user_version"), 8); + CHECK_EQ(schema::pragmaInteger(db, "user_version"), 9); CHECK_EQ(count(db, "SELECT count(*) FROM sqlite_master WHERE name = 'op_subjects'"), 1); CHECK_EQ(count(db, "SELECT count(*) FROM op_subjects"), 0); CHECK(migrated.subjects(2).empty()); @@ -322,6 +322,63 @@ int main() CHECK(commands::readFileBytes(tmp.path / "history.db") == before); CHECK_EQ(count(db, "SELECT count(*) FROM op_subjects"), 1); } + { + // A real v8 file opens at v9 with an empty readings table and every + // take row it had, each with no modification time: the store did not + // ask for one then, and a migration cannot invent what nobody read + // off the card (#140, #141). Rows written from here on carry one. + TempDir tmp; + { + auto db = sqlite::Db::open(tmp.path / "history.db"); + db.exec("PRAGMA page_size = 16384"); + db.exec("PRAGMA auto_vacuum = INCREMENTAL"); + for (int step = 0; step < 4; ++step) + db.exec(schema::kSteps[step]); + db.exec("PRAGMA user_version = 8"); + db.exec("INSERT INTO cards(model, label, first_seen, last_seen, marker_id, undo_floor) VALUES ('RC-5', 'A', 1, 1, 'v8', 0)"); + db.exec("INSERT INTO sessions(card, connected_at) VALUES (1, 1)"); + db.exec("INSERT INTO ops(id, session, kind, actor, status, at) VALUES ('v8-push', 1, 'push', 'app', 'done', 2)"); + db.exec("INSERT INTO slot_changes(op, slot, before_body, after_body) VALUES (1, 4, x'61', x'62')"); + db.exec("INSERT INTO slot_audio(op, slot, side, track, name, size, hash) " + "VALUES (1, 4, 'after', 1, '004_1.WAV', 3000, NULL)"); + } + HistoryStore migrated(tmp.path); + migrated.selectCard(1); + auto& db = migrated.db(); + CHECK_EQ(schema::pragmaInteger(db, "user_version"), 9); + CHECK_EQ(count(db, "SELECT count(*) FROM sqlite_master WHERE name = 'loudness_readings'"), 1); + CHECK_EQ(count(db, "SELECT count(*) FROM loudness_readings"), 0); + CHECK_EQ(count(db, "SELECT count(*) FROM pragma_table_info('slot_audio') WHERE name = 'modified'"), 1); + CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio"), 1); + CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio WHERE modified IS NULL"), 1); // the old row: honestly unknown + CHECK_EQ(migrated.slotTimeline(4).size(), 1u); + CHECK(migrated.slotTimeline(4).size() == 1u && migrated.slotTimeline(4).front().takeName == "004_1.WAV"); + CHECK(!migrated.readingFor(std::string(32, 'h')).has_value()); + // the new column and the new table take rows the way the store writes them + const auto op = migrated.beginOp(1, "v9-trim", "trim", 3); + migrated.recordLanded(op, 4, 1, "004_1.WAV", "new bytes", 1'700'000'000'000); + migrated.finishOp(op, history::OpStatus::done, ""); + CHECK_EQ(count(db, "SELECT modified FROM slot_audio WHERE op = " + std::to_string(op)), 1'700'000'000'000); + CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio WHERE modified IS NULL"), 1); // still only the old one + migrated.recordReading(HistoryStore::contentHash("new bytes"), { -18.2, 0.5f, -3.1, 0 }, 4); + CHECK(migrated.readingFor(HistoryStore::contentHash("new bytes")).has_value()); + // the new table holds the same rules as the method + CHECK_THROWS(db.exec("INSERT INTO loudness_readings VALUES (x'0000', NULL, 0.0, 0.0, 0, 1)"), "CHECK"); + CHECK_THROWS(db.exec("INSERT INTO loudness_readings VALUES (x'" + std::string(64, 'a') + + "', NULL, 0.0, 0.0, -1, 1)"), + "CHECK"); + CHECK_THROWS(db.exec("INSERT INTO loudness_readings VALUES (x'" + std::string(64, 'a') + + "', NULL, 0.0, 0.0, 0, NULL)"), + "NOT NULL"); + CHECK_THROWS(db.exec("INSERT INTO loudness_readings VALUES (x'" + std::string(64, 'a') + + "', 'loud', 0.0, 0.0, 0, 1)"), + ""); // STRICT: a word is not a REAL + CHECK_EQ(count(db, "SELECT count(*) FROM pragma_foreign_key_check"), 0); + // and a store already at v9 is left exactly as it is + const std::string before = commands::readFileBytes(tmp.path / "history.db"); + schema::migrate(db); + CHECK(commands::readFileBytes(tmp.path / "history.db") == before); + } { TempDir freshDir, migratedDir; { @@ -344,7 +401,7 @@ int main() TempDir tmp; { HistoryStore fresh(tmp.path); - fresh.db().exec("PRAGMA user_version = 9"); + fresh.db().exec("PRAGMA user_version = " + std::to_string(schema::kVersion + 1)); } const auto before = commands::readFileBytes(tmp.path / "history.db"); CHECK_THROWS(HistoryStore(tmp.path), "newer LooperCat"); diff --git a/tests/history_store_tests.cpp b/tests/history_store_tests.cpp index 8e860b8..fc799d9 100644 --- a/tests/history_store_tests.cpp +++ b/tests/history_store_tests.cpp @@ -16,15 +16,24 @@ // that could never return space, another program's database // - an operation remembers the slot it was about even when it changed // nothing there (#144): a row for the slot, never a state of it +// - a loudness reading is a fact about bytes (#140): filed under their +// hash and nothing else, every field back as it went in, replaced by a +// newer reading of the same bytes, untouched by whatever happens to the +// slot or the kept copy; a key that is not a hash is refused +// - every take row names the file's modification time (#141 reads it) #include "support.hpp" #include "../app/history/HistoryStore.h" #include "../app/history/Schema.h" +#include #include +#include #include #include +#include +#include #include using namespace loopercat; @@ -225,16 +234,42 @@ int main() Ready r(tmp.path); const std::string landed = take(30000, 11); const auto op = r.store.beginOp(r.session, "op-push", "push", 2000); - r.store.recordLanded(op, 9, 1, "new.wav", landed); - sqlite::Statement read(r.store.db(), "SELECT side, size, hash FROM slot_audio WHERE op = ?1"); + r.store.recordLanded(op, 9, 1, "new.wav", landed, 2345); + sqlite::Statement read(r.store.db(), "SELECT side, size, hash, modified FROM slot_audio WHERE op = ?1"); read.bind(1, op); CHECK(read.step()); CHECK_EQ(read.text(0), std::string("after")); CHECK_EQ(read.integer(1), 30000); CHECK(read.blob(2) == HistoryStore::contentHash(landed)); + CHECK_EQ(read.integer(3), 2345); // the file's stamp, as the recorder read it off the card CHECK_EQ(count(r.store.db(), "SELECT count(*) FROM blobs"), 0); } + // --- every take row names the file's modification time (#141) --- + // + // Theory: whatever way a take comes to be what a slot holds — landed by a + // write, found in place after one, photographed by the first sighting — + // its row carries the stamp the card's directory entry showed, exactly, + // in milliseconds since the epoch like every other time here. No such row + // written by this store is without one: NULL belongs to rows older than + // the column, and to archived takes, which are leaving their slot. + { + TempDir tmp; + Ready r(tmp.path); + const std::string bytes = take(3000, 144); + const auto op = r.store.beginOp(r.session, "op-stamped", "push", 2000); + r.store.recordLanded(op, 11, 1, "011_1.WAV", bytes, 1'700'000'000'123); + r.store.recordPresentAudio(op, 12, 1, "012_1.WAV", 3000, std::nullopt, 1'700'000'000'456); + r.store.finishOp(op, OpStatus::done, ""); + const auto snapshot = r.store.firstSeen(r.session, "snap", 2100); + r.store.snapshotSlot(snapshot, 13, "body", { { 1, "013_1.WAV", bytes, 1'700'000'000'789 } }, 2100); + sqlite::Db& db = r.store.db(); + CHECK_EQ(count(db, "SELECT modified FROM slot_audio WHERE slot = 11"), 1'700'000'000'123); + CHECK_EQ(count(db, "SELECT modified FROM slot_audio WHERE slot = 12"), 1'700'000'000'456); + CHECK_EQ(count(db, "SELECT modified FROM slot_audio WHERE slot = 13"), 1'700'000'000'789); + CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio WHERE side = 'after' AND modified IS NULL"), 0); + } + // --- the lifecycle cannot lie --- { TempDir tmp; @@ -366,7 +401,7 @@ int main() r.store.keepAudio(live, 5, 1, "005_1.WAV", old, 9000); r.store.recordPresentAudio(live, 5, 1, "005_1.WAV", static_cast(fresh.size()), - HistoryStore::contentHash(fresh)); + HistoryStore::contentHash(fresh), 9000); r.store.finishOp(live, OpStatus::done, "trimmed"); // A later operation after the system clock moved backwards. @@ -440,7 +475,7 @@ int main() const std::string bytes = take(8000, 71); const auto push = r.store.beginOp(r.session, "op-push", "push", 2000); r.store.recordBodies(push, { { 7, "empty", "loaded" } }); - r.store.recordLanded(push, 7, 1, "007_1.WAV", bytes); + r.store.recordLanded(push, 7, 1, "007_1.WAV", bytes, 2000); r.store.finishOp(push, OpStatus::done, ""); const std::string why = "already at -18.0 LUFS (measured -18.1), nothing to do"; const auto nothing = r.store.beginOp(r.session, "op-nothing", "normalize", 3000); @@ -542,5 +577,133 @@ int main() CHECK(r.store.slotTimeline(7).size() == 1u && r.store.slotTimeline(7).front().status == "failed"); } + // --- a loudness reading is a fact about bytes (#140) --- + // + // Theory: a reading is of bytes and of nothing else. It is filed under + // their hash whether or not any row names them, every field comes back as + // it went in — an unmeasurable loudness as absent, never as a number; a + // peak of -inf as -inf — a second reading of the same bytes replaces the + // first, and a key that is not a hash is refused rather than filed under. + { + TempDir tmp; + HistoryStore store(tmp.path); + const std::string bytes = take(5000, 140); + const std::string hash = HistoryStore::contentHash(bytes); + CHECK(!store.readingFor(hash).has_value()); // never measured: absent, not zeros + + // no slot_audio row, no blobs_meta row knows these bytes: filed regardless + const wav::LoudnessReading loud { -22.75, 0.73f, -2.5, 0 }; + store.recordReading(hash, loud, 5000); + const auto back = store.readingFor(hash); + CHECK(back.has_value()); + if (back) { + CHECK(back->reading.integratedLufs.has_value()); + CHECK(back->reading.integratedLufs.has_value() + && std::bit_cast(*back->reading.integratedLufs) + == std::bit_cast(-22.75)); + CHECK_EQ(std::bit_cast(back->reading.samplePeak), + std::bit_cast(0.73f)); + CHECK_EQ(std::bit_cast(back->reading.truePeakDb), + std::bit_cast(-2.5)); + CHECK_EQ(back->reading.wildSamples, 0); + CHECK_EQ(back->measuredMs, 5000); + } + CHECK_EQ(count(store.db(), "SELECT count(*) FROM blobs_meta"), 0); + CHECK_EQ(count(store.db(), "SELECT count(*) FROM slot_audio"), 0); + + // the same bytes measured again: one row, the newer numbers, the newer date + store.recordReading(hash, { -22.7, 0.75f, -2.4, 0 }, 6000); + CHECK_EQ(count(store.db(), "SELECT count(*) FROM loudness_readings"), 1); + const auto newer = store.readingFor(hash); + CHECK(newer.has_value() && newer->measuredMs == 6000); + CHECK(newer.has_value() && newer->reading.integratedLufs.has_value() + && std::abs(*newer->reading.integratedLufs - (-22.7)) <= 1.0e-12); + CHECK_EQ(std::bit_cast(newer ? newer->reading.samplePeak : 0.0f), + std::bit_cast(0.75f)); + + // digital silence: no loudness and a peak of -inf come back exactly so + const std::string quiet = HistoryStore::contentHash(take(5000, 141)); + store.recordReading(quiet, { std::nullopt, 0.0f, -std::numeric_limits::infinity(), 0 }, 7000); + const auto silence = store.readingFor(quiet); + CHECK(silence.has_value()); + CHECK(silence.has_value() && !silence->reading.integratedLufs.has_value()); + CHECK(silence.has_value() && std::isinf(silence->reading.truePeakDb) && silence->reading.truePeakDb < 0.0); + CHECK_EQ(std::bit_cast(silence ? silence->reading.samplePeak : 1.0f), + std::bit_cast(0.0f)); + CHECK_EQ(count(store.db(), "SELECT count(*) FROM loudness_readings WHERE integrated_lufs = 0"), 0); + CHECK_EQ(count(store.db(), "SELECT count(*) FROM loudness_readings WHERE integrated_lufs IS NULL"), 1); + + // damaged bytes: the count is the fact, the numbers beside it are kept as read + const std::string junk = HistoryStore::contentHash(take(5000, 142)); + store.recordReading(junk, { 767.0, 2.4e38f, 400.0, 1234 }, 8000); + const auto damaged = store.readingFor(junk); + CHECK(damaged.has_value() && damaged->reading.wildSamples == 1234); + CHECK_EQ(std::bit_cast(damaged ? damaged->reading.samplePeak : 0.0f), + std::bit_cast(2.4e38f)); + + // a key that is not a hash is refused, on the way in and on the way out + CHECK_THROWS(store.recordReading(hash.substr(0, 31), loud, 9000), "32 bytes"); + CHECK_THROWS(store.recordReading(hash + "x", loud, 9000), "32 bytes"); + CHECK_THROWS(store.recordReading("", loud, 9000), "32 bytes"); + CHECK_THROWS(store.readingFor(hash.substr(0, 31)), "32 bytes"); + CHECK_THROWS(store.readingFor(""), "32 bytes"); + // a value that is not a number cannot pass for "unmeasurable", and a + // count cannot be negative: refused, with nothing filed + const double nan = std::numeric_limits::quiet_NaN(); + CHECK_THROWS(store.recordReading(HistoryStore::contentHash("nan-lufs"), { nan, 0.5f, -3.0, 0 }, 1), + "not a number"); + CHECK_THROWS(store.recordReading(HistoryStore::contentHash("nan-peak"), + { -20.0, std::numeric_limits::quiet_NaN(), -3.0, 0 }, 1), + "not a number"); + CHECK_THROWS(store.recordReading(HistoryStore::contentHash("nan-tp"), { -20.0, 0.5f, nan, 0 }, 1), + "not a number"); + CHECK_THROWS(store.recordReading(HistoryStore::contentHash("negative"), { -20.0, 0.5f, -3.0, -1 }, 1), + "negative"); + CHECK_EQ(count(store.db(), "SELECT count(*) FROM loudness_readings"), 3); + // the table refuses on its own what the method refuses + CHECK_THROWS(store.db().exec("INSERT INTO loudness_readings VALUES (x'00', NULL, 0.0, 0.0, 0, 1)"), "CHECK"); + CHECK_THROWS(store.db().exec("INSERT INTO loudness_readings VALUES (x'" + std::string(64, '0') + + "', NULL, 0.0, 0.0, -1, 1)"), + "CHECK"); + CHECK_THROWS(store.db().exec("INSERT INTO loudness_readings VALUES (x'" + std::string(64, '0') + + "', NULL, NULL, 0.0, 0, 1)"), + "NOT NULL"); + CHECK_EQ(count(store.db(), "SELECT count(*) FROM loudness_readings"), 3); + } + { + // A reading outlives everything that can happen to the slot and to + // the bytes: the take's bytes released, the slot's history forgotten, + // the store reopened. A fact about bytes is not about a slot, and it + // costs nothing to keep. + TempDir tmp; + const std::string bytes = take(20000, 143); + const std::string hash = HistoryStore::contentHash(bytes); + { + Ready r(tmp.path); + const auto cleared = r.store.beginOp(r.session, "op-cleared", "clear", 2000); + r.store.keepAudio(cleared, 8, 1, "008_1.WAV", bytes, 2000); + r.store.finishOp(cleared, OpStatus::done, ""); + r.store.recordReading(hash, { -19.5, 0.9f, -1.2, 0 }, 2500); + // a later operation elsewhere, so the clear is no longer what Undo would put back + const auto renamed = r.store.beginOp(r.session, "op-renamed", "rename", 3000); + r.store.recordBodies(renamed, { { 9, "before", "after" } }); + r.store.finishOp(renamed, OpStatus::done, ""); + // the bytes go: the reading stays + CHECK(r.store.releaseBlobs({ hash }, r.store.offeredTargets(), 3500) > 0); + CHECK(!r.store.takeBytes(hash).has_value()); + CHECK(r.store.readingFor(hash).has_value()); + // the slot's history goes: the reading stays + r.store.forgetSlot(*r.store.selectedCard(), 8, 4000, true); + CHECK(r.store.slotTimeline(8).empty()); + CHECK(r.store.readingFor(hash).has_value()); + } + HistoryStore reopened(tmp.path); + const auto kept = reopened.readingFor(hash); + CHECK(kept.has_value()); + CHECK(kept.has_value() && kept->measuredMs == 2500); + CHECK(kept.has_value() && kept->reading.integratedLufs.has_value() + && std::abs(*kept->reading.integratedLufs - (-19.5)) <= 1.0e-12); + } + return testkit::summary("history_store_tests"); } diff --git a/tests/retention_store_tests.cpp b/tests/retention_store_tests.cpp index e58d8f0..b36d33a 100644 --- a/tests/retention_store_tests.cpp +++ b/tests/retention_store_tests.cpp @@ -175,7 +175,7 @@ int main() const std::string hash = HistoryStore::contentHash(shared); const auto opA = r.replaced(1, shared); const auto opB = r.replaced(2, shared); - r.store.recordLanded(opB, 2, 1, "landed.wav", shared); // an 'after' naming the same bytes + r.store.recordLanded(opB, 2, 1, "landed.wav", shared, r.now()); // an 'after' naming the same bytes const std::string other = take(20000, 2); const auto opC = r.replaced(3, other); @@ -240,14 +240,14 @@ int main() const auto hShared = HistoryStore::contentHash(shared); const auto hLater = HistoryStore::contentHash(later); const auto sighting = r.store.firstSeen(r.session, "sighting", r.now()); - r.store.snapshotSlot(sighting, 1, "body", { { 1, "original.wav", original } }, r.now()); - r.store.snapshotSlot(sighting, 2, "body", { { 1, "newer.wav", originalNew } }, r.now()); - r.store.snapshotSlot(sighting, 3, "body", { { 1, "shared.wav", shared } }, r.now()); + r.store.snapshotSlot(sighting, 1, "body", { { 1, "original.wav", original, 9001 } }, r.now()); + r.store.snapshotSlot(sighting, 2, "body", { { 1, "newer.wav", originalNew, 9002 } }, r.now()); + r.store.snapshotSlot(sighting, 3, "body", { { 1, "shared.wav", shared, 9003 } }, r.now()); r.store.finishOp(sighting, OpStatus::done, ""); r.replaced(4, later); const auto pushedAt = r.now(); const auto push = r.store.beginOp(r.session, "push", "push", pushedAt); - r.store.recordLanded(push, 27, 1, "pushed.wav", shared); // 'after' is a reference too + r.store.recordLanded(push, 27, 1, "pushed.wav", shared, pushedAt); // 'after' is a reference too r.store.finishOp(push, OpStatus::done, ""); r.renamed(9); // no take is needed by undo From 5577d4a1396678117da6ff99ce1bcf7980dd1f3d Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 00:51:55 +0200 Subject: [PATCH 02/10] Tell the journal what normalize measured, and file it under the bytes it measured normalize meters the take before deciding, and the reading went out with the result. A new optional journal hook beside audioWritten, loudnessMeasured, hands it out with the very bytes measured, once the command knows they are audio: before the archive, before any rewrite, and whether a write follows or not. A slot found at target and a slot refused as silent are both measurements of their bytes; bytes refused as damaged report no reading. The bytes a gain lands get no derived number - the next read measures them. The recorder files it through HistoryRecorder::reading, under the hash the archive names the original by when a write follows. A reading is a fact about bytes, not about an operation, so none needs to have begun - but it is written only while a card's session is open: with no card in front of it the history is not told about bytes it cannot place, and the store is never opened for a reading alone. Inside an operation the session is open, so the hook refuses loudly if it is not. --- app/history/HistoryRecorder.cpp | 20 ++++++ app/history/HistoryRecorder.h | 11 ++- core/include/loopercat/Commands.hpp | 15 ++++ tests/commands_tests.cpp | 79 ++++++++++++++++++++ tests/history_recorder_tests.cpp | 108 +++++++++++++++++++++++++++- 5 files changed, 231 insertions(+), 2 deletions(-) diff --git a/app/history/HistoryRecorder.cpp b/app/history/HistoryRecorder.cpp index 3646ea7..9d1de78 100644 --- a/app/history/HistoryRecorder.cpp +++ b/app/history/HistoryRecorder.cpp @@ -216,6 +216,14 @@ void HistoryRecorder::landed(const std::string& opId, int slot, const std::strin modifiedMs(volume::wavDir(op.volume, slot) / fileName)); } +bool HistoryRecorder::reading(const std::string& hash, const wav::LoudnessReading& reading) +{ + if (!session_) + return false; + store().recordReading(hash, reading, clock_()); + return true; +} + void HistoryRecorder::recordWhatSlotsHold(const Operation& op) { const std::vector slots = store().touchedSlots(op.row); @@ -291,6 +299,18 @@ commands::WriteOptions withHistory(const std::shared_ptr& recor options.journal.slotsChanging = [recorder, opId](const std::vector& slots) { recorder->preserveSlots(opId, slots); }; + // What the command measured, under the hash of the bytes it measured — + // the before-bytes, which the archive names by the same hash if a write + // follows. Inside an operation the card's session is open, so the + // reading always goes in; a store that cannot take it stops the command + // here, before the card is touched, like every other hook. + options.journal.loudnessMeasured = [recorder, opId](int slot, const std::string&, + std::string_view bytes, + const wav::LoudnessReading& reading) { + if (!recorder->reading(HistoryStore::contentHash(bytes), reading)) + throw Error("operation " + opId + " measured slot " + std::to_string(slot) + + " with no card session open to file the reading in"); + }; // The settings pair, before it is written: each changed section, its // text before and after (sysfile::sectionChanges, in the core). A throw // here stops the write with the card as it was. diff --git a/app/history/HistoryRecorder.h b/app/history/HistoryRecorder.h index f1cf6c2..9527066 100644 --- a/app/history/HistoryRecorder.h +++ b/app/history/HistoryRecorder.h @@ -67,6 +67,14 @@ class HistoryRecorder // entry now shows for the file (#141). void landed(const std::string& opId, int slot, const std::string& fileName, std::string_view bytes); + // A loudness reading of bytes the worker has in hand, filed under their + // content hash (#140) — a fact about bytes, not about an operation, so + // none needs to have begun. Written while a card's session is open and + // not otherwise: with no card in front of it the history is not told + // about bytes it cannot place, and the store is never opened for this + // alone. Returns whether the reading went in; the store's own refusals + // (a hash of the wrong length, a value that is not a number) throw. + bool reading(const std::string& hash, const wav::LoudnessReading& reading); // `error` empty = the job succeeded, and `note` is the line the job wrote // about itself ("normalized -3.2 dB", "already at -18.0 LUFS") — the only // record of an operation that decided to change nothing, and empty for the @@ -155,7 +163,8 @@ class HistoryRecorder // The one wiring from an operation's WriteOptions into the history, shared by // the app and its tests so the tests exercise what ships: each replaced take // is kept by the history before the card changes; bodies, landed takes and -// settings sections are recorded under the operation's id. +// settings sections are recorded under the operation's id; a reading the +// command took on its way is filed under the bytes it measured (#140). commands::WriteOptions withHistory(const std::shared_ptr& recorder, commands::WriteOptions options); diff --git a/core/include/loopercat/Commands.hpp b/core/include/loopercat/Commands.hpp index f418110..3084e8c 100644 --- a/core/include/loopercat/Commands.hpp +++ b/core/include/loopercat/Commands.hpp @@ -237,6 +237,16 @@ struct Journal { // After a take has landed on the card, with its bytes: the post-state's // audio, so the history never has to read it back over USB. std::function audioWritten; + // Optional. What a take measured, with the very bytes measured, from a + // command that meters on its way (normalize): a reading is a fact about + // those bytes, and the history files it under their hash (#140) — so it + // is reported while they are still the take's, before any archive or + // rewrite, and it is reported whether or not a write follows. Only a + // measurement is reported: bytes that are not audio are refused by the + // command instead, and the bytes a rewrite lands get no derived number. + std::function + loudnessMeasured; // Just before the settings pair is written, with every section this edit // changes — the pedal's own settings are undoable like a memory is // (sysfile::SectionChange carries the section's bytes either side). @@ -1002,6 +1012,11 @@ inline NormalizeResult normalize(const fs::path& volume, int slot, + std::to_string(reading.wildSamples) + " impossible sample value(s) — bytes that are not audio. The take looks " "damaged; re-push it from the original instead of normalizing it"); + // The bytes are audio: what they measure is a fact worth keeping whatever + // this command then does with it — writes a gain, finds nothing to do, + // or refuses below because nothing can be done for silence. + if (options.write.journal.loudnessMeasured) + options.write.journal.loudnessMeasured(slot, files.front(), raw, reading); if (!reading.integratedLufs.has_value()) throw Error("slot " + std::to_string(slot) + " is silent or shorter than the 400 ms a loudness measurement needs"); diff --git a/tests/commands_tests.cpp b/tests/commands_tests.cpp index ffc0d30..23f3cfc 100644 --- a/tests/commands_tests.cpp +++ b/tests/commands_tests.cpp @@ -2292,6 +2292,85 @@ int main() CHECK(!fs::exists(tmp.path / "archive")); // and no archive copy was spent } + // --- normalize tells the journal what it measured, of the bytes it measured (#140) --- + // + // Theory: the reading is of the take as the command found it — reported + // once, with those very bytes, before anything about the take changes, + // and whether or not a write follows. A take refused as not audio is no + // reading. A journal without the hook changes nothing about the command. + { + TempDir tmp; + const fs::path volume = makePedal(tmp.path); + putSineFloatWav(volume, 6, "quiet.wav", 44100, -28.0); + putSineFloatWav(volume, 7, "attarget.wav", 44100, -18.0); + putSineFloatWav(volume, 4, "faint.wav", 44100, -100.0); // under the -70 gate + putSineFloatWav(volume, 8, "damaged.wav", 44100, -23.0, 1.0e20f); // one impossible sample + struct Heard { + int slot; + std::string name; + std::string bytes; + wav::LoudnessReading reading; + }; + std::vector heard; + const auto listening = [&](commands::WriteOptions options) { + options.journal.loudnessMeasured = [&](int slot, const std::string& name, + std::string_view bytes, + const wav::LoudnessReading& reading) { + heard.push_back({ slot, name, std::string(bytes), reading }); + }; + return options; + }; + + // a gain is written: the journal heard the take BEFORE it, byte for byte + const std::string quiet = commands::readFileBytes(volume::wavDir(volume, 6) / "quiet.wav"); + const auto applied = commands::normalize( + volume, 6, { .targetLufs = -18.0, .write = listening(writeOpts(tmp.path)) }); + CHECK(applied.applied); + CHECK_EQ(heard.size(), 1u); + if (heard.size() == 1u) { + CHECK_EQ(heard[0].slot, 6); + CHECK_EQ(heard[0].name, std::string("quiet.wav")); + CHECK(heard[0].bytes == quiet); + CHECK(heard[0].bytes != commands::readFileBytes(volume::wavDir(volume, 6) / "quiet.wav")); + CHECK(heard[0].reading.integratedLufs.has_value() + && std::abs(*heard[0].reading.integratedLufs - (-28.0)) <= 0.1); + CHECK_EQ(heard[0].reading.wildSamples, 0); + } + + // nothing to write: measured all the same, reported once + heard.clear(); + const std::string atTargetBytes = + commands::readFileBytes(volume::wavDir(volume, 7) / "attarget.wav"); + const auto atTarget = commands::normalize( + volume, 7, { .targetLufs = -18.0, .write = listening(writeOpts(tmp.path)) }); + CHECK(!atTarget.applied); + CHECK_EQ(heard.size(), 1u); + CHECK(heard.size() == 1u && heard[0].slot == 7 && heard[0].bytes == atTargetBytes); + CHECK(heard.size() == 1u && heard[0].reading.integratedLufs.has_value() + && std::abs(*heard[0].reading.integratedLufs - (-18.0)) <= 0.1); + + // silence: the command refuses to act, and what it measured is still a + // measurement — "nothing to measure" is an answer about these bytes + heard.clear(); + CHECK_THROWS(commands::normalize(volume, 4, { .targetLufs = -18.0, + .write = listening(writeOpts(tmp.path)) }), + "silent or shorter"); + CHECK_EQ(heard.size(), 1u); + CHECK(heard.size() == 1u && !heard[0].reading.integratedLufs.has_value()); + + // bytes that are not audio: refused, and no reading is reported of them + heard.clear(); + CHECK_THROWS(commands::normalize(volume, 8, { .targetLufs = -18.0, + .write = listening(writeOpts(tmp.path)) }), + "impossible sample"); + CHECK(heard.empty()); + + // a journal without the hook: the command measures and writes as before + putSineFloatWav(volume, 9, "plain.wav", 44100, -28.0); + CHECK(commands::normalize(volume, 9, { .targetLufs = -18.0, .write = writeOpts(tmp.path) }) + .applied); + } + // --- a real card: a mutation changes what it exists to write, and no other byte --- // // fixtures/rc5-card.RC0 is a MEMORY1.RC0 off an RC-5 (fw 1.10): 41 loops in diff --git a/tests/history_recorder_tests.cpp b/tests/history_recorder_tests.cpp index bc88c6a..4e794fe 100644 --- a/tests/history_recorder_tests.cpp +++ b/tests/history_recorder_tests.cpp @@ -17,7 +17,9 @@ // - an op names the slot it is about once it has begun, and keeps it when // it then writes nothing (#144); maintenance is about no slot // - every take row carries the stamp the card's directory entry showed -// (#141) +// (#141), and what normalize measured is in the history under the bytes +// it measured — written or not (#140); a reading has nowhere to go while +// no card is in front of the history #include "support.hpp" @@ -26,12 +28,16 @@ #include +#include #include +#include #include #include #include #include +#include #include +#include using namespace loopercat; using history::HistoryRecorder; @@ -73,6 +79,43 @@ void putWav(const fs::path& volume, int slot, const std::string& name, int frame bytes.size())); } +// A float32 stereo take of a 997 Hz sine at `dbfs` peak in both channels — +// the tone BS.1770 calibrates on, so a -28 dBFS take reads -28 LUFS — which +// is what a measurement has to read off before anything can be said about it. +void putSineWav(const fs::path& volume, int slot, const std::string& name, int frames, double dbfs) +{ + std::vector b; + const auto ascii = [&b](std::string_view t) { + for (const char c : t) + b.push_back(static_cast(c)); + }; + const auto p16 = [&b](int v) { + b.push_back(static_cast(v & 0xff)); + b.push_back(static_cast((v >> 8) & 0xff)); + }; + const auto p32 = [&p16](int v) { p16(v & 0xffff); p16((v >> 16) & 0xffff); }; + const auto sample = [&b](float value) { + const auto bits = std::bit_cast(value); + for (int shift = 0; shift < 32; shift += 8) + b.push_back(static_cast((bits >> shift) & 0xffu)); + }; + const int dataSize = frames * 8; + ascii("RIFF"); p32(12 + 24 + 8 + dataSize - 8); ascii("WAVE"); + ascii("fmt "); p32(16); + p16(3); p16(2); p32(wav::kSampleRate); p32(wav::kSampleRate * 8); p16(8); p16(32); + ascii("data"); p32(dataSize); + const double amp = std::pow(10.0, dbfs / 20.0); + const double w = 2.0 * std::numbers::pi * 997.0 / wav::kSampleRate; + for (int frame = 0; frame < frames; ++frame) { + const auto v = static_cast(amp * std::sin(w * frame)); + sample(v); + sample(v); + } + fs::create_directories(volume::wavDir(volume, slot)); + commands::writeFileBytes(volume::wavDir(volume, slot) / name, + std::string_view(reinterpret_cast(b.data()), b.size())); +} + // A file's modification time as the OS reports it, ms since the epoch — the // oracle the rows are compared against. std::int64_t fileStamp(const fs::path& file) @@ -643,6 +686,69 @@ int main() CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio WHERE side = 'after'"), 3); // snapshot, rename, trim } + // --- what normalize measured is in the history, under the bytes it measured (#140) --- + // + // Theory: the reading is of the take as the command found it, so it is + // filed under the hash of those bytes — the same hash the archive names + // when a write follows — and it is filed whether a write follows or not. + // The bytes a gain lands get no derived number: nobody measured them. And + // a reading has nowhere to go while no card is in front of the history. + { + TempDir tmp; + const fs::path volume = makePedal(tmp.path); + putSineWav(volume, 6, "take.wav", 44100, -28.0); + const fs::path takeFile = volume::wavDir(volume, 6) / "take.wav"; + const std::string original = commands::readFileBytes(takeFile); + auto rec = recorderAt(tmp.path / "history"); + // nothing in front of the history yet: the reading is not taken, and the store is not even opened for it + CHECK(!rec->reading(HistoryStore::contentHash(original), { -28.0, 0.04f, -28.0, 0 })); + CHECK(!fs::exists(tmp.path / "history" / "history.db")); + + CHECK_EQ(run(*rec, "op-norm", "normalize", volume, [&] { + commands::normalize(volume, 6, { .targetLufs = -18.0, .write = options(rec, "op-norm") }); + }), + std::string()); + sqlite::Db& db = rec->store().db(); + const auto before = rec->store().readingFor(HistoryStore::contentHash(original)); + CHECK(before.has_value()); + CHECK(before.has_value() && before->reading.integratedLufs.has_value() + && std::abs(*before->reading.integratedLufs - (-28.0)) <= 0.1); + CHECK(before.has_value() && before->reading.wildSamples == 0); + CHECK(before.has_value() && before->measuredMs > 1'000'000); // the recorder's clock, not a zero + // the same hash the archive filed the original under + CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio a JOIN ops o ON o.seq = a.op " + "WHERE o.id = 'op-norm' AND a.side = 'before' AND a.hash = (SELECT hash FROM loudness_readings)"), + 1); + // the bytes the gain landed have no number of their own + const std::string landed = commands::readFileBytes(takeFile); + CHECK(landed != original); + CHECK(!rec->store().readingFor(HistoryStore::contentHash(landed)).has_value()); + + // the slot is at target now: the second normalize writes nothing, + // archives nothing, has nothing to say about the slot (#144 names it + // as a subject in the app) — and still files what it measured + CHECK_EQ(run(*rec, "op-again", "normalize", volume, [&] { + commands::normalize(volume, 6, { .targetLufs = -18.0, .write = options(rec, "op-again") }); + }), + std::string()); + CHECK_EQ(text(db, "SELECT status FROM ops WHERE id = 'op-again'"), std::string("done")); + CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio a JOIN ops o ON o.seq = a.op WHERE o.id = 'op-again'"), 0); + CHECK_EQ(count(db, "SELECT count(*) FROM slot_changes c JOIN ops o ON o.seq = c.op WHERE o.id = 'op-again'"), 0); + const auto now = rec->store().readingFor(HistoryStore::contentHash(landed)); + CHECK(now.has_value()); + CHECK(now.has_value() && now->reading.integratedLufs.has_value() + && std::abs(*now->reading.integratedLufs - (-18.0)) <= 0.1); + CHECK_EQ(count(db, "SELECT count(*) FROM loudness_readings"), 2); + + // the card goes away: a reading has nowhere to go again, and nothing is filed + rec->disconnect(); + CHECK(!rec->reading(HistoryStore::contentHash("later"), { -20.0, 0.5f, -3.0, 0 })); + CHECK_EQ(count(db, "SELECT count(*) FROM loudness_readings"), 2); + // and the store's refusals come through, a session or not + rec->selectVolume(volume); + CHECK_THROWS(rec->reading("short", { -20.0, 0.5f, -3.0, 0 }), "32 bytes"); + } + // --- the wiring refuses to be built without what it needs --- { TempDir tmp; From 4a0f7cb190be87acc2aaca237e326834264b1db1 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 00:51:55 +0200 Subject: [PATCH 03/10] File every loudness read in the history: Check loudness, the background check and the player's pass The Check-slot job and the background check read the take's bytes anyway; readSlotLoudness measures them and files the reading under their hash before the words go to the screen. The filing is the read's tail, not its condition: with no card in front of the history nothing is filed, and a store that refuses is a line in operations.log while the reading still lands in the column. The player asked how loud the loop is, and that answer does not depend on the history being able to remember it. The player's pass metered the loaded loop while drawing its waveform. It now reads the file into memory in slices and decodes from there, so the hash is of every byte the meter saw and the card is still read once; stop() is heard between slices, since one read of a long take over USB would outlast its patience. The hash rides up with the reading and crosses to the worker - the store's thread - as a quiet job that needs no card. A lane that is only drawn, in a multi-track memory, streams from the file as before. The import path is left as it is: it meters only with normalize-on- upload on, and then usually applies a gain. Proving the gain-free stream byte-identical to what push lands means auditing the resampler at ratio 1.0, and the next read of the slot measures the landed bytes themselves. --- CMakeLists.txt | 1 + app/MainComponent.cpp | 53 +++++++++++++++++++++------ app/MainComponent.h | 3 ++ app/PlayerPane.cpp | 61 ++++++++++++++++++++++++++------ app/PlayerPane.h | 32 +++++++++++++---- app/history/SlotLoudness.h | 59 ++++++++++++++++++++++++++++++ tests/history_recorder_tests.cpp | 48 +++++++++++++++++++++++++ tests/player_pane_harness.cpp | 25 +++++++++---- 8 files changed, 248 insertions(+), 34 deletions(-) create mode 100644 app/history/SlotLoudness.h diff --git a/CMakeLists.txt b/CMakeLists.txt index e38d895..5bbe987 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -648,6 +648,7 @@ target_link_libraries(player_pane_harness PRIVATE felitronics::appkit juce::juce_gui_basics juce::juce_audio_utils + juce::juce_cryptography # the read pass hashes what it meters (#140) juce::juce_recommended_config_flags juce::juce_recommended_warning_flags) add_test(NAME player_pane_harness COMMAND player_pane_harness) diff --git a/app/MainComponent.cpp b/app/MainComponent.cpp index 33491f4..fc48307 100644 --- a/app/MainComponent.cpp +++ b/app/MainComponent.cpp @@ -10,6 +10,7 @@ #include "history/HistoryRecorder.h" #include "history/ForgetSlotJob.h" #include "ClearSlotHistoryAction.h" +#include "history/SlotLoudness.h" #include "history/SlotRows.h" #include "OperationsLog.h" #include "PedalPortName.h" @@ -361,9 +362,13 @@ MainComponent::MainComponent(std::string explicitVolume, juce::File dataOverride editPlayStop(slot, std::move(edits)); }; table.onLoudnessCellDoubleClicked = [this](int slot) { measureSlotLoudness(slot); }; - // The player read the file for its waveform anyway; the meter rode along. - player.onLoudnessRead = [this](int slot, const wav::LoudnessReading& reading) { + // The player read the file for its waveform anyway; the meter rode along, + // and so did the hash of what it read — the history keeps the number + // under it (#140), after the words are on screen. + player.onLoudnessRead = [this](int slot, const wav::LoudnessReading& reading, + const std::string& hash) { applyLoudnessReport(slot, describeReading(reading, currentTargetLufs()), 0); + keepReading(hash, reading); }; player.onNormalize = [this](int slot) { if (const SlotRow* row = pedalBusy ? nullptr : slotRowFor(slot)) @@ -2406,15 +2411,17 @@ void MainComponent::enqueueLoudnessRead(int slot, double target, int batch) worker.enqueue( { "Check slot " + juce::String(slot) + " loudness", slot, - [slot, target, batch, note, safe](const volume::fs::path& volumePath) { - const std::vector files = volume::listSlotWavs(volumePath, slot); - if (files.empty()) - throw Error("slot " + std::to_string(slot) + " has no audio to measure"); - const std::string raw = - commands::readFileBytes(volume::wavDir(volumePath, slot) / files.front()); - const wav::LoudnessReading reading = wav::measureLoudness(wav::BytesView( - reinterpret_cast(raw.data()), raw.size())); - const LoudnessReport report = describeReading(reading, target); + [slot, target, batch, note, safe, rec = recorder, + logDir = settings.dataDir()](const volume::fs::path& volumePath) { + // The bytes are read once: measured, and filed in the history + // under their hash (#140). A history that would not take the + // reading is a line in the log, not this read's failure. + const history::SlotLoudness read = history::readSlotLoudness(volumePath, slot, *rec); + if (!read.failure.empty()) + oplog::append(logDir, "slot " + juce::String(slot) + + " loudness reading not kept in the history: " + + juce::String::fromUTF8(read.failure.c_str())); + const LoudnessReport report = describeReading(read.reading, target); *note = report.noteText; juce::MessageManager::callAsync([safe, slot, report, batch] { if (safe != nullptr) @@ -2424,6 +2431,30 @@ void MainComponent::enqueueLoudnessRead(int slot, double target, int batch) note, batch, /*background=*/true }); } +// The player's pass metered the loaded loop on its own thread; the history +// is the worker's, so the number crosses over as a job of its own — one that +// reads no card and tells nobody anything: the words are already on screen, +// and a history with no card in front of it simply takes nothing. +void MainComponent::keepReading(std::string hash, wav::LoudnessReading reading) +{ + worker.enqueue({ "Keep a loudness reading", + 0, + [rec = recorder, key = std::move(hash), reading, + logDir = settings.dataDir()](const volume::fs::path&) { + try { + rec->reading(key, reading); + } catch (const Error& e) { + oplog::append(logDir, "loudness reading not kept in the history: " + + juce::String::fromUTF8(e.what())); + } + }, + nullptr, + 0, + true, // background: the player did not ask to wait for it + true, // quiet: it has nothing to say, and it does not fail + false }); // and it needs no card: the store is on this computer +} + void MainComponent::applyLoudnessReport(int slot, const LoudnessReport& report, int batch) { table.setLoudness(slot, { report.cellText, report.attention, false, report.rowText, diff --git a/app/MainComponent.h b/app/MainComponent.h index 6a307bc..3e4c03c 100644 --- a/app/MainComponent.h +++ b/app/MainComponent.h @@ -303,6 +303,9 @@ class MainComponent final : public juce::Component, }; static LoudnessReport describeReading(const wav::LoudnessReading& reading, double targetLufs); void enqueueLoudnessRead(int slot, double target, int batch); + // A reading the player's own pass took, into the history under the hash + // of the bytes it metered (#140) — on the worker, the store's thread. + void keepReading(std::string hash, wav::LoudnessReading reading); void applyLoudnessReport(int slot, const LoudnessReport& report, int batch); void measureSlotLoudness(int slot); void startLoudnessCheck(const std::vector& slots); diff --git a/app/PlayerPane.cpp b/app/PlayerPane.cpp index 1aed553..49c4971 100644 --- a/app/PlayerPane.cpp +++ b/app/PlayerPane.cpp @@ -3,10 +3,13 @@ #include "PlayerPane.h" +#include "history/ContentHash.h" + #include #include #include +#include namespace loopercat { @@ -18,6 +21,7 @@ namespace const juce::Colour kDim { 0xff63636d }; constexpr int kTransportRowHeight = 30; constexpr int kReadBlock = 32768; // frames per read-pass block: ~0.7 s, a fine stop grain + constexpr int kReadSlice = 1 << 20; // bytes per slice of a whole-file read: ~3 s of float32 stereo // The transport row's right side, outside in: the time readout ("12:34 / // 12:34" at 13 px plus its 44 px right margin), then the operation zone // that [Reset][Trim] and [Measure][Normalize…] take turns in. Any @@ -329,31 +333,67 @@ void PlayerPane::ReadPass::start(std::vector files, int slot) startThread(); } +bool PlayerPane::ReadPass::readWhole(const juce::File& file, juce::MemoryBlock& out) +{ + juce::FileInputStream in(file); + if (!in.openedOk()) + return false; + const juce::int64 total = in.getTotalLength(); + if (total < 0) + return false; + out.setSize(static_cast(total), false); + juce::int64 done = 0; + while (done < total && !threadShouldExit()) { + const int n = static_cast(std::min(kReadSlice, total - done)); + const int got = in.read(static_cast(out.getData()) + done, n); + if (got <= 0) + return false; + done += got; + } + return done == total; +} + void PlayerPane::ReadPass::run() { const int slot = slot_; juce::Component::SafePointer owner(&owner_); - const auto finish = [owner, slot](std::optional reading) { - juce::MessageManager::callAsync([owner, slot, reading] { + const auto finish = [owner, slot](std::optional reading, + std::string hash) { + juce::MessageManager::callAsync([owner, slot, reading, key = std::move(hash)] { if (owner != nullptr) - owner->passFinished(slot, reading); + owner->passFinished(slot, reading, key); }); }; // The meter measures one loop; a multi-track memory draws its lanes and // reports no reading. const bool measure = files_.size() == 1; std::optional reading; + std::string hash; // of the bytes metered: the key the reading is filed under (#140) for (std::size_t lane = 0; lane < files_.size() && lane < 2; ++lane) { juce::AudioThumbnail& thumbnail = owner_.thumbnailFor(static_cast(lane)); if (files_[lane] == juce::File()) { thumbnail.reset(2, 44100.0, 0); // a track without a take: an empty lane, drawn continue; } - std::unique_ptr reader( - owner_.engine_.formats().createReaderFor(files_[lane])); + // Declared before the reader: a reader over the bytes must go first. + juce::MemoryBlock bytes; + std::unique_ptr reader; + if (measure) { + if (!readWhole(files_[lane], bytes)) { + if (!threadShouldExit()) + finish(std::nullopt, {}); // a short read is not a reading + return; + } + hash = history::contentHash( + std::string_view(static_cast(bytes.getData()), bytes.getSize())); + reader.reset(owner_.engine_.formats().createReaderFor( + std::make_unique(bytes.getData(), bytes.getSize(), false))); + } else { + reader.reset(owner_.engine_.formats().createReaderFor(files_[lane])); + } if (reader == nullptr || reader->numChannels < 1) { if (measure) - finish(std::nullopt); + finish(std::nullopt, {}); return; } const int channels = static_cast(reader->numChannels); @@ -401,7 +441,7 @@ void PlayerPane::ReadPass::run() return; // stopped for a newer file: no verdict, and no message if (measure) { if (position < total || !metered) { - finish(std::nullopt); // a short read is not a reading + finish(std::nullopt, {}); // a short read is not a reading return; } reading = wav::LoudnessReading { meter->integratedLufs(), meter->samplePeak(), @@ -409,10 +449,11 @@ void PlayerPane::ReadPass::run() } } if (measure) - finish(reading); + finish(reading, hash); } -void PlayerPane::passFinished(int slot, std::optional reading) +void PlayerPane::passFinished(int slot, std::optional reading, + std::string contentHash) { if (slot != slot_) return; // the pass for a slot no longer loaded @@ -424,7 +465,7 @@ void PlayerPane::passFinished(int slot, std::optional read return; } if (onLoudnessRead) - onLoudnessRead(slot, *reading); // the owner answers with setLoudness + onLoudnessRead(slot, *reading, contentHash); // the owner answers with setLoudness } // The readout sits right after the name and takes what is left of the row diff --git a/app/PlayerPane.h b/app/PlayerPane.h index 57ddd34..23a334d 100644 --- a/app/PlayerPane.h +++ b/app/PlayerPane.h @@ -14,6 +14,7 @@ #include #include +#include #include //============================================================================== @@ -98,13 +99,17 @@ class PlayerPane final : public juce::Component, // loads in ONE background pass that feeds both the waveform and a // BS.1770 meter, and hands the reading up through onLoudnessRead the // moment the waveform is complete — no second read off the card, no - // button. The owner turns it into words (target, damage) and feeds them - // back through setLoudness; a read-only check running elsewhere lands - // the same way. The readout and Normalize… share the row with Trim and - // step aside while a trim selection is active: the selection owns the - // row then, and Normalize is whole-loop work that must not read as "the + // button. With it goes the content hash of the bytes it metered + // (history::contentHash): a reading is a fact about those bytes, and + // the owner files it in the history under that key (#140). The owner + // turns it into words (target, damage) and feeds them back through + // setLoudness; a read-only check running elsewhere lands the same way. + // The readout and Normalize… share the row with Trim and step aside + // while a trim selection is active: the selection owns the row then, + // and Normalize is whole-loop work that must not read as "the // selection". All setters ignore a slot that is not the loaded one. - std::function onLoudnessRead; + std::function + onLoudnessRead; void setLoudness(int slot, const juce::String& text, bool attention, bool damaged, const juce::String& tooltip); void setLoudnessPending(int slot); @@ -147,12 +152,19 @@ class PlayerPane final : public juce::Component, void updateTransportRow(); void updateLoudnessButtons(); void layoutReadout(); - void passFinished(int slot, std::optional reading); // message thread + void passFinished(int slot, std::optional reading, + std::string contentHash); // message thread // The one read pass over a loaded file: waveform blocks into the // thumbnail, samples into the meter, then the reading up to the pane. A // new load or a clear stops it between blocks — browsing fast through // slots must not queue a card's worth of reads. + // + // A file that is metered is read into memory whole, in slices, and + // decoded from there: the reading is filed under the hash of its bytes + // (#140), a hash is of all of them, and the card is still read once. A + // lane that is only drawn (a multi-track memory) streams from the file + // as before. class ReadPass final : public juce::Thread { public: @@ -166,6 +178,12 @@ class PlayerPane final : public juce::Component, void run() override; private: + // The whole file into `out`, a slice at a time so stop() is heard + // between two of them: one read of a long take over USB would hold + // the thread past stop()'s patience. False when the file could not + // be read to its end — or when the pass was told to stop. + bool readWhole(const juce::File& file, juce::MemoryBlock& out); + PlayerPane& owner_; std::vector files_; int slot_ = 0; diff --git a/app/history/SlotLoudness.h b/app/history/SlotLoudness.h new file mode 100644 index 0000000..dec47b3 --- /dev/null +++ b/app/history/SlotLoudness.h @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE. +// SPDX-License-Identifier: AGPL-3.0-or-later + +#pragma once + +#include "HistoryRecorder.h" + +#include +#include +#include + +#include + +//============================================================================== +// loopercat::history::readSlotLoudness — one slot's loudness read as the +// worker runs it, for Check loudness and the background check (issue #61): +// the take's bytes come off the card once and are measured, and the reading +// is filed in the history under the hash of those very bytes (#140) before +// the caller turns it into words. +// +// The read is the job; the filing is its tail. A history with no card in +// front of it takes nothing (`kept` false, no failure), and a store that +// refuses the row is reported in `failure` with the reading still returned — +// the player asked how loud the loop is, and that answer does not depend on +// the history being able to remember it. A slot with no take, and bytes that +// are not the pedal's own stereo float, are errors as before. +// +// Worker thread, like every read of the card and every write to the store. +//============================================================================== +namespace loopercat::history +{ + +struct SlotLoudness { + wav::LoudnessReading reading; + std::string hash; // of the bytes measured — the key the reading is filed under + bool kept = false; // the history holds the reading now + std::string failure; // the store's refusal, when it refused; empty otherwise +}; + +inline SlotLoudness readSlotLoudness(const volume::fs::path& volume, int slot, + HistoryRecorder& recorder) +{ + const std::vector files = volume::listSlotWavs(volume, slot); + if (files.empty()) + throw Error("slot " + std::to_string(slot) + " has no audio to measure"); + const std::string raw = commands::readFileBytes(volume::wavDir(volume, slot) / files.front()); + SlotLoudness out; + out.reading = wav::measureLoudness( + wav::BytesView(reinterpret_cast(raw.data()), raw.size())); + out.hash = HistoryStore::contentHash(raw); + try { + out.kept = recorder.reading(out.hash, out.reading); + } catch (const Error& e) { + out.failure = e.what(); + } + return out; +} + +} // namespace loopercat::history diff --git a/tests/history_recorder_tests.cpp b/tests/history_recorder_tests.cpp index 4e794fe..525d156 100644 --- a/tests/history_recorder_tests.cpp +++ b/tests/history_recorder_tests.cpp @@ -20,9 +20,12 @@ // (#141), and what normalize measured is in the history under the bytes // it measured — written or not (#140); a reading has nowhere to go while // no card is in front of the history +// - a loudness check files what it read under the hash of the file read, +// and still answers when the history will not take it #include "support.hpp" +#include "../app/history/SlotLoudness.h" #include "../app/history/WriteOptionsFactory.h" #include "../app/OperationsLog.h" @@ -749,6 +752,51 @@ int main() CHECK_THROWS(rec->reading("short", { -20.0, 0.5f, -3.0, 0 }), "32 bytes"); } + // --- a loudness check files what it read, and answers even when the history will not (#140) --- + // + // Theory: the job reads the take once, measures it, and files the reading + // under the hash of exactly the bytes it read. The answer does not depend + // on the filing: no card in front of the history, nothing filed and no + // failure; a store that refuses, the refusal reported and the reading + // still returned. A slot with no take is an error, as before. + { + TempDir tmp; + const fs::path volume = makePedal(tmp.path); + putSineWav(volume, 9, "009_1.WAV", 44100, -23.0); + const std::string bytes = commands::readFileBytes(volume::wavDir(volume, 9) / "009_1.WAV"); + auto rec = recorderAt(tmp.path / "history"); + + const auto cold = history::readSlotLoudness(volume, 9, *rec); + CHECK(cold.hash == HistoryStore::contentHash(bytes)); + CHECK(cold.reading.integratedLufs.has_value() + && std::abs(*cold.reading.integratedLufs - (-23.0)) <= 0.1); + CHECK(!cold.kept); + CHECK(cold.failure.empty()); + CHECK(!fs::exists(tmp.path / "history" / "history.db")); // not even opened for it + + rec->selectVolume(volume); // the card is in front of the history now + const auto warm = history::readSlotLoudness(volume, 9, *rec); + CHECK(warm.kept); + CHECK(warm.failure.empty()); + const auto stored = rec->store().readingFor(HistoryStore::contentHash(bytes)); + CHECK(stored.has_value()); + CHECK(stored.has_value() && stored->reading.integratedLufs.has_value() + && warm.reading.integratedLufs.has_value() + && std::abs(*stored->reading.integratedLufs - *warm.reading.integratedLufs) <= 1.0e-12); + CHECK(stored.has_value() && stored->reading.wildSamples == 0); + CHECK_EQ(count(rec->store().db(), "SELECT count(*) FROM loudness_readings"), 1); + + // the history cannot take it: the answer still comes, with the refusal beside it + rec->store().db().exec("DROP TABLE loudness_readings"); + const auto broken = history::readSlotLoudness(volume, 9, *rec); + CHECK(broken.reading.integratedLufs.has_value()); + CHECK(broken.hash == HistoryStore::contentHash(bytes)); + CHECK(!broken.kept); + CHECK(!broken.failure.empty()); + + CHECK_THROWS(history::readSlotLoudness(volume, 10, *rec), "no audio to measure"); + } + // --- the wiring refuses to be built without what it needs --- { TempDir tmp; diff --git a/tests/player_pane_harness.cpp b/tests/player_pane_harness.cpp index 2539a39..834e65f 100644 --- a/tests/player_pane_harness.cpp +++ b/tests/player_pane_harness.cpp @@ -21,11 +21,14 @@ #include "../app/CardPermissions.h" #include "../app/PlayerPane.h" +#include "../app/history/ContentHash.h" #include #include #include +#include +#include using namespace loopercat; @@ -77,14 +80,18 @@ int main() const juce::File wavFile = juce::File::getSpecialLocation(juce::File::tempDirectory) .getChildFile("loopercat_player_pane_harness.wav"); + testkit::WavSpec spec; + spec.frames = kFrames; + const auto wavBytes = testkit::syntheticWav(spec); { - testkit::WavSpec spec; - spec.frames = kFrames; - const auto bytes = testkit::syntheticWav(spec); std::ofstream out(wavFile.getFullPathName().toStdString(), std::ios::binary); - out.write(reinterpret_cast(bytes.data()), - static_cast(bytes.size())); + out.write(reinterpret_cast(wavBytes.data()), + static_cast(wavBytes.size())); } + // What the history would file the reading under: the hash of the file's + // bytes, every one of them, not of what a decoder chose to read. + const std::string wavHash = history::contentHash( + std::string_view(reinterpret_cast(wavBytes.data()), wavBytes.size())); AudioEngine engine; PlayerPane pane(engine); @@ -105,16 +112,22 @@ int main() pane.onTrim = [&](int slot, juce::int64 in, juce::int64 out) { trimmed = Trimmed { slot, in, out }; }; // The owner's half of the loudness round trip: the reading comes back as // words, and the button is enabled again. - pane.onLoudnessRead = [&](int slot, const wav::LoudnessReading&) { + // The hash rides along with the reading (#140): the one the history keys + // takes by, of exactly the bytes in the file the pane was handed. + std::string readHash; + pane.onLoudnessRead = [&](int slot, const wav::LoudnessReading&, const std::string& hash) { + readHash = hash; pane.setLoudness(slot, "-14.0 LUFS", false, false, ""); }; const auto load = [&] { + readHash.clear(); pane.setSlot(1, wavFile, "01 Loop", false, kFrames); CHECK(engine.hasSource()); CHECK(pane.currentPath() == wavFile.getFullPathName()); settle(pane); CHECK(normalize->isEnabled()); // so a dropped press below is the card's doing + CHECK(readHash == wavHash); // the reading came up keyed by the file's own bytes }; // --- a pane never told what it may do offers nothing --- From 84ebd818ee3d36d402429d9792211c92e9904eaf Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 01:37:40 +0200 Subject: [PATCH 04/10] Read a WAV header off a probe of the file, with the file's size as the bound A reader that wants to know whether the core would accept a file - the player's read pass, before it files a loudness reading under the file's hash (#140) - should not have to read the whole file to ask. readWavInfo gains an overload over the file's first bytes and its whole size: the same chunk walk, the same refusals, and the core's truncation check against the real size, which is the one lie a probe alone cannot see. A chunk header or a fmt body that lies past the probe is a refusal with its own words, never a guess. readWavInfo(data) is this with the whole file as the probe, and reads exactly as it did. --- core/include/loopercat/Wav.hpp | 47 +++++++++++++++++++++++++--------- tests/wav_tests.cpp | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/core/include/loopercat/Wav.hpp b/core/include/loopercat/Wav.hpp index 9d6f6b1..4b793c9 100644 --- a/core/include/loopercat/Wav.hpp +++ b/core/include/loopercat/Wav.hpp @@ -85,9 +85,20 @@ namespace detail { } // namespace detail -inline Info readWavInfo(BytesView data) +// The header of a file whose first bytes are `head` and whose whole size is +// `fileSize`: the same chunk walk and the same refusals as over the whole +// file, for a reader that must know whether this is a take the core would +// accept without reading all of it (#140). Every chunk header and the fmt +// body must lie inside `head`; a file whose chunk headers run past the probe +// is refused as unreadable from it, never guessed at. readWavInfo(data) is +// this with the whole file as the head. +inline Info readWavInfo(BytesView head, std::int64_t fileSize) { - if (data.size() < 44 || !detail::chunkIdIs(data, 0, "RIFF") || !detail::chunkIdIs(data, 8, "WAVE")) + if (fileSize < static_cast(head.size())) + throw Error("header probe of " + std::to_string(head.size()) + + " bytes is longer than the file it is said to begin, " + + std::to_string(fileSize) + " bytes"); + if (head.size() < 44 || !detail::chunkIdIs(head, 0, "RIFF") || !detail::chunkIdIs(head, 8, "WAVE")) throw Error("not a RIFF/WAVE file"); bool haveFmt = false; @@ -95,31 +106,38 @@ inline Info readWavInfo(BytesView data) std::int64_t sampleRate = 0; std::int64_t dataBytes = -1; std::int64_t offset = 12; - const auto size = static_cast(data.size()); + const std::int64_t size = fileSize; + const auto probe = static_cast(head.size()); while (offset + 8 <= size) { + if (offset + 8 > probe) + throw Error("header probe of " + std::to_string(probe) + + " bytes ends before the chunk at offset " + std::to_string(offset)); const auto o = static_cast(offset); - const std::int64_t chunkSize = detail::u32(data, o + 4); - // Every chunk body must lie inside the buffer BEFORE anything reads + const std::int64_t chunkSize = detail::u32(head, o + 4); + // Every chunk body must lie inside the file BEFORE anything reads // from it: a crafted or truncated size field must become an explicit // error, never an out-of-bounds read. if (offset + 8 + chunkSize > size) throw Error("truncated file: chunk at offset " + std::to_string(offset) + " claims " + std::to_string(chunkSize) + " bytes, file has " + std::to_string(size - offset - 8) + " left"); - if (detail::chunkIdIs(data, o, "fmt ")) { + if (detail::chunkIdIs(head, o, "fmt ")) { // One fmt, one data — a file with duplicates is ambiguous (which // one would the pedal index?), so it is refused, not guessed at. if (haveFmt) throw Error("malformed file: more than one fmt chunk"); if (chunkSize < 16) throw Error("malformed fmt chunk"); - fmtTag = static_cast(detail::u16(data, o + 8)); - channels = static_cast(detail::u16(data, o + 10)); - sampleRate = detail::u32(data, o + 12); - blockAlign = static_cast(detail::u16(data, o + 20)); - bitsPerSample = static_cast(detail::u16(data, o + 22)); + if (offset + 8 + 16 > probe) + throw Error("header probe of " + std::to_string(probe) + + " bytes ends inside the fmt chunk at offset " + std::to_string(offset)); + fmtTag = static_cast(detail::u16(head, o + 8)); + channels = static_cast(detail::u16(head, o + 10)); + sampleRate = detail::u32(head, o + 12); + blockAlign = static_cast(detail::u16(head, o + 20)); + bitsPerSample = static_cast(detail::u16(head, o + 22)); haveFmt = true; - } else if (detail::chunkIdIs(data, o, "data")) { + } else if (detail::chunkIdIs(head, o, "data")) { if (dataBytes >= 0) throw Error("malformed file: more than one data chunk"); dataBytes = chunkSize; @@ -147,6 +165,11 @@ inline Info readWavInfo(BytesView data) dataBytes / blockAlign, dataBytes }; } +inline Info readWavInfo(BytesView data) +{ + return readWavInfo(data, static_cast(data.size())); +} + // The canonical rewrite of a frame range [startFrame, endFrame): RIFF + fmt + // data, nothing else, holding exactly that slice of the audio. The full-file // canonicalize() below is the [0, frames) case; trim uses a real sub-range. diff --git a/tests/wav_tests.cpp b/tests/wav_tests.cpp index 27cd8c6..05d4cea 100644 --- a/tests/wav_tests.cpp +++ b/tests/wav_tests.cpp @@ -324,5 +324,50 @@ int main() CHECK_THROWS(wav::trackFileName("x.wav", 3, 2), "track out of range"); CHECK_THROWS(wav::trackFileName("x.wav", 2, 1), "track out of range"); + // --- the header off a probe, with the file's size as the bound (#140) --- + // + // Theory: a reader that knows a file's first bytes and its whole size can + // ask the same questions of the header the whole-file parse asks, and + // must get the same answers — including the refusal of a file shorter + // than its header claims, which is the one lie a probe alone cannot see. + // What the probe does not reach, it must say it does not reach. + { + const auto whole = syntheticWav({ .tag = 3, .bits = 32, .frames = 44100 }); + const auto size = static_cast(whole.size()); + const wav::BytesView header(whole.data(), 44); // RIFF + fmt(16) + the data chunk's header + CHECK(wav::readWavInfo(header, size) == wav::readWavInfo(whole)); + CHECK(wav::readWavInfo(wav::BytesView(whole.data(), 4096), size) == wav::readWavInfo(whole)); + CHECK(wav::readWavInfo(whole, size) == wav::readWavInfo(whole)); + // shorter than the header claims: by one byte, by 70 % — the core's + // truncation check, against the real size + CHECK_THROWS(wav::readWavInfo(header, size - 1), "truncated"); + CHECK_THROWS(wav::readWavInfo(header, 44 + 13230 * 8), "truncated"); // 0.3 s present, 1 s claimed + CHECK_THROWS(wav::readWavInfo(header, 44), "truncated"); + // a probe longer than the file is a lie about the file + CHECK_THROWS(wav::readWavInfo(wav::BytesView(whole.data(), 100), 44), "longer than the file"); + // a chunk header past the probe: the probe cannot answer, and says so + const auto listed = syntheticWav({ .tag = 3, .bits = 32, .frames = 100, .extraChunk = true }); + CHECK_THROWS(wav::readWavInfo(wav::BytesView(listed.data(), 44), + static_cast(listed.size())), + "ends before the chunk at offset 70"); + CHECK(wav::readWavInfo(wav::BytesView(listed.data(), 78), static_cast(listed.size())) + == wav::readWavInfo(listed)); + // a fmt body cut by the probe: the same + std::vector fmt; + const auto p16 = [&fmt](int v) { + fmt.push_back(static_cast(v & 0xff)); + fmt.push_back(static_cast((v >> 8) & 0xff)); + }; + const auto p32 = [&p16](int v) { p16(v & 0xffff); p16((v >> 16) & 0xffff); }; + p16(3); p16(2); p32(44100); p32(44100 * 8); p16(8); p16(32); + const std::vector pad(24, 0), data(800, 0); + const auto junkFirst = rawRiff({ { "JUNK", 24, pad }, { "fmt ", 16, fmt }, { "data", 800, data } }); + const auto junkSize = static_cast(junkFirst.size()); + CHECK_THROWS(wav::readWavInfo(wav::BytesView(junkFirst.data(), 60), junkSize), "ends inside the fmt"); + CHECK_THROWS(wav::readWavInfo(wav::BytesView(junkFirst.data(), 50), junkSize), "ends before the chunk at offset 44"); + CHECK(wav::readWavInfo(wav::BytesView(junkFirst.data(), 76), junkSize) == wav::readWavInfo(junkFirst)); + CHECK_EQ(wav::readWavInfo(wav::BytesView(junkFirst.data(), 76), junkSize).frames, 100); + } + return testkit::summary("wav"); } From 939953b7a5189c6743c02c7946a23d97a12f2091 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 01:37:40 +0200 Subject: [PATCH 05/10] File the player's reading only when it is the core's reading too, and stream the take again The read pass filed what JUCE's decoder read, and JUCE reads more than the core does: it pads a file shorter than its header claims with silence and reads a number (0.3 s present and 1 s claimed filed -23.0 where the core refuses; 2 s of 3 filed -20.34 where the audio measures -20.00), and it decodes pcm16 and mono files the pedal gate refuses (review of #140, P0). The two agree on exactly one shape, bit for bit: the pedal's own stereo float32, whole. The pass streams again as before - progressive waveform, the JUCE meter for the number on screen, no whole-file read. The hash goes up with the reading only when the core would measure those very bytes, decided off the file's first 4 KiB and its size through the core's own header walk and upload gate (ReaderLoudness.h), and only when the file is still the one the decode began on: size and stamp are read before the decode and after the hash, so a take replaced under the pass is not filed under another file's bytes. The hash is streamed in a second pass through a 1 MiB buffer (FileHash.h): the digest ends on the first short read, so the bytes it saw are counted against the file's length, and a read the pass cut short is no hash at all. A take of any length stays out of memory, and stop() is heard between decoded blocks and between hash buffers. ReaderMeter is the one place a decoded block is fed to the meter, shared by the pass and by reader_loudness_tests, which pins the decoder-side reading to wav::measureLoudness bit for bit - LUFS, sample peak, true peak, wild count - on float32 takes of three levels, silence, a damaged take and one under a gating block, and shows the gate refusing the truncated, pcm16, mono and 48 kHz files JUCE still decodes. The player harness loads a pcm16 take and must get its number with no key, and a float32 take with the hash of its every byte. --- CMakeLists.txt | 21 +++ app/MainComponent.cpp | 3 +- app/PlayerPane.cpp | 85 +++++------ app/PlayerPane.h | 28 ++-- app/ReaderLoudness.h | 146 ++++++++++++++++++ app/history/FileHash.h | 86 +++++++++++ tests/player_pane_harness.cpp | 40 ++++- tests/reader_loudness_tests.cpp | 258 ++++++++++++++++++++++++++++++++ 8 files changed, 594 insertions(+), 73 deletions(-) create mode 100644 app/ReaderLoudness.h create mode 100644 app/history/FileHash.h create mode 100644 tests/reader_loudness_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5bbe987..cdeb6e6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -716,6 +716,27 @@ target_link_libraries(wav_import_tests PRIVATE juce::juce_recommended_warning_flags) add_test(NAME wav_import_tests COMMAND wav_import_tests) +# ---------------------------------------------------------------------------- +# The player's meter over JUCE's decoder against the core's (#140): on a take +# the pedal gate accepts the two agree bit for bit, and the gate refuses what +# JUCE would still decode — truncated, pcm16, mono — off the header and the +# size alone. The streamed file hash against the in-memory one. No window. +# ---------------------------------------------------------------------------- +juce_add_console_app(reader_loudness_tests PRODUCT_NAME "reader_loudness_tests") +target_sources(reader_loudness_tests PRIVATE tests/reader_loudness_tests.cpp) +target_compile_definitions(reader_loudness_tests PRIVATE + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 + LOOPERCAT_GOLDEN_JSON="${CMAKE_CURRENT_SOURCE_DIR}/fixtures/golden.json") +target_link_libraries(reader_loudness_tests PRIVATE + loopercat::core + nlohmann_json::nlohmann_json + juce::juce_audio_formats + juce::juce_cryptography + juce::juce_recommended_config_flags + juce::juce_recommended_warning_flags) +add_test(NAME reader_loudness_tests COMMAND reader_loudness_tests) + # ---------------------------------------------------------------------------- # The history store (issue #72): theory tests against real SQLite files — the # storage properties read back, atomicity of a take and its row, dedup, diff --git a/app/MainComponent.cpp b/app/MainComponent.cpp index fc48307..1491a91 100644 --- a/app/MainComponent.cpp +++ b/app/MainComponent.cpp @@ -368,7 +368,8 @@ MainComponent::MainComponent(std::string explicitVolume, juce::File dataOverride player.onLoudnessRead = [this](int slot, const wav::LoudnessReading& reading, const std::string& hash) { applyLoudnessReport(slot, describeReading(reading, currentTargetLufs()), 0); - keepReading(hash, reading); + if (!hash.empty()) // empty: the core would not measure these bytes to this number + keepReading(hash, reading); }; player.onNormalize = [this](int slot) { if (const SlotRow* row = pedalBusy ? nullptr : slotRowFor(slot)) diff --git a/app/PlayerPane.cpp b/app/PlayerPane.cpp index 49c4971..0ff8c8c 100644 --- a/app/PlayerPane.cpp +++ b/app/PlayerPane.cpp @@ -3,13 +3,13 @@ #include "PlayerPane.h" -#include "history/ContentHash.h" +#include "ReaderLoudness.h" +#include "history/FileHash.h" #include #include #include -#include namespace loopercat { @@ -20,8 +20,7 @@ namespace const juce::Colour kText { 0xffd8d8d8 }; const juce::Colour kDim { 0xff63636d }; constexpr int kTransportRowHeight = 30; - constexpr int kReadBlock = 32768; // frames per read-pass block: ~0.7 s, a fine stop grain - constexpr int kReadSlice = 1 << 20; // bytes per slice of a whole-file read: ~3 s of float32 stereo + constexpr int kReadBlock = readerloudness::kBlockFrames; // frames per read-pass block: ~0.7 s, a fine stop grain // The transport row's right side, outside in: the time readout ("12:34 / // 12:34" at 13 px plus its 44 px right margin), then the operation zone // that [Reset][Trim] and [Measure][Normalize…] take turns in. Any @@ -333,24 +332,24 @@ void PlayerPane::ReadPass::start(std::vector files, int slot) startThread(); } -bool PlayerPane::ReadPass::readWhole(const juce::File& file, juce::MemoryBlock& out) -{ - juce::FileInputStream in(file); - if (!in.openedOk()) - return false; - const juce::int64 total = in.getTotalLength(); - if (total < 0) - return false; - out.setSize(static_cast(total), false); - juce::int64 done = 0; - while (done < total && !threadShouldExit()) { - const int n = static_cast(std::min(kReadSlice, total - done)); - const int got = in.read(static_cast(out.getData()) + done, n); - if (got <= 0) - return false; - done += got; - } - return done == total; +// The key a metered take is filed under (#140), or nothing. Nothing when the +// core would not measure these bytes (JUCE decodes files the core refuses, +// and pads a truncated one to its header's length — its number is then not +// the core's), when the file changed under the pass (size or stamp differ +// from before the decode: the number and the hash would be of two files), +// and when the hash read stopped short. The hash is streamed, a second pass +// over the file; a take of any length stays out of memory. +std::string PlayerPane::ReadPass::fileKey(const juce::File& file, juce::int64 sizeBefore, + juce::Time modifiedBefore) +{ + if (!readerloudness::coreWouldMeasure(file)) + return {}; + const auto hash = history::fileContentHash(file, [this] { return threadShouldExit(); }); + if (!hash) + return {}; + if (file.getSize() != sizeBefore || file.getLastModificationTime() != modifiedBefore) + return {}; + return *hash; } void PlayerPane::ReadPass::run() @@ -375,22 +374,12 @@ void PlayerPane::ReadPass::run() thumbnail.reset(2, 44100.0, 0); // a track without a take: an empty lane, drawn continue; } - // Declared before the reader: a reader over the bytes must go first. - juce::MemoryBlock bytes; - std::unique_ptr reader; - if (measure) { - if (!readWhole(files_[lane], bytes)) { - if (!threadShouldExit()) - finish(std::nullopt, {}); // a short read is not a reading - return; - } - hash = history::contentHash( - std::string_view(static_cast(bytes.getData()), bytes.getSize())); - reader.reset(owner_.engine_.formats().createReaderFor( - std::make_unique(bytes.getData(), bytes.getSize(), false))); - } else { - reader.reset(owner_.engine_.formats().createReaderFor(files_[lane])); - } + // What the file was before the decode began: a replace under the pass + // shows as a changed size or stamp afterwards, and then nothing is filed. + const juce::int64 sizeBefore = files_[lane].getSize(); + const juce::Time modifiedBefore = files_[lane].getLastModificationTime(); + std::unique_ptr reader( + owner_.engine_.formats().createReaderFor(files_[lane])); if (reader == nullptr || reader->numChannels < 1) { if (measure) finish(std::nullopt, {}); @@ -404,17 +393,16 @@ void PlayerPane::ReadPass::run() // pedal plays anyway, and a program past the meter's capacity is not // one it can read; the waveform still draws, the reading is simply // absent. - std::optional meter; + std::optional meter; if (measure) { try { - meter.emplace(static_cast(reader->sampleRate)); + meter.emplace(reader->sampleRate); } catch (const Error&) { } } bool metered = meter.has_value(); juce::AudioBuffer buffer(channels, kReadBlock); - std::vector interleaved(2 * static_cast(kReadBlock)); juce::int64 position = 0; while (position < total && !threadShouldExit()) { const int n = static_cast(std::min(kReadBlock, total - position)); @@ -422,15 +410,8 @@ void PlayerPane::ReadPass::run() break; thumbnail.addBlock(position, buffer, 0, n); if (metered) { - // Mono feeds both meter channels — the pedal plays it that way. - const float* left = buffer.getReadPointer(0); - const float* right = buffer.getReadPointer(channels >= 2 ? 1 : 0); - for (int i = 0; i < n; ++i) { - interleaved[2 * static_cast(i)] = left[i]; - interleaved[2 * static_cast(i) + 1] = right[i]; - } try { - meter->process(interleaved.data(), static_cast(n)); + meter->feed(buffer, n); } catch (const Error&) { metered = false; // past the meter's capacity: the waveform still draws } @@ -444,8 +425,10 @@ void PlayerPane::ReadPass::run() finish(std::nullopt, {}); // a short read is not a reading return; } - reading = wav::LoudnessReading { meter->integratedLufs(), meter->samplePeak(), - meter->truePeakDb(), meter->wildSamples() }; + reading = meter->reading(); + hash = fileKey(files_[lane], sizeBefore, modifiedBefore); + if (threadShouldExit()) + return; // the hash read was cut short for a newer file } } if (measure) diff --git a/app/PlayerPane.h b/app/PlayerPane.h index 23a334d..62f1a37 100644 --- a/app/PlayerPane.h +++ b/app/PlayerPane.h @@ -100,10 +100,13 @@ class PlayerPane final : public juce::Component, // BS.1770 meter, and hands the reading up through onLoudnessRead the // moment the waveform is complete — no second read off the card, no // button. With it goes the content hash of the bytes it metered - // (history::contentHash): a reading is a fact about those bytes, and - // the owner files it in the history under that key (#140). The owner - // turns it into words (target, damage) and feeds them back through - // setLoudness; a read-only check running elsewhere lands the same way. + // (history::contentHash), when the core would measure those bytes to + // the same number — the pedal's own stereo float32, whole — and empty + // otherwise: a reading is a fact about bytes, and the owner files it in + // the history under that key (#140) only when it is the core's fact + // too. The owner turns it into words (target, damage) and feeds them + // back through setLoudness; a read-only check running elsewhere lands + // the same way. // The readout and Normalize… share the row with Trim and step aside // while a trim selection is active: the selection owns the row then, // and Normalize is whole-loop work that must not read as "the @@ -160,11 +163,11 @@ class PlayerPane final : public juce::Component, // new load or a clear stops it between blocks — browsing fast through // slots must not queue a card's worth of reads. // - // A file that is metered is read into memory whole, in slices, and - // decoded from there: the reading is filed under the hash of its bytes - // (#140), a hash is of all of them, and the card is still read once. A - // lane that is only drawn (a multi-track memory) streams from the file - // as before. + // A metered take is then hashed in a second, streamed pass over the file + // (history::fileContentHash), and the hash goes up with the reading only + // when the core would measure those very bytes (ReaderLoudness.h) and the + // file is still the one the decode began on. Otherwise the reading goes + // up alone: a number for the screen, none for the history. class ReadPass final : public juce::Thread { public: @@ -178,11 +181,8 @@ class PlayerPane final : public juce::Component, void run() override; private: - // The whole file into `out`, a slice at a time so stop() is heard - // between two of them: one read of a long take over USB would hold - // the thread past stop()'s patience. False when the file could not - // be read to its end — or when the pass was told to stop. - bool readWhole(const juce::File& file, juce::MemoryBlock& out); + std::string fileKey(const juce::File& file, juce::int64 sizeBefore, + juce::Time modifiedBefore); PlayerPane& owner_; std::vector files_; diff --git a/app/ReaderLoudness.h b/app/ReaderLoudness.h new file mode 100644 index 0000000..a089541 --- /dev/null +++ b/app/ReaderLoudness.h @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE. +// SPDX-License-Identifier: AGPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include + +#include +#include +#include + +//============================================================================== +// loopercat::readerloudness — the player's meter over JUCE's decoder, and the +// gate on what it may file. +// +// The player's read pass meters the loaded loop while it draws the waveform, +// through whatever JUCE's reader decodes. For the number on screen that is +// fine. For the history (#140) it is not enough: a reading filed under the +// bytes' hash claims to be what the core measures off those bytes, and JUCE +// reads more than the core does — it pads a truncated file to its header's +// length with silence, and it decodes pcm16 and mono files the pedal gate +// refuses. The two agree bit for bit on exactly one shape: the pedal's own +// stereo float32, whole. So: +// +// ReaderMeter the decoder-side meter, one place for how a decoded +// block is fed (mono into both channels, as the pedal +// plays it) — shared by the pass and by the suite that +// pins it to wav::measureLoudness bit for bit; +// coreWouldMeasure the gate, from the file's first bytes and its size, +// no whole-file read: the core's own header walk with +// the real size as its bound, then the upload gate, then +// "has frames" — what wav::measureLoudness demands. +// +// A take refused by the gate still gets its number on screen; it just does +// not get filed. +//============================================================================== +namespace loopercat::readerloudness +{ + +// Frames per decoded block in the pass: ~0.7 s at the pedal's rate, a fine +// grain for stopping between blocks. The suite feeds the meter in the same +// blocks, so what it pins is what ships. +inline constexpr int kBlockFrames = 32768; + +// The first bytes of a file the gate reads: room for RIFF, a fmt chunk of +// either body length the pedal writes (16 or 28 bytes), a DAW's LIST chunk, +// and the data chunk's header. A header that does not fit is a file this +// app did not write, and the gate refuses it rather than reading further. +inline constexpr int kHeaderProbeBytes = 4096; + +class ReaderMeter +{ +public: + // Throws like loudness::Meter: a rate that does not cut into 100 ms + // sub-blocks is not one the meter reads. + explicit ReaderMeter(double sampleRate) + : meter_(static_cast(sampleRate)), + interleaved_(2 * static_cast(kBlockFrames)) + { + } + + // One decoded block, `frames` long, from a reader with one or two + // channels. Throws past the meter's capacity, like the meter. + void feed(const juce::AudioBuffer& block, int frames) + { + if (frames > kBlockFrames) + throw Error("a decoded block is at most " + std::to_string(kBlockFrames) + + " frames, got " + std::to_string(frames)); + // Mono feeds both meter channels — the pedal plays it that way. + const float* left = block.getReadPointer(0); + const float* right = block.getReadPointer(block.getNumChannels() >= 2 ? 1 : 0); + for (int i = 0; i < frames; ++i) { + interleaved_[2 * static_cast(i)] = left[i]; + interleaved_[2 * static_cast(i) + 1] = right[i]; + } + meter_.process(interleaved_.data(), static_cast(frames)); + } + + wav::LoudnessReading reading() const + { + return { meter_.integratedLufs(), meter_.samplePeak(), meter_.truePeakDb(), + meter_.wildSamples() }; + } + +private: + loudness::Meter meter_; + std::vector interleaved_; +}; + +// The whole reader through ReaderMeter, in the pass's blocks — for the +// suite, and for any caller that wants only the number. Throws where the +// meter throws, and when the reader stops short of its own length. +inline wav::LoudnessReading measure(juce::AudioFormatReader& reader) +{ + ReaderMeter meter(reader.sampleRate); + const int channels = static_cast(reader.numChannels); + juce::AudioBuffer buffer(channels, kBlockFrames); + for (juce::int64 position = 0; position < reader.lengthInSamples;) { + const int n = static_cast( + std::min(kBlockFrames, reader.lengthInSamples - position)); + if (!reader.read(&buffer, 0, n, position, true, true)) + throw Error("the decoder stopped " + std::to_string(reader.lengthInSamples - position) + + " frame(s) short of the length it declared"); + meter.feed(buffer, n); + position += n; + } + return meter.reading(); +} + +// Whether wav::measureLoudness would accept a file that begins with `head` +// and is `fileSize` bytes long: a RIFF/WAVE whose every chunk lies inside +// the file (the core's truncation check, against the real size), 44.1 kHz +// stereo float32 (the upload gate), with frames to measure. A refusal is an +// answer about the file, so it is a false, not a throw. +inline bool coreWouldMeasure(wav::BytesView head, std::int64_t fileSize) +{ + try { + return wav::assertUploadable(wav::readWavInfo(head, fileSize)).frames > 0; + } catch (const Error&) { + return false; + } +} + +// The same, off the file itself: its first kHeaderProbeBytes and its size. +// A file that cannot be opened or read that far is not one to file. +inline bool coreWouldMeasure(const juce::File& file) +{ + juce::FileInputStream in(file); + if (!in.openedOk()) + return false; + const juce::int64 size = in.getTotalLength(); + if (size < 0) + return false; + std::vector head( + static_cast(std::min(kHeaderProbeBytes, size))); + const int got = in.read(head.data(), static_cast(head.size())); + if (got != static_cast(head.size())) + return false; + return coreWouldMeasure(wav::BytesView(head.data(), head.size()), size); +} + +} // namespace loopercat::readerloudness diff --git a/app/history/FileHash.h b/app/history/FileHash.h new file mode 100644 index 0000000..ce6c7bc --- /dev/null +++ b/app/history/FileHash.h @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE. +// SPDX-License-Identifier: AGPL-3.0-or-later + +#pragma once + +#include "ContentHash.h" + +#include +#include + +#include +#include +#include + +//============================================================================== +// loopercat::history::fileContentHash — contentHash of a file on disk, +// streamed: the same 32-byte key the bytes in memory get, read through a +// 1 MiB buffer so a take of any length is never held whole (#140). The +// player's read pass hashes what it metered this way, on its own thread. +// +// The answer is the hash of the WHOLE file or nothing: a read that stopped +// short — the caller said stop, or the file could not be read to its end — +// returns nothing rather than the hash of a prefix. juce::SHA256 ends the +// digest on the first short read, so the bytes it consumed are counted and +// compared with the file's length. +//============================================================================== +namespace loopercat::history +{ + +namespace detail +{ + // The file stream as the digest sees it: dry the moment the caller says + // stop, and counting every byte handed over. + class CountedStream final : public juce::InputStream + { + public: + CountedStream(juce::InputStream& source, const std::function& shouldStop) + : source_(source), shouldStop_(shouldStop) + { + } + + juce::int64 getTotalLength() override { return source_.getTotalLength(); } + bool isExhausted() override { return source_.isExhausted(); } + juce::int64 getPosition() override { return source_.getPosition(); } + bool setPosition(juce::int64 position) override { return source_.setPosition(position); } + int read(void* dest, int maxBytes) override + { + if (shouldStop_ && shouldStop_()) + return 0; + const int got = source_.read(dest, maxBytes); + if (got > 0) + handedOver_ += got; + return got; + } + juce::int64 handedOver() const { return handedOver_; } + + private: + juce::InputStream& source_; + const std::function& shouldStop_; + juce::int64 handedOver_ = 0; + }; +} // namespace detail + +inline constexpr int kHashBufferBytes = 1 << 20; + +inline std::optional fileContentHash(const juce::File& file, + const std::function& shouldStop = {}) +{ + juce::FileInputStream in(file); + if (!in.openedOk()) + return std::nullopt; + const juce::int64 length = in.getTotalLength(); + if (length < 0) + return std::nullopt; + detail::CountedStream counted(in, shouldStop); + // The digest reads 64 bytes at a time; the buffer turns that into one + // read of the file per MiB. + juce::BufferedInputStream buffered(&counted, kHashBufferBytes, false); + const juce::SHA256 sha(buffered); + if (counted.handedOver() != length) + return std::nullopt; + const juce::MemoryBlock raw = sha.getRawData(); + return std::string(static_cast(raw.getData()), raw.getSize()); +} + +} // namespace loopercat::history diff --git a/tests/player_pane_harness.cpp b/tests/player_pane_harness.cpp index 834e65f..c76c909 100644 --- a/tests/player_pane_harness.cpp +++ b/tests/player_pane_harness.cpp @@ -88,10 +88,21 @@ int main() out.write(reinterpret_cast(wavBytes.data()), static_cast(wavBytes.size())); } - // What the history would file the reading under: the hash of the file's - // bytes, every one of them, not of what a decoder chose to read. - const std::string wavHash = history::contentHash( - std::string_view(reinterpret_cast(wavBytes.data()), wavBytes.size())); + // The fixture above is pcm16 — a shape the pedal gate refuses, so the pane + // must hand up NO hash for it (#140, review): JUCE decodes it and the + // number shows, but the history gets nothing it could mistake for the + // core's reading. A pedal float32 take beside it gets its hash — of the + // file's bytes, every one of them, not of what a decoder chose to read. + const juce::File floatFile = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("loopercat_player_pane_harness_f32.wav"); + const auto floatBytes = testkit::syntheticWav({ .tag = 3, .bits = 32, .frames = kFrames }); + { + std::ofstream out(floatFile.getFullPathName().toStdString(), std::ios::binary); + out.write(reinterpret_cast(floatBytes.data()), + static_cast(floatBytes.size())); + } + const std::string floatHash = history::contentHash( + std::string_view(reinterpret_cast(floatBytes.data()), floatBytes.size())); AudioEngine engine; PlayerPane pane(engine); @@ -112,22 +123,26 @@ int main() pane.onTrim = [&](int slot, juce::int64 in, juce::int64 out) { trimmed = Trimmed { slot, in, out }; }; // The owner's half of the loudness round trip: the reading comes back as // words, and the button is enabled again. - // The hash rides along with the reading (#140): the one the history keys - // takes by, of exactly the bytes in the file the pane was handed. + // The hash rides along with the reading (#140): the history's key for the + // bytes, when the core would measure them — and empty when it would not. + bool readCame = false; std::string readHash; pane.onLoudnessRead = [&](int slot, const wav::LoudnessReading&, const std::string& hash) { + readCame = true; readHash = hash; pane.setLoudness(slot, "-14.0 LUFS", false, false, ""); }; const auto load = [&] { + readCame = false; readHash.clear(); pane.setSlot(1, wavFile, "01 Loop", false, kFrames); CHECK(engine.hasSource()); CHECK(pane.currentPath() == wavFile.getFullPathName()); settle(pane); CHECK(normalize->isEnabled()); // so a dropped press below is the card's doing - CHECK(readHash == wavHash); // the reading came up keyed by the file's own bytes + CHECK(readCame); // the number came up for the screen... + CHECK(readHash.empty()); // ...with no key for the history: pcm16 is not the pedal's }; // --- a pane never told what it may do offers nothing --- @@ -207,7 +222,18 @@ int main() pane.setMarkers(0.2, 0.7); CHECK(trim->isVisible()); + // --- a pedal float32 take comes up with its key; pcm16 came up without one --- + + readCame = false; + readHash.clear(); + pane.setSlot(2, floatFile, "02 Float", false, kFrames); + CHECK(pane.currentPath() == floatFile.getFullPathName()); + settle(pane); + CHECK(readCame); + CHECK(readHash == floatHash); // the hash of the file's own bytes, every one of them + pane.clear(); wavFile.deleteFile(); + floatFile.deleteFile(); return testkit::summary("player_pane_harness"); } diff --git a/tests/reader_loudness_tests.cpp b/tests/reader_loudness_tests.cpp new file mode 100644 index 0000000..d603921 --- /dev/null +++ b/tests/reader_loudness_tests.cpp @@ -0,0 +1,258 @@ +// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE. +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// The player's meter over JUCE's decoder (app/ReaderLoudness.h) against the +// core's own (wav::measureLoudness), and the gate between them (#140): +// +// 1. for a take the pedal gate accepts — stereo float32, whole — what the +// pass reads off JUCE's decoder IS what the core reads off the bytes, +// bit for bit: LUFS, sample peak, true peak, wild count. A decoder that +// starts to disagree fails here, before it files a wrong number; +// 2. the gate refuses what JUCE would still happily decode and the core +// would not: a file shorter than its header claims (JUCE pads it with +// silence), pcm16, mono — and it refuses off the first bytes and the +// size, never the whole file; +// 3. the streamed file hash is the in-memory hash of the same bytes, and a +// read cut short is no hash at all. + +#include "support.hpp" + +#include "../app/ReaderLoudness.h" +#include "../app/history/ContentHash.h" +#include "../app/history/FileHash.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace loopercat; + +namespace { + +// A float32 stereo 44.1 kHz WAV of a 997 Hz sine at `dbfs` peak in both +// channels — the tone BS.1770 calibrates on; `spike` plants one frame-0 +// sample on channel 0, 1e20 of it making the take "damaged". +std::vector sineWav(int frames, double dbfs, float spike = 0.0f) +{ + std::vector b; + const auto ascii = [&b](std::string_view s) { + for (const char c : s) + b.push_back(static_cast(c)); + }; + const auto p16 = [&b](int v) { + b.push_back(static_cast(v & 0xff)); + b.push_back(static_cast((v >> 8) & 0xff)); + }; + const auto p32 = [&p16](long long v) { + p16(static_cast(v & 0xffff)); + p16(static_cast((v >> 16) & 0xffff)); + }; + const auto pf = [&p32](float v) { p32(static_cast(std::bit_cast(v))); }; + const int dataSize = frames * 8; + ascii("RIFF"); p32(4 + 8 + 16 + 8 + dataSize); ascii("WAVE"); + ascii("fmt "); p32(16); + p16(3); p16(2); p32(44100); p32(44100LL * 8); p16(8); p16(32); + ascii("data"); p32(dataSize); + const double amp = std::pow(10.0, dbfs / 20.0); + const double w = 2.0 * std::numbers::pi * 997.0 / 44100.0; + for (int i = 0; i < frames; ++i) { + const auto v = static_cast(amp * std::sin(w * i)); + pf(i == 0 && spike > 0.0f ? spike : v); + pf(v); + } + return b; +} + +wav::BytesView view(const std::vector& b) { return { b.data(), b.size() }; } + +std::string_view chars(const std::vector& b) +{ + return { reinterpret_cast(b.data()), b.size() }; +} + +struct Scratch { + juce::File dir; + Scratch() + : dir(juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("loopercat-reader-loudness-" + + juce::String(juce::Time::getHighResolutionTicks()))) + { + if (!dir.createDirectory().wasOk()) + throw Error("cannot create the scratch directory"); + } + ~Scratch() + { + // Every reader over these files must be gone by now: a failed sweep is + // a failed test, not a quiet leftover (the Windows lane holds open files). + if (!dir.deleteRecursively()) + testkit::fail("scratch directory not removed: " + dir.getFullPathName().toStdString(), + __FILE__, __LINE__); + } + juce::File put(const juce::String& name, const std::vector& bytes) const + { + const juce::File file = dir.getChildFile(name); + if (!file.replaceWithData(bytes.data(), bytes.size())) + throw Error("cannot write " + file.getFullPathName().toStdString()); + return file; + } +}; + +std::unique_ptr open(const juce::File& file) +{ + juce::WavAudioFormat format; + std::unique_ptr reader( + format.createReaderFor(new juce::FileInputStream(file), true)); + if (reader == nullptr) + throw Error("JUCE could not open " + file.getFullPathName().toStdString()); + return reader; +} + +// Bit for bit: a reading is four numbers, and "close enough" is how a wrong +// decoder slips a wrong number into the history. +void checkSameReading(const wav::LoudnessReading& core, const wav::LoudnessReading& decoded) +{ + CHECK_EQ(core.integratedLufs.has_value(), decoded.integratedLufs.has_value()); + if (core.integratedLufs && decoded.integratedLufs) + CHECK_EQ(std::bit_cast(*core.integratedLufs), + std::bit_cast(*decoded.integratedLufs)); + CHECK_EQ(std::bit_cast(core.samplePeak), std::bit_cast(decoded.samplePeak)); + CHECK_EQ(std::bit_cast(core.truePeakDb), std::bit_cast(decoded.truePeakDb)); + CHECK_EQ(core.wildSamples, decoded.wildSamples); +} + +} // namespace + +int main() +{ + Scratch scratch; + + // --- 1. the pass reads what the core reads, bit for bit, on pedal float32 --- + { + // a loud take, a quiet one, three seconds long (block boundaries fall + // inside it: the core chunks at 4096 frames, the pass at 32768) + for (const double dbfs : { -14.0, -23.0, -40.0 }) { + const auto bytes = sineWav(3 * 44100, dbfs); + const juce::File file = scratch.put("tone" + juce::String(dbfs) + ".wav", bytes); + const auto core = wav::measureLoudness(view(bytes)); + const auto decoded = readerloudness::measure(*open(file)); + CHECK(core.integratedLufs.has_value()); + checkSameReading(core, decoded); + CHECK(readerloudness::coreWouldMeasure(file)); + } + // digital silence: both say "nothing to measure", -inf dBTP, and still agree + const auto quiet = testkit::syntheticWav({ .tag = 3, .bits = 32, .frames = 44100 }); + const juce::File quietFile = scratch.put("silence.wav", quiet); + const auto quietCore = wav::measureLoudness(view(quiet)); + CHECK(!quietCore.integratedLufs.has_value()); + checkSameReading(quietCore, readerloudness::measure(*open(quietFile))); + CHECK(readerloudness::coreWouldMeasure(quietFile)); + // a damaged take: the wild count is the same count + const auto damaged = sineWav(44100, -23.0, 1.0e20f); + const juce::File damagedFile = scratch.put("damaged.wav", damaged); + const auto damagedCore = wav::measureLoudness(view(damaged)); + CHECK_EQ(damagedCore.wildSamples, 1); + checkSameReading(damagedCore, readerloudness::measure(*open(damagedFile))); + CHECK(readerloudness::coreWouldMeasure(damagedFile)); + // under one gating block: unmeasurable on both sides + const auto brief = sineWav(4410, -23.0); + const juce::File briefFile = scratch.put("brief.wav", brief); + checkSameReading(wav::measureLoudness(view(brief)), readerloudness::measure(*open(briefFile))); + CHECK(readerloudness::coreWouldMeasure(briefFile)); + } + + // --- 2. the gate refuses what JUCE decodes and the core would not --- + { + // 0.3 s present, 1 s claimed: JUCE pads to the header's length and + // still reads a number; the core refuses the file; the gate says no + auto cut = sineWav(44100, -23.0); + cut.resize(44 + 13230 * 8); + const juce::File cutFile = scratch.put("cut-short.wav", cut); + CHECK_THROWS(wav::measureLoudness(view(cut)), "truncated"); + CHECK(readerloudness::measure(*open(cutFile)).integratedLufs.has_value()); // the reviewer's -23.0 + CHECK(!readerloudness::coreWouldMeasure(cutFile)); + // 2 s present, 3 s claimed: the padded number is not the audio's + auto padded = sineWav(3 * 44100, -20.0); + padded.resize(44 + 2 * 44100 * 8); + const juce::File paddedFile = scratch.put("cut-third.wav", padded); + const auto audio = wav::measureLoudness(view(sineWav(2 * 44100, -20.0))); + const auto juceSays = readerloudness::measure(*open(paddedFile)); + CHECK(audio.integratedLufs && juceSays.integratedLufs + && std::abs(*audio.integratedLufs - *juceSays.integratedLufs) > 0.1); + CHECK(!readerloudness::coreWouldMeasure(paddedFile)); + // pcm16 stereo: JUCE decodes it, the pedal gate refuses it + const auto pcm = testkit::syntheticWav({ .tag = 1, .bits = 16, .frames = 44100 }); + const juce::File pcmFile = scratch.put("pcm16.wav", pcm); + CHECK_THROWS(wav::measureLoudness(view(pcm)), "32-bit float"); + (void) readerloudness::measure(*open(pcmFile)); // reads fine — which is the problem + CHECK(!readerloudness::coreWouldMeasure(pcmFile)); + // mono float32: the same + const auto mono = testkit::syntheticWav({ .tag = 3, .channels = 1, .bits = 32, .frames = 44100 }); + const juce::File monoFile = scratch.put("mono.wav", mono); + CHECK_THROWS(wav::measureLoudness(view(mono)), "stereo"); + (void) readerloudness::measure(*open(monoFile)); + CHECK(!readerloudness::coreWouldMeasure(monoFile)); + // 48 kHz float32: the pedal does not play it, the gate does not file it + const auto fast = testkit::syntheticWav({ .tag = 3, .sampleRate = 48000, .bits = 32, .frames = 48000 }); + CHECK(!readerloudness::coreWouldMeasure(scratch.put("48k.wav", fast))); + // not a WAV at all, and a file that is not there + CHECK(!readerloudness::coreWouldMeasure(scratch.put("text.wav", std::vector(100, 'x')))); + CHECK(!readerloudness::coreWouldMeasure(scratch.dir.getChildFile("missing.wav"))); + // the gate reads the header and the size, nothing more: the same + // answers off the first 44 bytes as off the file + const auto whole = sineWav(44100, -23.0); + CHECK(readerloudness::coreWouldMeasure(wav::BytesView(whole.data(), 44), + static_cast(whole.size()))); + CHECK(!readerloudness::coreWouldMeasure(wav::BytesView(whole.data(), 44), 44 + 13230 * 8)); + CHECK(!readerloudness::coreWouldMeasure(wav::BytesView(whole.data(), 44), 44)); // no frames + CHECK(!readerloudness::coreWouldMeasure(wav::BytesView(whole.data(), 40), + static_cast(whole.size()))); + // and JUCE never says it stopped short: a reader told its stream is a + // frame longer than it is reads that frame as silence and reports + // success — which is the whole reason the gate, not the decoder, + // decides what gets filed + { + juce::WavAudioFormat format; + std::unique_ptr reader( + format.createReaderFor(new juce::MemoryInputStream(whole.data(), whole.size(), false), true)); + CHECK(reader != nullptr); + if (reader) { + reader->lengthInSamples += 1; + CHECK(readerloudness::measure(*reader).integratedLufs.has_value()); + } + } + } + + // --- 3. the streamed hash is the in-memory hash, or nothing --- + { + const auto bytes = sineWav(3 * 44100, -18.0); // 1 MB and a bit: past one hash buffer + const juce::File file = scratch.put("hashed.wav", bytes); + CHECK(bytes.size() > static_cast(history::kHashBufferBytes)); + const auto streamed = history::fileContentHash(file); + CHECK(streamed.has_value()); + CHECK(streamed.has_value() && *streamed == history::contentHash(chars(bytes))); + CHECK(streamed.has_value() && streamed->size() == 32u); + // the empty file hashes like empty bytes; a missing file hashes to nothing + // (juce::File::replaceWithData with no bytes deletes rather than empties: create() instead) + const juce::File empty = scratch.dir.getChildFile("empty.bin"); + CHECK(empty.create().wasOk()); + CHECK(empty.existsAsFile() && empty.getSize() == 0); + CHECK(history::fileContentHash(empty) == history::contentHash("")); + CHECK(!history::fileContentHash(scratch.dir.getChildFile("nowhere.bin")).has_value()); + // told to stop before the end: no hash of a prefix + int reads = 0; + const auto stopAfterFirst = [&reads] { return reads++ > 0; }; + CHECK(!history::fileContentHash(file, stopAfterFirst).has_value()); + CHECK(!history::fileContentHash(file, [] { return true; }).has_value()); + // a tiny file, under one digest block + const juce::File tiny = scratch.put("tiny.bin", std::vector(7, 'q')); + CHECK(history::fileContentHash(tiny) == history::contentHash("qqqqqqq")); + } + + return testkit::summary("reader_loudness_tests"); +} From f732c211facb7e734ab0c28b7aa0451ed1a947e1 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 01:37:40 +0200 Subject: [PATCH 06/10] Carry a hash to a new take row only for the very file the earlier row saw hashHeldBefore matched an earlier after-row on name and size and skipped any newer row without a hash. Take A renamed, then re-recorded on the pedal as B under the same name and size, then renamed again, gave B's row A's hash with B's stamp; with a rename in between, the hash came from two operations back (review of #140, P1). #141 would read A's loudness for B. It now looks only at the slot's newest after-row for the file name - the slot's last word about that file - and carries its hash only when the row has one and its size and modification time are the file's now. A newer row without a hash blocks every older one; a row from before the store kept stamps cannot vouch for a file; a stamp or size that differ are another file under the same name. The recorder reads the stamp once and asks it of the row too. Both of the reviewer's scenarios run end to end in history_recorder_tests, and the store's rules on their own in history_store_tests. --- app/history/HistoryRecorder.cpp | 7 ++- app/history/HistoryRecorder.h | 6 ++- app/history/HistoryStore.cpp | 18 +++++-- app/history/HistoryStore.h | 12 +++-- tests/history_recorder_tests.cpp | 92 ++++++++++++++++++++++++++++++++ tests/history_store_tests.cpp | 49 +++++++++++++++++ 6 files changed, 171 insertions(+), 13 deletions(-) diff --git a/app/history/HistoryRecorder.cpp b/app/history/HistoryRecorder.cpp index 9d1de78..c835822 100644 --- a/app/history/HistoryRecorder.cpp +++ b/app/history/HistoryRecorder.cpp @@ -240,9 +240,12 @@ void HistoryRecorder::recordWhatSlotsHold(const Operation& op) const auto size = static_cast(std::filesystem::file_size(dir / name, ec)); if (ec) throw Error("cannot measure " + (dir / name).string()); + // The stamp is read once and asked of the earlier row too: a hash + // is carried over only for the very file that row saw. + const std::int64_t modified = modifiedMs(dir / name); store().recordPresentAudio(op.row, slot, kTrack, name, size, - store().hashHeldBefore(op.row, from, name, size), - modifiedMs(dir / name)); + store().hashHeldBefore(op.row, from, name, size, modified), + modified); } } } diff --git a/app/history/HistoryRecorder.h b/app/history/HistoryRecorder.h index 9527066..d7f533c 100644 --- a/app/history/HistoryRecorder.h +++ b/app/history/HistoryRecorder.h @@ -140,8 +140,10 @@ class HistoryRecorder // on its own: a swap would send the reader into the other slot's rows, // and a rename would leave no sign that the slot held a take at all. // Hashes are carried only where they are certain — from the slot's own - // last state, or, for a swap, from the slot it exchanged with. A file the - // store has never seen is recorded by name and size, with no hash. + // last state, or, for a swap, from the slot it exchanged with, and only + // when that state's size and stamp are the file's now. A file the store + // has never seen, or has seen change since, is recorded by name and + // size, with no hash. void recordWhatSlotsHold(const Operation& op); std::int64_t sessionFor(const std::filesystem::path& volume); diff --git a/app/history/HistoryStore.cpp b/app/history/HistoryStore.cpp index b6b43e5..34de907 100644 --- a/app/history/HistoryStore.cpp +++ b/app/history/HistoryStore.cpp @@ -398,17 +398,25 @@ bool HistoryStore::hasAfterAudio(std::int64_t op, int slot) } std::optional HistoryStore::hashHeldBefore(std::int64_t op, int slot, - const std::string& name, std::int64_t size) -{ - sqlite::Statement read(db_, "SELECT hash FROM slot_audio WHERE slot = ?2 AND side = 'after' " - "AND name = ?3 AND size = ?4 AND op < ?1 AND hash IS NOT NULL " + const std::string& name, std::int64_t size, + std::int64_t modifiedMs) +{ + // The newest row for this file name, whatever it says: a row with no + // hash is the slot's last word about the file, and an older row with + // one is about an earlier file of the same name. + sqlite::Statement read(db_, "SELECT hash, size, modified FROM slot_audio " + "WHERE slot = ?2 AND side = 'after' AND name = ?3 AND op < ?1 " "AND op IN (SELECT o.seq FROM ops o JOIN sessions s ON s.id = o.session " "WHERE s.card = (SELECT s2.card FROM ops o2 JOIN sessions s2 " "ON s2.id = o2.session WHERE o2.seq = ?1)) " "ORDER BY op DESC LIMIT 1"); - read.bind(1, op).bind(2, slot).bindText(3, name).bind(4, size); + read.bind(1, op).bind(2, slot).bindText(3, name); if (!read.step()) return std::nullopt; + if (read.isNull(0) || read.isNull(2)) + return std::nullopt; // no hash, or a stamp the store never asked for: uncertain + if (read.integer(1) != size || read.integer(2) != modifiedMs) + return std::nullopt; // the same name on a different file return read.blob(0); } diff --git a/app/history/HistoryStore.h b/app/history/HistoryStore.h index fac4491..0a4271a 100644 --- a/app/history/HistoryStore.h +++ b/app/history/HistoryStore.h @@ -180,11 +180,15 @@ class HistoryStore // Every slot an operation touched, by its body or its audio. std::vector touchedSlots(std::int64_t op); bool hasAfterAudio(std::int64_t op, int slot); - // The hash a slot's last recorded state gives a file of this name and - // size, looking only before `op`. Absent when nothing matches — and then - // it stays absent rather than being guessed from another file. + // The hash the slot's newest row before `op` gives a file of this name — + // and only when that row carries a hash, and its size and modification + // time are the file's now. A newer row without a hash is the last word + // (the file changed while the app was away), a row without a stamp + // (older than store version 9) cannot vouch for the file, and a stamp + // or size that differ are another file under the same name: all of them + // absent, never guessed from an older row (#141 reads these hashes). std::optional hashHeldBefore(std::int64_t op, int slot, const std::string& name, - std::int64_t size); + std::int64_t size, std::int64_t modifiedMs); sqlite::Db& db() { return db_; } diff --git a/tests/history_recorder_tests.cpp b/tests/history_recorder_tests.cpp index 525d156..c5d4861 100644 --- a/tests/history_recorder_tests.cpp +++ b/tests/history_recorder_tests.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include using namespace loopercat; @@ -545,6 +546,97 @@ int main() CHECK_EQ(row.integer(2), 1); // no hash, and none invented } + { + // A take re-recorded in place under the same name and size is another + // file (#141, review). Take A is renamed, so its row carries A's hash + // with A's stamp; the pedal then records B over it — same name, same + // size, a later stamp — and the next rename must not pair B's stamp + // with A's hash. And once a row without a hash stands for the file, + // no older row with one may speak over it: the stamp is the tell. + TempDir tmp; + const fs::path volume = makePedal(tmp.path); + putWav(volume, 3, "003_1.WAV", 132300); + const fs::path file = volume::wavDir(volume, 3) / "003_1.WAV"; + const std::string a = commands::readFileBytes(file); + const std::int64_t stampA = 1'600'000'000'000; + CHECK(juce::File(juce::String(file.string())).setLastModificationTime(juce::Time(stampA))); + auto rec = recorderAt(tmp.path / "history"); + sqlite::Db& db = rec->store().db(); + const auto rowOf = [&db](const std::string& opId) { + sqlite::Statement row(db, "SELECT hex(a.hash), a.modified, hash IS NULL FROM slot_audio a " + "JOIN ops o ON o.seq = a.op WHERE o.id = ?1 AND a.slot = 3 AND a.side = 'after'"); + row.bindText(1, opId); + if (!row.step()) + throw Error("no after-row for " + opId); + return std::tuple(row.text(0), row.integer(1), row.integer(2) != 0); + }; + const auto hex = [](const std::string& raw) { + static constexpr char digits[] = "0123456789ABCDEF"; + std::string out; + for (const char c : raw) { + const auto b = static_cast(c); + out += digits[b >> 4]; + out += digits[b & 0xF]; + } + return out; + }; + + CHECK_EQ(run(*rec, "op-r1", "rename", volume, [&] { + commands::rename(volume, 3, "First", options(rec, "op-r1")); + }), + std::string()); + const auto [hash1, stamp1, null1] = rowOf("op-r1"); + CHECK(!null1); + CHECK_EQ(hash1, hex(HistoryStore::contentHash(a))); // A, photographed and carried + CHECK_EQ(stamp1, stampA); + + // the pedal records B over A: same name, same size, other bytes, later stamp + std::string b = a; + b.back() = static_cast(b.back() ^ 0x5a); + CHECK(b != a && b.size() == a.size()); + commands::writeFileBytes(file, b); + const std::int64_t stampB = stampA + 10'000; + CHECK(juce::File(juce::String(file.string())).setLastModificationTime(juce::Time(stampB))); + + CHECK_EQ(run(*rec, "op-r2", "rename", volume, [&] { + commands::rename(volume, 3, "Second", options(rec, "op-r2")); + }), + std::string()); + const auto [hash2, stamp2, null2] = rowOf("op-r2"); + CHECK(null2); // B is a stranger: no hash, and never A's + CHECK_EQ(stamp2, stampB); + + // a third rename: the newest row for the file has no hash and is the + // last word — A's row two operations back may not speak over it + CHECK_EQ(run(*rec, "op-r3", "rename", volume, [&] { + commands::rename(volume, 3, "Third", options(rec, "op-r3")); + }), + std::string()); + const auto [hash3, stamp3, null3] = rowOf("op-r3"); + CHECK(null3); + CHECK_EQ(stamp3, stampB); + + // once the app itself lands a take there, the hash travels again — for that file + CHECK_EQ(run(*rec, "op-trim", "trim", volume, [&] { + commands::trim(volume, 3, 0, 66150, { .write = options(rec, "op-trim") }); + }), + std::string()); + const auto [hashC, stampC, nullC] = rowOf("op-trim"); + CHECK(!nullC); + CHECK_EQ(hashC, hex(HistoryStore::contentHash(commands::readFileBytes(file)))); + CHECK_EQ(run(*rec, "op-r4", "rename", volume, [&] { + commands::rename(volume, 3, "Fourth", options(rec, "op-r4")); + }), + std::string()); + const auto [hash4, stamp4, null4] = rowOf("op-r4"); + CHECK(!null4); + CHECK_EQ(hash4, hashC); + CHECK_EQ(stamp4, stampC); + CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio WHERE slot = 3 AND side = 'after' AND hash = " + "(SELECT hash FROM slot_audio a2 JOIN ops o2 ON o2.seq = a2.op WHERE o2.id = 'op-r1' AND a2.slot = 3)"), + 2); // A's hash: the snapshot's row and the first rename's, nowhere else + } + { // A failed operation is not written down as a state. The failure that // discriminates is one whose audio hooks never fire — a rename cannot diff --git a/tests/history_store_tests.cpp b/tests/history_store_tests.cpp index fc799d9..e1764f4 100644 --- a/tests/history_store_tests.cpp +++ b/tests/history_store_tests.cpp @@ -705,5 +705,54 @@ int main() && std::abs(*kept->reading.integratedLufs - (-19.5)) <= 1.0e-12); } + // --- a hash is carried to a new row only for the very file an earlier row saw (#141) --- + // + // Theory: the slot's newest row for a file name is the slot's last word + // about that file. It vouches for the file in front of the recorder only + // when it carries a hash and its size and stamp are the file's now. A + // newer row without a hash means the file changed while the app was away, + // and no older row may speak over it; a row from before the store kept + // stamps cannot vouch for anything. + { + TempDir tmp; + Ready r(tmp.path); + const std::string a = take(3000, 151); + const std::string hashA = HistoryStore::contentHash(a); + const auto push = r.store.beginOp(r.session, "op-push", "push", 2000); + r.store.recordLanded(push, 5, 1, "005_1.WAV", a, 1000); + r.store.recordLanded(push, 7, 1, "007_1.WAV", take(3000, 152), 1000); + r.store.finishOp(push, OpStatus::done, ""); + const auto next = r.store.beginOp(r.session, "op-next", "rename", 3000); + // the same file: name, size and stamp as the row saw them + CHECK(r.store.hashHeldBefore(next, 5, "005_1.WAV", 3000, 1000) == hashA); + // another file under the same name: a different stamp, or a different size + CHECK(!r.store.hashHeldBefore(next, 5, "005_1.WAV", 3000, 2000).has_value()); + CHECK(!r.store.hashHeldBefore(next, 5, "005_1.WAV", 3001, 1000).has_value()); + // another slot, another name: nothing to carry + CHECK(!r.store.hashHeldBefore(next, 6, "005_1.WAV", 3000, 1000).has_value()); + CHECK(!r.store.hashHeldBefore(next, 5, "006_1.WAV", 3000, 1000).has_value()); + // only rows before the operation asking + CHECK(!r.store.hashHeldBefore(push, 5, "005_1.WAV", 3000, 1000).has_value()); + // the newest row has no hash: the last word, and an older match is not consulted + r.store.recordPresentAudio(next, 5, 1, "005_1.WAV", 3000, std::nullopt, 2000); + r.store.finishOp(next, OpStatus::done, ""); + const auto later = r.store.beginOp(r.session, "op-later", "rename", 4000); + CHECK(!r.store.hashHeldBefore(later, 5, "005_1.WAV", 3000, 2000).has_value()); + CHECK(!r.store.hashHeldBefore(later, 5, "005_1.WAV", 3000, 1000).has_value()); // not even for the old stamp + // a row from before the store kept stamps cannot vouch for a file + sqlite::Statement unstamp(r.store.db(), "UPDATE slot_audio SET modified = NULL WHERE slot = 7"); + unstamp.run(); + CHECK(!r.store.hashHeldBefore(later, 7, "007_1.WAV", 3000, 1000).has_value()); + // another card's rows are another card's + r.store.finishOp(later, OpStatus::done, ""); + const auto other = r.store.openSession(r.store.card("other", "RC-5", "Other", 5000), 5000); + const auto elsewhere = r.store.beginOp(other, "op-elsewhere", "rename", 6000); + r.store.recordLanded(elsewhere, 9, 1, "009_1.WAV", a, 1000); + r.store.finishOp(elsewhere, OpStatus::done, ""); + r.store.selectCard(1); + const auto back = r.store.beginOp(r.session, "op-back", "rename", 7000); + CHECK(!r.store.hashHeldBefore(back, 9, "009_1.WAV", 3000, 1000).has_value()); + } + return testkit::summary("history_store_tests"); } From 96f8733a321aba580439e321d54a2fa59e9277c4 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 01:37:40 +0200 Subject: [PATCH 07/10] Refuse a negative sample peak, catch what the filing throws, and stat a take once Three small ones from the review of #140. A sample peak is the largest |sample|, a magnitude: recordReading refuses one below zero, and the new table carries CHECK (sample_peak >= 0) - it is this branch's table, so no migration pays for it. The two background filings caught only loopercat::Error; anything else the store threw would have failed the read, or crossed Thread::run: they catch std::exception. modifiedMs stat'ed a file twice - existsAsFile, then the time - and a file gone in between would have stored the epoch as a real stamp; one stat now, and the epoch, JUCE's answer for a file it cannot read, is the error it is. It no longer includes Sqlite.h for a UTF-8 helper. --- app/MainComponent.cpp | 2 +- app/history/FileTime.h | 21 ++++++++++++--------- app/history/HistoryStore.cpp | 4 ++++ app/history/Schema.h | 2 +- app/history/SlotLoudness.h | 5 +++-- tests/history_schema_tests.cpp | 3 +++ tests/history_store_tests.cpp | 8 ++++++++ 7 files changed, 32 insertions(+), 13 deletions(-) diff --git a/app/MainComponent.cpp b/app/MainComponent.cpp index 1491a91..075e47a 100644 --- a/app/MainComponent.cpp +++ b/app/MainComponent.cpp @@ -2444,7 +2444,7 @@ void MainComponent::keepReading(std::string hash, wav::LoudnessReading reading) logDir = settings.dataDir()](const volume::fs::path&) { try { rec->reading(key, reading); - } catch (const Error& e) { + } catch (const std::exception& e) { oplog::append(logDir, "loudness reading not kept in the history: " + juce::String::fromUTF8(e.what())); } diff --git a/app/history/FileTime.h b/app/history/FileTime.h index 7b09f80..43ff58f 100644 --- a/app/history/FileTime.h +++ b/app/history/FileTime.h @@ -3,8 +3,6 @@ #pragma once -#include "Sqlite.h" - #include #include @@ -24,19 +22,24 @@ // unchanged file for equality, so the number has to be the platform's own // stamp, exactly, every time. // -// A file that cannot be read is an error, not a null: slot_audio.modified is -// NULL only in rows written before the store asked (Schema.h), and a null -// that meant "could not tell" would read as one of those. +// One stat, read as the answer: JUCE answers a file it cannot stat with the +// epoch itself, and the epoch is not a time a take on a card was written at, +// so that answer is an error here, not a null. slot_audio.modified is NULL +// only in rows written before the store asked (Schema.h), and a null that +// meant "could not tell" would read as one of those. //============================================================================== namespace loopercat::history { inline std::int64_t modifiedMs(const std::filesystem::path& file) { - const juce::File entry(juce::String::fromUTF8(sqlite::utf8(file).c_str())); - if (!entry.existsAsFile()) - throw Error("cannot read the modification time of " + file.string() + ": no such file"); - return entry.getLastModificationTime().toMilliseconds(); + const std::u8string utf8 = file.u8string(); + const juce::File entry(juce::String::fromUTF8(reinterpret_cast(utf8.data()), + static_cast(utf8.size()))); + const std::int64_t stamp = entry.getLastModificationTime().toMilliseconds(); + if (stamp == 0) + throw Error("cannot read the modification time of " + file.string()); + return stamp; } } // namespace loopercat::history diff --git a/app/history/HistoryStore.cpp b/app/history/HistoryStore.cpp index 34de907..dfb34b4 100644 --- a/app/history/HistoryStore.cpp +++ b/app/history/HistoryStore.cpp @@ -302,6 +302,10 @@ void HistoryStore::recordReading(const std::string& hash, const wav::LoudnessRea if (std::isnan(reading.samplePeak) || std::isnan(reading.truePeakDb) || (reading.integratedLufs.has_value() && std::isnan(*reading.integratedLufs))) throw Error("a loudness reading with a value that is not a number is not a reading"); + // The sample peak is the largest |sample|: a magnitude, never below zero. + if (reading.samplePeak < 0.0f) + throw Error("a sample peak is a magnitude and cannot be negative, got " + + std::to_string(reading.samplePeak)); sqlite::Statement put(db_, "INSERT INTO loudness_readings" "(hash, integrated_lufs, sample_peak, true_peak_dbtp, wild_samples, measured) " "VALUES (?1, ?2, ?3, ?4, ?5, ?6) " diff --git a/app/history/Schema.h b/app/history/Schema.h index a9e9dfe..b04a747 100644 --- a/app/history/Schema.h +++ b/app/history/Schema.h @@ -206,7 +206,7 @@ R"sql( CREATE TABLE loudness_readings( hash BLOB PRIMARY KEY CHECK (length(hash) = 32), integrated_lufs REAL, - sample_peak REAL NOT NULL, + sample_peak REAL NOT NULL CHECK (sample_peak >= 0), true_peak_dbtp REAL NOT NULL, wild_samples INTEGER NOT NULL CHECK (wild_samples >= 0), measured INTEGER NOT NULL diff --git a/app/history/SlotLoudness.h b/app/history/SlotLoudness.h index dec47b3..6bb03b6 100644 --- a/app/history/SlotLoudness.h +++ b/app/history/SlotLoudness.h @@ -9,6 +9,7 @@ #include #include +#include #include //============================================================================== @@ -50,8 +51,8 @@ inline SlotLoudness readSlotLoudness(const volume::fs::path& volume, int slot, out.hash = HistoryStore::contentHash(raw); try { out.kept = recorder.reading(out.hash, out.reading); - } catch (const Error& e) { - out.failure = e.what(); + } catch (const std::exception& e) { + out.failure = e.what(); // the store's refusal, or anything else the filing threw } return out; } diff --git a/tests/history_schema_tests.cpp b/tests/history_schema_tests.cpp index 8cca4e5..ed5ed4d 100644 --- a/tests/history_schema_tests.cpp +++ b/tests/history_schema_tests.cpp @@ -370,6 +370,9 @@ int main() CHECK_THROWS(db.exec("INSERT INTO loudness_readings VALUES (x'" + std::string(64, 'a') + "', NULL, 0.0, 0.0, 0, NULL)"), "NOT NULL"); + CHECK_THROWS(db.exec("INSERT INTO loudness_readings VALUES (x'" + std::string(64, 'a') + + "', NULL, -1.0, 0.0, 0, 1)"), + "CHECK"); // a sample peak is a magnitude CHECK_THROWS(db.exec("INSERT INTO loudness_readings VALUES (x'" + std::string(64, 'a') + "', 'loud', 0.0, 0.0, 0, 1)"), ""); // STRICT: a word is not a REAL diff --git a/tests/history_store_tests.cpp b/tests/history_store_tests.cpp index e1764f4..ab25466 100644 --- a/tests/history_store_tests.cpp +++ b/tests/history_store_tests.cpp @@ -659,9 +659,17 @@ int main() "not a number"); CHECK_THROWS(store.recordReading(HistoryStore::contentHash("negative"), { -20.0, 0.5f, -3.0, -1 }, 1), "negative"); + // a sample peak is a magnitude: below zero it is not a peak + CHECK_THROWS(store.recordReading(HistoryStore::contentHash("neg-peak"), { -20.0, -0.5f, -3.0, 0 }, 1), + "negative"); + CHECK_THROWS(store.recordReading(HistoryStore::contentHash("neg-peak"), { -20.0, -1.0e-30f, -3.0, 0 }, 1), + "negative"); CHECK_EQ(count(store.db(), "SELECT count(*) FROM loudness_readings"), 3); // the table refuses on its own what the method refuses CHECK_THROWS(store.db().exec("INSERT INTO loudness_readings VALUES (x'00', NULL, 0.0, 0.0, 0, 1)"), "CHECK"); + CHECK_THROWS(store.db().exec("INSERT INTO loudness_readings VALUES (x'" + std::string(64, '0') + + "', NULL, -0.5, 0.0, 0, 1)"), + "CHECK"); CHECK_THROWS(store.db().exec("INSERT INTO loudness_readings VALUES (x'" + std::string(64, '0') + "', NULL, 0.0, 0.0, -1, 1)"), "CHECK"); From a66d53bd88cf0e35c71d15f3c4dc85765ca5f82c Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 02:03:22 +0200 Subject: [PATCH 08/10] File the player's reading only when JUCE decoded the frames the core would measure The gate proved the core would measure the file; it did not prove JUCE decoded the same frames. JUCE stops its chunk walk at the RIFF size field and sizes the data chunk with the frame size it knows when it meets it, so a RIFF size that ends after fmt, or inside a LIST ahead of data, or a data chunk ahead of fmt in a file under the probe, gives a reader of 0 frames over a file the core reads whole. The pass then filed {absent, 0, -inf} under the hash the check job files the core's number under, and the stored reading depended on which path ran last (re-review of #140, P0). The gate now returns the core's Info, and the pass files only when the reader's frame count, sample rate and channel count are the core's (decodesAsCore). reader_loudness_tests builds the reviewer's three shapes, shows the core reading each whole and JUCE opening each as a reader of no frames, and the second half of the gate failing on every one; the good tones and the LIST-ahead-of-data shape pass both halves. --- app/PlayerPane.cpp | 22 ++-- app/PlayerPane.h | 11 +- app/ReaderLoudness.h | 60 ++++++++--- tests/reader_loudness_tests.cpp | 182 +++++++++++++++++++++++++------- 4 files changed, 208 insertions(+), 67 deletions(-) diff --git a/app/PlayerPane.cpp b/app/PlayerPane.cpp index 0ff8c8c..29e5877 100644 --- a/app/PlayerPane.cpp +++ b/app/PlayerPane.cpp @@ -335,14 +335,18 @@ void PlayerPane::ReadPass::start(std::vector files, int slot) // The key a metered take is filed under (#140), or nothing. Nothing when the // core would not measure these bytes (JUCE decodes files the core refuses, // and pads a truncated one to its header's length — its number is then not -// the core's), when the file changed under the pass (size or stamp differ -// from before the decode: the number and the hash would be of two files), -// and when the hash read stopped short. The hash is streamed, a second pass -// over the file; a take of any length stays out of memory. -std::string PlayerPane::ReadPass::fileKey(const juce::File& file, juce::int64 sizeBefore, - juce::Time modifiedBefore) -{ - if (!readerloudness::coreWouldMeasure(file)) +// the core's), when JUCE did not decode the frames the core would measure +// (a RIFF size field that ends early leaves it a reader of 0 frames over a +// file the core reads whole), when the file changed under the pass (size or +// stamp differ from before the decode: the number and the hash would be of +// two files), and when the hash read stopped short. The hash is streamed, a +// second pass over the file; a take of any length stays out of memory. +std::string PlayerPane::ReadPass::fileKey(const juce::File& file, + const juce::AudioFormatReader& reader, + juce::int64 sizeBefore, juce::Time modifiedBefore) +{ + const auto info = readerloudness::measurableInfo(file); + if (!info || !readerloudness::decodesAsCore(reader, *info)) return {}; const auto hash = history::fileContentHash(file, [this] { return threadShouldExit(); }); if (!hash) @@ -426,7 +430,7 @@ void PlayerPane::ReadPass::run() return; } reading = meter->reading(); - hash = fileKey(files_[lane], sizeBefore, modifiedBefore); + hash = fileKey(files_[lane], *reader, sizeBefore, modifiedBefore); if (threadShouldExit()) return; // the hash read was cut short for a newer file } diff --git a/app/PlayerPane.h b/app/PlayerPane.h index 62f1a37..924a378 100644 --- a/app/PlayerPane.h +++ b/app/PlayerPane.h @@ -165,9 +165,10 @@ class PlayerPane final : public juce::Component, // // A metered take is then hashed in a second, streamed pass over the file // (history::fileContentHash), and the hash goes up with the reading only - // when the core would measure those very bytes (ReaderLoudness.h) and the - // file is still the one the decode began on. Otherwise the reading goes - // up alone: a number for the screen, none for the history. + // when the core would measure those very bytes, JUCE decoded the frames + // the core would (ReaderLoudness.h), and the file is still the one the + // decode began on. Otherwise the reading goes up alone: a number for the + // screen, none for the history. class ReadPass final : public juce::Thread { public: @@ -181,8 +182,8 @@ class PlayerPane final : public juce::Component, void run() override; private: - std::string fileKey(const juce::File& file, juce::int64 sizeBefore, - juce::Time modifiedBefore); + std::string fileKey(const juce::File& file, const juce::AudioFormatReader& reader, + juce::int64 sizeBefore, juce::Time modifiedBefore); PlayerPane& owner_; std::vector files_; diff --git a/app/ReaderLoudness.h b/app/ReaderLoudness.h index a089541..5d9c01f 100644 --- a/app/ReaderLoudness.h +++ b/app/ReaderLoudness.h @@ -9,8 +9,10 @@ #include +#include #include #include +#include #include //============================================================================== @@ -30,12 +32,23 @@ // block is fed (mono into both channels, as the pedal // plays it) — shared by the pass and by the suite that // pins it to wav::measureLoudness bit for bit; -// coreWouldMeasure the gate, from the file's first bytes and its size, +// measurableInfo the gate, from the file's first bytes and its size, // no whole-file read: the core's own header walk with // the real size as its bound, then the upload gate, then -// "has frames" — what wav::measureLoudness demands. +// "has frames" — what wav::measureLoudness demands — and +// what the core found: its Info; +// decodesAsCore the other half of the proof. The gate says the core +// would measure the file; it does not say JUCE decoded +// the same frames. JUCE stops its chunk walk at the RIFF +// size field and sizes the data chunk with the frame +// size it knows when it meets it, so a RIFF size that +// ends after fmt, or inside a LIST before data, or a +// data chunk ahead of fmt, gives a reader of 0 frames +// over a file the core reads whole. A number from that +// reader is not the core's. The pass files only when the +// reader's frame count, rate and channels are the core's. // -// A take refused by the gate still gets its number on screen; it just does +// A take refused by either still gets its number on screen; it just does // not get filed. //============================================================================== namespace loopercat::readerloudness @@ -111,36 +124,51 @@ inline wav::LoudnessReading measure(juce::AudioFormatReader& reader) return meter.reading(); } -// Whether wav::measureLoudness would accept a file that begins with `head` -// and is `fileSize` bytes long: a RIFF/WAVE whose every chunk lies inside -// the file (the core's truncation check, against the real size), 44.1 kHz -// stereo float32 (the upload gate), with frames to measure. A refusal is an -// answer about the file, so it is a false, not a throw. -inline bool coreWouldMeasure(wav::BytesView head, std::int64_t fileSize) +// What the core makes of a file that begins with `head` and is `fileSize` +// bytes long, when wav::measureLoudness would accept it: a RIFF/WAVE whose +// every chunk lies inside the file (the core's truncation check, against the +// real size), 44.1 kHz stereo float32 (the upload gate), with frames to +// measure. Nothing when it would not — a refusal is an answer about the +// file, not a throw. +inline std::optional measurableInfo(wav::BytesView head, std::int64_t fileSize) { try { - return wav::assertUploadable(wav::readWavInfo(head, fileSize)).frames > 0; + const wav::Info info = wav::assertUploadable(wav::readWavInfo(head, fileSize)); + if (info.frames <= 0) + return std::nullopt; + return info; } catch (const Error&) { - return false; + return std::nullopt; } } // The same, off the file itself: its first kHeaderProbeBytes and its size. // A file that cannot be opened or read that far is not one to file. -inline bool coreWouldMeasure(const juce::File& file) +inline std::optional measurableInfo(const juce::File& file) { juce::FileInputStream in(file); if (!in.openedOk()) - return false; + return std::nullopt; const juce::int64 size = in.getTotalLength(); if (size < 0) - return false; + return std::nullopt; std::vector head( static_cast(std::min(kHeaderProbeBytes, size))); const int got = in.read(head.data(), static_cast(head.size())); if (got != static_cast(head.size())) - return false; - return coreWouldMeasure(wav::BytesView(head.data(), head.size()), size); + return std::nullopt; + return measurableInfo(wav::BytesView(head.data(), head.size()), size); +} + +// Whether JUCE's reader decodes the very frames the core would measure: as +// many of them, at the core's rate, in the core's channels. The rate is an +// integer in a WAV header and comes back whole from either reader, so the +// comparison is exact. +inline bool decodesAsCore(const juce::AudioFormatReader& reader, const wav::Info& info) +{ + return reader.lengthInSamples == info.frames + && static_cast(reader.numChannels) == info.channels + && static_cast(std::llround(reader.sampleRate)) == info.sampleRate; } } // namespace loopercat::readerloudness diff --git a/tests/reader_loudness_tests.cpp b/tests/reader_loudness_tests.cpp index d603921..a424021 100644 --- a/tests/reader_loudness_tests.cpp +++ b/tests/reader_loudness_tests.cpp @@ -35,40 +35,78 @@ using namespace loopercat; namespace { +// Little-endian RIFF building blocks, by hand: the suite assembles files the +// way the format spells them, not the way any reader expects them. +void ascii(std::vector& b, std::string_view s) +{ + for (const char c : s) + b.push_back(static_cast(c)); +} +void p16(std::vector& b, int v) +{ + b.push_back(static_cast(v & 0xff)); + b.push_back(static_cast((v >> 8) & 0xff)); +} +void p32(std::vector& b, long long v) +{ + p16(b, static_cast(v & 0xffff)); + p16(b, static_cast((v >> 16) & 0xffff)); +} +void pf(std::vector& b, float v) +{ + p32(b, static_cast(std::bit_cast(v))); +} +// The pedal's float32 stereo 44.1 kHz fmt body, 16 bytes. +void pedalFmt(std::vector& b) +{ + p16(b, 3); p16(b, 2); p32(b, 44100); p32(b, 44100LL * 8); p16(b, 8); p16(b, 32); +} + // A float32 stereo 44.1 kHz WAV of a 997 Hz sine at `dbfs` peak in both // channels — the tone BS.1770 calibrates on; `spike` plants one frame-0 -// sample on channel 0, 1e20 of it making the take "damaged". -std::vector sineWav(int frames, double dbfs, float spike = 0.0f) +// sample on channel 0, 1e20 of it making the take "damaged"; `extraChunk` +// puts a 26-byte LIST chunk between fmt and data, the DAW-export shape. +std::vector sineWav(int frames, double dbfs, float spike = 0.0f, + bool extraChunk = false) { std::vector b; - const auto ascii = [&b](std::string_view s) { - for (const char c : s) - b.push_back(static_cast(c)); - }; - const auto p16 = [&b](int v) { - b.push_back(static_cast(v & 0xff)); - b.push_back(static_cast((v >> 8) & 0xff)); - }; - const auto p32 = [&p16](long long v) { - p16(static_cast(v & 0xffff)); - p16(static_cast((v >> 16) & 0xffff)); - }; - const auto pf = [&p32](float v) { p32(static_cast(std::bit_cast(v))); }; + const int extra = extraChunk ? 8 + 26 : 0; const int dataSize = frames * 8; - ascii("RIFF"); p32(4 + 8 + 16 + 8 + dataSize); ascii("WAVE"); - ascii("fmt "); p32(16); - p16(3); p16(2); p32(44100); p32(44100LL * 8); p16(8); p16(32); - ascii("data"); p32(dataSize); + ascii(b, "RIFF"); p32(b, 4 + 8 + 16 + extra + 8 + dataSize); ascii(b, "WAVE"); + ascii(b, "fmt "); p32(b, 16); + pedalFmt(b); + if (extraChunk) { + ascii(b, "LIST"); p32(b, 26); + b.insert(b.end(), 26, 0); + } + ascii(b, "data"); p32(b, dataSize); const double amp = std::pow(10.0, dbfs / 20.0); const double w = 2.0 * std::numbers::pi * 997.0 / 44100.0; for (int i = 0; i < frames; ++i) { const auto v = static_cast(amp * std::sin(w * i)); - pf(i == 0 && spike > 0.0f ? spike : v); - pf(v); + pf(b, i == 0 && spike > 0.0f ? spike : v); + pf(b, v); } return b; } +// The same file with its RIFF size field — bytes 4..7 — saying `size`. +std::vector withRiffSize(std::vector bytes, std::uint32_t size) +{ + for (int shift = 0; shift < 32; shift += 8) + bytes[4 + static_cast(shift / 8)] = static_cast((size >> shift) & 0xffu); + return bytes; +} + +// A reader over the file, or null when JUCE will not open it — the pass +// then meters nothing and files nothing, which the suite counts as safe. +std::unique_ptr tryOpen(const juce::File& file) +{ + juce::WavAudioFormat format; + return std::unique_ptr( + format.createReaderFor(new juce::FileInputStream(file), true)); +} + wav::BytesView view(const std::vector& b) { return { b.data(), b.size() }; } std::string_view chars(const std::vector& b) @@ -140,10 +178,25 @@ int main() const auto bytes = sineWav(3 * 44100, dbfs); const juce::File file = scratch.put("tone" + juce::String(dbfs) + ".wav", bytes); const auto core = wav::measureLoudness(view(bytes)); - const auto decoded = readerloudness::measure(*open(file)); + const auto reader = open(file); + const auto decoded = readerloudness::measure(*reader); CHECK(core.integratedLufs.has_value()); checkSameReading(core, decoded); - CHECK(readerloudness::coreWouldMeasure(file)); + // the gate's two halves: the core would measure it, and JUCE + // decoded the frames the core would — as many, same rate, stereo + const auto info = readerloudness::measurableInfo(file); + CHECK(info.has_value()); + CHECK(info.has_value() && info->frames == 3 * 44100); + CHECK(info.has_value() && readerloudness::decodesAsCore(*reader, *info)); + } + // the DAW-export shape, a LIST chunk ahead of data: read whole by both + { + const auto listed = sineWav(44100, -23.0, 0.0f, /*extraChunk=*/true); + const juce::File file = scratch.put("listed.wav", listed); + const auto reader = open(file); + checkSameReading(wav::measureLoudness(view(listed)), readerloudness::measure(*reader)); + const auto info = readerloudness::measurableInfo(file); + CHECK(info.has_value() && readerloudness::decodesAsCore(*reader, *info)); } // digital silence: both say "nothing to measure", -inf dBTP, and still agree const auto quiet = testkit::syntheticWav({ .tag = 3, .bits = 32, .frames = 44100 }); @@ -151,19 +204,19 @@ int main() const auto quietCore = wav::measureLoudness(view(quiet)); CHECK(!quietCore.integratedLufs.has_value()); checkSameReading(quietCore, readerloudness::measure(*open(quietFile))); - CHECK(readerloudness::coreWouldMeasure(quietFile)); + CHECK(readerloudness::measurableInfo(quietFile)); // a damaged take: the wild count is the same count const auto damaged = sineWav(44100, -23.0, 1.0e20f); const juce::File damagedFile = scratch.put("damaged.wav", damaged); const auto damagedCore = wav::measureLoudness(view(damaged)); CHECK_EQ(damagedCore.wildSamples, 1); checkSameReading(damagedCore, readerloudness::measure(*open(damagedFile))); - CHECK(readerloudness::coreWouldMeasure(damagedFile)); + CHECK(readerloudness::measurableInfo(damagedFile)); // under one gating block: unmeasurable on both sides const auto brief = sineWav(4410, -23.0); const juce::File briefFile = scratch.put("brief.wav", brief); checkSameReading(wav::measureLoudness(view(brief)), readerloudness::measure(*open(briefFile))); - CHECK(readerloudness::coreWouldMeasure(briefFile)); + CHECK(readerloudness::measurableInfo(briefFile)); } // --- 2. the gate refuses what JUCE decodes and the core would not --- @@ -175,7 +228,7 @@ int main() const juce::File cutFile = scratch.put("cut-short.wav", cut); CHECK_THROWS(wav::measureLoudness(view(cut)), "truncated"); CHECK(readerloudness::measure(*open(cutFile)).integratedLufs.has_value()); // the reviewer's -23.0 - CHECK(!readerloudness::coreWouldMeasure(cutFile)); + CHECK(!readerloudness::measurableInfo(cutFile)); // 2 s present, 3 s claimed: the padded number is not the audio's auto padded = sineWav(3 * 44100, -20.0); padded.resize(44 + 2 * 44100 * 8); @@ -184,33 +237,33 @@ int main() const auto juceSays = readerloudness::measure(*open(paddedFile)); CHECK(audio.integratedLufs && juceSays.integratedLufs && std::abs(*audio.integratedLufs - *juceSays.integratedLufs) > 0.1); - CHECK(!readerloudness::coreWouldMeasure(paddedFile)); + CHECK(!readerloudness::measurableInfo(paddedFile)); // pcm16 stereo: JUCE decodes it, the pedal gate refuses it const auto pcm = testkit::syntheticWav({ .tag = 1, .bits = 16, .frames = 44100 }); const juce::File pcmFile = scratch.put("pcm16.wav", pcm); CHECK_THROWS(wav::measureLoudness(view(pcm)), "32-bit float"); (void) readerloudness::measure(*open(pcmFile)); // reads fine — which is the problem - CHECK(!readerloudness::coreWouldMeasure(pcmFile)); + CHECK(!readerloudness::measurableInfo(pcmFile)); // mono float32: the same const auto mono = testkit::syntheticWav({ .tag = 3, .channels = 1, .bits = 32, .frames = 44100 }); const juce::File monoFile = scratch.put("mono.wav", mono); CHECK_THROWS(wav::measureLoudness(view(mono)), "stereo"); (void) readerloudness::measure(*open(monoFile)); - CHECK(!readerloudness::coreWouldMeasure(monoFile)); + CHECK(!readerloudness::measurableInfo(monoFile)); // 48 kHz float32: the pedal does not play it, the gate does not file it const auto fast = testkit::syntheticWav({ .tag = 3, .sampleRate = 48000, .bits = 32, .frames = 48000 }); - CHECK(!readerloudness::coreWouldMeasure(scratch.put("48k.wav", fast))); + CHECK(!readerloudness::measurableInfo(scratch.put("48k.wav", fast))); // not a WAV at all, and a file that is not there - CHECK(!readerloudness::coreWouldMeasure(scratch.put("text.wav", std::vector(100, 'x')))); - CHECK(!readerloudness::coreWouldMeasure(scratch.dir.getChildFile("missing.wav"))); + CHECK(!readerloudness::measurableInfo(scratch.put("text.wav", std::vector(100, 'x')))); + CHECK(!readerloudness::measurableInfo(scratch.dir.getChildFile("missing.wav"))); // the gate reads the header and the size, nothing more: the same // answers off the first 44 bytes as off the file const auto whole = sineWav(44100, -23.0); - CHECK(readerloudness::coreWouldMeasure(wav::BytesView(whole.data(), 44), + CHECK(readerloudness::measurableInfo(wav::BytesView(whole.data(), 44), static_cast(whole.size()))); - CHECK(!readerloudness::coreWouldMeasure(wav::BytesView(whole.data(), 44), 44 + 13230 * 8)); - CHECK(!readerloudness::coreWouldMeasure(wav::BytesView(whole.data(), 44), 44)); // no frames - CHECK(!readerloudness::coreWouldMeasure(wav::BytesView(whole.data(), 40), + CHECK(!readerloudness::measurableInfo(wav::BytesView(whole.data(), 44), 44 + 13230 * 8)); + CHECK(!readerloudness::measurableInfo(wav::BytesView(whole.data(), 44), 44)); // no frames + CHECK(!readerloudness::measurableInfo(wav::BytesView(whole.data(), 40), static_cast(whole.size()))); // and JUCE never says it stopped short: a reader told its stream is a // frame longer than it is reads that frame as silence and reports @@ -224,10 +277,65 @@ int main() if (reader) { reader->lengthInSamples += 1; CHECK(readerloudness::measure(*reader).integratedLufs.has_value()); + // and a reader whose frame count is not the core's is not the core's reader + CHECK(!readerloudness::decodesAsCore(*reader, wav::readWavInfo(view(whole)))); } } } + // --- 2b. the core reads the file whole; JUCE reads what the RIFF size field lets it --- + // + // Theory: JUCE's chunk walk stops at the RIFF size field and sizes the + // data chunk with the frame size it knows when it meets it; the core walks + // the file to its end and takes fmt and data in any order. On a file the + // two read differently the gate's first half (the core would measure it) + // holds and its second half (JUCE decoded the core's frames) must fail — + // or the pass would file JUCE's {absent, 0, -inf} under the hash the + // check job files the core's number under (review of #140, P0 narrowed). + { + struct Shape { + const char* name; + std::vector bytes; + }; + // (a) a RIFF size that ends right after fmt: WAVE + the fmt chunk + const auto afterFmt = withRiffSize(sineWav(44100, -20.0), 4 + 8 + 16); + // (b) a RIFF size that ends inside a LIST chunk ahead of data: ten of its 26 bytes + const auto insideList = withRiffSize(sineWav(44100, -20.0, 0.0f, /*extraChunk=*/true), 4 + 24 + 8 + 10); + // (c) a tiny file with data ahead of fmt: eight frames at half scale + std::vector dataFirst; + ascii(dataFirst, "RIFF"); p32(dataFirst, 4 + 8 + 64 + 8 + 16); ascii(dataFirst, "WAVE"); + ascii(dataFirst, "data"); p32(dataFirst, 64); + for (int i = 0; i < 16; ++i) + pf(dataFirst, 0.5f); + ascii(dataFirst, "fmt "); p32(dataFirst, 16); + pedalFmt(dataFirst); + for (const Shape& shape : { Shape { "riff-after-fmt.wav", afterFmt }, + Shape { "riff-inside-list.wav", insideList }, + Shape { "data-before-fmt.wav", dataFirst } }) { + const juce::File file = scratch.put(shape.name, shape.bytes); + // the core reads the whole file: a reading with a real peak + const auto core = wav::measureLoudness(view(shape.bytes)); + CHECK(core.samplePeak > 0.0f); + const auto info = readerloudness::measurableInfo(file); + CHECK(info.has_value()); // the first half of the gate holds... + const auto reader = tryOpen(file); + CHECK(reader != nullptr); // ...and JUCE opens the file... + if (reader == nullptr || !info) + continue; + CHECK_EQ(reader->lengthInSamples, 0); // ...as one of no frames + CHECK(!readerloudness::decodesAsCore(*reader, *info)); // so the second half fails + // which is what JUCE's number would have been filed as + const auto juceSays = readerloudness::measure(*reader); + CHECK(!juceSays.integratedLufs.has_value()); + CHECK_EQ(std::bit_cast(juceSays.samplePeak), std::bit_cast(0.0f)); + } + // the two tone-length shapes read -20 LUFS whole: the number the check job files + for (const auto* bytes : { &afterFmt, &insideList }) { + const auto core = wav::measureLoudness(view(*bytes)); + CHECK(core.integratedLufs.has_value() && std::abs(*core.integratedLufs - (-20.0)) <= 0.1); + } + } + // --- 3. the streamed hash is the in-memory hash, or nothing --- { const auto bytes = sineWav(3 * 44100, -18.0); // 1 MB and a bit: past one hash buffer From 8dbe7600c2ac291df4b3051115d89c91db4d2bcc Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 02:03:22 +0200 Subject: [PATCH 09/10] Let a take row from before version 9 vouch by name and size, as it was written A newest after-row with no modification time vouched for nothing, which is every row of a history migrated from version 5 to 8 - every preview tester's. Until audio landed in a slot, its rename, tempo and settings states carried no hash, and the History window refused to restore them: "that row recorded no state that can go back as a whole" (re-review of #140, P2). hashHeldBefore now holds a row without a stamp to the rule it was written under, name and size, for that row only; a stamped row is still held to its stamp. The staleness the allowance permits stays confined to rows written before the upgrade: the first operation on the slot writes a stamped row, and from there the slot is held to the new rule. The store suite covers the allowance and its edge, and the recorder suite runs a migrated history through a rename that carries the hash, a re-record that the old row still vouches for, and a second re-record that the new row does not. --- app/history/HistoryStore.cpp | 18 +++++++-- app/history/HistoryStore.h | 14 ++++--- tests/history_recorder_tests.cpp | 68 ++++++++++++++++++++++++++++++++ tests/history_store_tests.cpp | 15 +++++-- 4 files changed, 101 insertions(+), 14 deletions(-) diff --git a/app/history/HistoryStore.cpp b/app/history/HistoryStore.cpp index dfb34b4..844664c 100644 --- a/app/history/HistoryStore.cpp +++ b/app/history/HistoryStore.cpp @@ -417,10 +417,20 @@ std::optional HistoryStore::hashHeldBefore(std::int64_t op, int slo read.bind(1, op).bind(2, slot).bindText(3, name); if (!read.step()) return std::nullopt; - if (read.isNull(0) || read.isNull(2)) - return std::nullopt; // no hash, or a stamp the store never asked for: uncertain - if (read.integer(1) != size || read.integer(2) != modifiedMs) - return std::nullopt; // the same name on a different file + if (read.isNull(0)) + return std::nullopt; // no hash: the file changed while the app was away + if (read.integer(1) != size) + return std::nullopt; // the same name on a file of another size + // A row from before the store kept stamps (version 8 and older) can be + // asked only what it knows, name and size — the rule those rows were + // written under. Without it every slot of a migrated history would stand + // hash-less until audio landed in it, and nothing recorded there could be + // restored from the History window. The staleness this allows stays + // confined to those rows: a stamped row is held to its stamp. + if (read.isNull(2)) + return read.blob(0); + if (read.integer(2) != modifiedMs) + return std::nullopt; // the same name and size on another file return read.blob(0); } diff --git a/app/history/HistoryStore.h b/app/history/HistoryStore.h index 0a4271a..8d86b7a 100644 --- a/app/history/HistoryStore.h +++ b/app/history/HistoryStore.h @@ -181,12 +181,14 @@ class HistoryStore std::vector touchedSlots(std::int64_t op); bool hasAfterAudio(std::int64_t op, int slot); // The hash the slot's newest row before `op` gives a file of this name — - // and only when that row carries a hash, and its size and modification - // time are the file's now. A newer row without a hash is the last word - // (the file changed while the app was away), a row without a stamp - // (older than store version 9) cannot vouch for the file, and a stamp - // or size that differ are another file under the same name: all of them - // absent, never guessed from an older row (#141 reads these hashes). + // and only when that row carries a hash, its size is the file's, and its + // modification time is the file's now. A newer row without a hash is the + // last word (the file changed while the app was away), and a stamp or + // size that differ are another file under the same name: absent, never + // guessed from an older row (#141 reads these hashes). A row written + // before the store kept stamps (version 8 and older) is held to name and + // size alone, as it was written: migrated histories keep their slots + // restorable, and the risk stays with those rows. std::optional hashHeldBefore(std::int64_t op, int slot, const std::string& name, std::int64_t size, std::int64_t modifiedMs); diff --git a/tests/history_recorder_tests.cpp b/tests/history_recorder_tests.cpp index c5d4861..bc5685a 100644 --- a/tests/history_recorder_tests.cpp +++ b/tests/history_recorder_tests.cpp @@ -637,6 +637,74 @@ int main() 2); // A's hash: the snapshot's row and the first rename's, nowhere else } + { + // A history migrated from version 8 or older (every preview tester's) + // has rows with no stamp. Its slots must stay restorable: the newest + // such row carries its hash on name and size, as it was written — + // while a stamped row beside it is still held to its stamp. + TempDir tmp; + const fs::path volume = makePedal(tmp.path); + putWav(volume, 5, "005_1.WAV", 132300); + putWav(volume, 6, "006_1.WAV", 132300); + const fs::path fileFive = volume::wavDir(volume, 5) / "005_1.WAV"; + const fs::path fileSix = volume::wavDir(volume, 6) / "006_1.WAV"; + const std::string five = commands::readFileBytes(fileFive); + auto rec = recorderAt(tmp.path / "history"); + CHECK_EQ(run(*rec, "op-old", "rename", volume, [&] { + commands::rename(volume, 5, "Old", options(rec, "op-old")); + commands::rename(volume, 6, "Older", options(rec, "op-old")); + }), + std::string()); + sqlite::Db& db = rec->store().db(); + // the rows as a pre-v9 store left them: no stamp + db.exec("UPDATE slot_audio SET modified = NULL"); + CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio WHERE modified IS NOT NULL"), 0); + // slot 5 untouched: the next rename carries the hash on name and size alone + CHECK_EQ(run(*rec, "op-five", "rename", volume, [&] { + commands::rename(volume, 5, "Five", options(rec, "op-five")); + }), + std::string()); + { + sqlite::Statement rowFive(db, "SELECT hex(a.hash), a.modified IS NULL FROM slot_audio a JOIN ops o ON o.seq = a.op " + "WHERE o.id = 'op-five' AND a.slot = 5 AND a.side = 'after'"); + CHECK(rowFive.step()); + std::string hexFive; + for (const char c : HistoryStore::contentHash(five)) { + static constexpr char digits[] = "0123456789ABCDEF"; + const auto b = static_cast(c); + hexFive += digits[b >> 4]; + hexFive += digits[b & 0xF]; + } + CHECK_EQ(rowFive.text(0), hexFive); + CHECK_EQ(rowFive.integer(1), 0); // and the new row is stamped + } + // slot 6 re-recorded in place, same size: the old row still vouches by + // name and size — the allowance's price, confined to pre-v9 rows — + // and from here on the slot's rows are stamped and held to it + std::string six = commands::readFileBytes(fileSix); + six.back() = static_cast(six.back() ^ 0x5a); + commands::writeFileBytes(fileSix, six); + CHECK(juce::File(juce::String(fileSix.string())).setLastModificationTime(juce::Time(1'600'000'000'000))); + CHECK_EQ(run(*rec, "op-six", "rename", volume, [&] { + commands::rename(volume, 6, "Six", options(rec, "op-six")); + }), + std::string()); + CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio a JOIN ops o ON o.seq = a.op " + "WHERE o.id = 'op-six' AND a.slot = 6 AND a.hash IS NOT NULL AND a.modified = 1600000000000"), + 1); + // now re-record again under the stamped row: another stamp, no hash + six.back() = static_cast(six.back() ^ 0x3c); + commands::writeFileBytes(fileSix, six); + CHECK(juce::File(juce::String(fileSix.string())).setLastModificationTime(juce::Time(1'600'000'010'000))); + CHECK_EQ(run(*rec, "op-six-again", "rename", volume, [&] { + commands::rename(volume, 6, "Six again", options(rec, "op-six-again")); + }), + std::string()); + CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio a JOIN ops o ON o.seq = a.op " + "WHERE o.id = 'op-six-again' AND a.slot = 6 AND a.hash IS NULL AND a.modified = 1600000010000"), + 1); + } + { // A failed operation is not written down as a state. The failure that // discriminates is one whose audio hooks never fire — a rename cannot diff --git a/tests/history_store_tests.cpp b/tests/history_store_tests.cpp index ab25466..42b9507 100644 --- a/tests/history_store_tests.cpp +++ b/tests/history_store_tests.cpp @@ -719,8 +719,9 @@ int main() // about that file. It vouches for the file in front of the recorder only // when it carries a hash and its size and stamp are the file's now. A // newer row without a hash means the file changed while the app was away, - // and no older row may speak over it; a row from before the store kept - // stamps cannot vouch for anything. + // and no older row may speak over it. A row from before the store kept + // stamps is held to the rule it was written under, name and size — the + // one allowance, so a migrated history's slots stay restorable. { TempDir tmp; Ready r(tmp.path); @@ -747,10 +748,16 @@ int main() const auto later = r.store.beginOp(r.session, "op-later", "rename", 4000); CHECK(!r.store.hashHeldBefore(later, 5, "005_1.WAV", 3000, 2000).has_value()); CHECK(!r.store.hashHeldBefore(later, 5, "005_1.WAV", 3000, 1000).has_value()); // not even for the old stamp - // a row from before the store kept stamps cannot vouch for a file + // a row from before the store kept stamps is asked only what it knows: + // name and size carry its hash, whatever stamp the file has now; a + // different size is still another file sqlite::Statement unstamp(r.store.db(), "UPDATE slot_audio SET modified = NULL WHERE slot = 7"); unstamp.run(); - CHECK(!r.store.hashHeldBefore(later, 7, "007_1.WAV", 3000, 1000).has_value()); + CHECK(r.store.hashHeldBefore(later, 7, "007_1.WAV", 3000, 1000) == HistoryStore::contentHash(take(3000, 152))); + CHECK(r.store.hashHeldBefore(later, 7, "007_1.WAV", 3000, 987654321) == HistoryStore::contentHash(take(3000, 152))); + CHECK(!r.store.hashHeldBefore(later, 7, "007_1.WAV", 2999, 1000).has_value()); + // and a stamped row stays held to its stamp: the allowance is for old rows only + CHECK(!r.store.hashHeldBefore(later, 5, "005_1.WAV", 3000, 1000).has_value()); // another card's rows are another card's r.store.finishOp(later, OpStatus::done, ""); const auto other = r.store.openSession(r.store.card("other", "RC-5", "Other", 5000), 5000); From 8909de532c2c87b6de6745c5210c03ed465ffb0e Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 11:47:19 +0200 Subject: [PATCH 10/10] Hold a take row to the stamp its file reports, not the one the test asked for The stamp test set a modification time with milliseconds and expected the same integer back. JUCE on Linux sets and reads whole seconds, so the CI lanes read 1600000000000 where the test asked for 1600000000123 and the rows, which faithfully carry what the directory entry says, failed against the asked-for value. The invariant is that a row says what the file system says: compare against the stamp read back, and check only that the setter took (rounded down to a second at most). --- tests/history_recorder_tests.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/history_recorder_tests.cpp b/tests/history_recorder_tests.cpp index bc5685a..5935bf1 100644 --- a/tests/history_recorder_tests.cpp +++ b/tests/history_recorder_tests.cpp @@ -812,10 +812,14 @@ int main() const fs::path volume = makePedal(tmp.path); putWav(volume, 4, "take.wav", 132300); const fs::path takeFile = volume::wavDir(volume, 4) / "take.wav"; - // a stamp of the test's choosing, with milliseconds, years in the past - const std::int64_t stamped = 1'600'000'000'123; - CHECK(juce::File(juce::String(takeFile.string())).setLastModificationTime(juce::Time(stamped))); - CHECK_EQ(fileStamp(takeFile), stamped); // the file system kept it to the millisecond + // a stamp of the test's choosing, years in the past, with milliseconds + // the file system may or may not keep: macOS and Windows keep them, + // JUCE on Linux sets and reads whole seconds. The rows are held to what + // the directory entry says, so that is what the test compares against. + const std::int64_t chosen = 1'600'000'000'123; + CHECK(juce::File(juce::String(takeFile.string())).setLastModificationTime(juce::Time(chosen))); + const std::int64_t stamped = fileStamp(takeFile); + CHECK(stamped <= chosen && chosen - stamped < 1000); // the stamp took, at most rounded down to a second auto rec = recorderAt(tmp.path / "history"); CHECK_EQ(run(*rec, "op-rename", "rename", volume, [&] { commands::rename(volume, 4, "Stamped", options(rec, "op-rename"));