From 96c5253b7eb4285d0ec55e4278ad862128e2a671 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 10:39:32 +0200 Subject: [PATCH 01/14] Read each take's name, size and stamp off its directory entry in the connect scan The connect scan reads the two memory files and lists directories, and nothing it carried could tell one file in a slot from another without reading it (#141). SlotRow now carries TakeFacts for track 1's first take: its name, its size and the platform's own modification time, read the way the history stores it (FileTime.h), so the two compare as equal integers. No audio is read for them. An entry the file system will not describe whole - a folder under a take's name, a name with nothing behind it, a link to nothing, a stamp that reads as the epoch, which is JUCE's answer for a file it cannot stat - carries no facts: the slot keeps its name on screen and nothing is inferred about it, rather than the scan failing on one take. The facts are part of the snapshot, so a rewritten take is a changed snapshot. take_facts_tests runs the real scan over a scratch volume; the two harnesses that build rows by hand name the new field. --- CMakeLists.txt | 19 ++++ app/PedalWorker.h | 15 ++- app/TakeFacts.h | 61 +++++++++++ tests/rhythm_pane_harness.cpp | 2 +- tests/slot_table_harness.cpp | 2 +- tests/take_facts_tests.cpp | 187 ++++++++++++++++++++++++++++++++++ 6 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 app/TakeFacts.h create mode 100644 tests/take_facts_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index cdeb6e6..ab2dbd5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -737,6 +737,25 @@ target_link_libraries(reader_loudness_tests PRIVATE juce::juce_recommended_warning_flags) add_test(NAME reader_loudness_tests COMMAND reader_loudness_tests) +# A take's directory entry as the connect scan reads it (#141): name, size +# and the platform's own stamp off the file system, no facts for an entry it +# will not describe — and the real scan carrying them slot by slot without +# reading audio. JUCE for the stamp and the worker's thread; no window. +juce_add_console_app(take_facts_tests PRODUCT_NAME "take_facts_tests") +target_sources(take_facts_tests PRIVATE tests/take_facts_tests.cpp) +target_compile_definitions(take_facts_tests PRIVATE + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 + LOOPERCAT_GOLDEN_JSON="${CMAKE_CURRENT_SOURCE_DIR}/fixtures/golden.json") +target_link_libraries(take_facts_tests PRIVATE + loopercat::core + nlohmann_json::nlohmann_json + juce::juce_core + juce::juce_events + juce::juce_recommended_config_flags + juce::juce_recommended_warning_flags) +add_test(NAME take_facts_tests COMMAND take_facts_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/PedalWorker.h b/app/PedalWorker.h index 991e8a9..bbb8c90 100644 --- a/app/PedalWorker.h +++ b/app/PedalWorker.h @@ -3,6 +3,8 @@ #pragma once +#include "TakeFacts.h" + #include #include #include @@ -47,6 +49,11 @@ struct SlotRow std::string wavPath; // absolute path of track 1's first wav — what playback opens std::vector trackPaths; // one per track the model has, in track order: // the track's first wav, or empty for no take + // What wavPath's directory entry says for free (#141): name, size, stamp + // — the take the LUFS column is about, as the history can recognise it + // without reading it. Absent for an empty slot, and for an entry the + // file system would not describe whole (TakeFacts.h). + std::optional take; bool operator==(const SlotRow&) const = default; }; @@ -247,8 +254,14 @@ class PedalWorker final : private juce::Thread firstPath = trackPath; trackPaths.push_back(std::move(trackPath)); } + // The first take's directory entry, read while the scan is + // already in its folder: no audio, and a take the entry will + // not describe simply carries no facts. + std::optional take; + if (!firstPath.empty()) + take = takeFacts(volume::fs::path(firstPath)); snapshot.slots.push_back({ std::move(info), std::move(files), std::move(firstPath), - std::move(trackPaths) }); + std::move(trackPaths), std::move(take) }); } } catch (const Error& e) { snapshot.error = e.what(); diff --git a/app/TakeFacts.h b/app/TakeFacts.h new file mode 100644 index 0000000..93d66c9 --- /dev/null +++ b/app/TakeFacts.h @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE. +// SPDX-License-Identifier: AGPL-3.0-or-later + +#pragma once + +#include "history/FileTime.h" + +#include + +#include +#include +#include +#include + +//============================================================================== +// loopercat::TakeFacts — what a take's directory entry says about it for free +// (#141): its name, its size and its modification time. No byte of audio is +// read for these, which is the point: the connect scan lists directories and +// reads the two memory files, and that is all it may cost. Together with the +// slot's WavLen these facts identify a take well enough for the history to +// say what it already knows about those bytes (HistoryStore::hashOfSighting). +// +// The stamp is read the way the store writes it (history::modifiedMs): the +// platform's own number, exactly, because the history compares two sightings +// of one unchanged file for equality — FAT keeps it at two-second steps, and a +// tolerance would make two files one. +// +// An entry the file system will not describe whole — not a regular file, no +// size, no stamp — has no facts: the slot keeps its name on screen and +// nothing is inferred about it, rather than the whole scan failing on one +// take. +//============================================================================== +namespace loopercat +{ + +struct TakeFacts +{ + std::string name; + std::int64_t size = 0; + std::int64_t modifiedMs = 0; + + bool operator==(const TakeFacts&) const = default; +}; + +inline std::optional takeFacts(const std::filesystem::path& file) +{ + std::error_code ec; + if (!std::filesystem::is_regular_file(file, ec) || ec) + return std::nullopt; + const std::uintmax_t size = std::filesystem::file_size(file, ec); + if (ec) + return std::nullopt; + try { + return TakeFacts { file.filename().string(), static_cast(size), + history::modifiedMs(file) }; + } catch (const Error&) { + return std::nullopt; // the platform gave no stamp for it + } +} + +} // namespace loopercat diff --git a/tests/rhythm_pane_harness.cpp b/tests/rhythm_pane_harness.cpp index 2daacac..53f2f9a 100644 --- a/tests/rhythm_pane_harness.cpp +++ b/tests/rhythm_pane_harness.cpp @@ -73,7 +73,7 @@ std::vector rowsOf() set(8, "RHYTHM", "PlayCount", rc0::kRhythmPlayCount1Meas); std::vector rows; for (auto& info : catalog::listSlots(text)) - rows.push_back({ std::move(info), "", "", { "" } }); + rows.push_back({ std::move(info), "", "", { "" }, std::nullopt }); return rows; } diff --git a/tests/slot_table_harness.cpp b/tests/slot_table_harness.cpp index d3d391a..e9c74db 100644 --- a/tests/slot_table_harness.cpp +++ b/tests/slot_table_harness.cpp @@ -86,7 +86,7 @@ int main() table.setSize(900, 400); std::vector rows; for (auto& info : catalog::listSlots(testkit::syntheticMemoryText())) - rows.push_back({ std::move(info), "", "", { "" } }); + rows.push_back({ std::move(info), "", "", { "" }, std::nullopt }); table.setRows(rows); juce::TableListBox* box = listBoxOf(table); diff --git a/tests/take_facts_tests.cpp b/tests/take_facts_tests.cpp new file mode 100644 index 0000000..e67df0f --- /dev/null +++ b/tests/take_facts_tests.cpp @@ -0,0 +1,187 @@ +// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE. +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// A take's directory entry as the connect scan reads it (#141): TakeFacts.h, +// and PedalWorker::scanOnce carrying the facts slot by slot, against a +// scratch volume. What must hold, and what these try to break: +// +// - a regular file: its name, its size, and the platform's own stamp, +// exactly — the number the history stores for the same file +// - an entry the file system will not describe as a take — a folder under +// the take's name, a name with no file behind it, a link to nothing, a +// file whose stamp reads as the epoch (what the platform answers for a +// file it cannot stat) — has no facts, and no throw +// - the scan reads no audio for any of this: a take that is not a WAV at +// all is sighted like any other, and a slot whose entry gives no facts +// keeps its name on screen while the scan carries on, no error +// - an empty slot carries no facts + +#include "support.hpp" + +#include "../app/PedalWorker.h" +#include "../app/TakeFacts.h" + +#include +#include +#include + +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#endif + +using namespace loopercat; +namespace fs = std::filesystem; + +namespace { + +struct TempDir { + fs::path path; + TempDir() + { + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + path = fs::temp_directory_path() / ("loopercat-take-facts-" + std::to_string(stamp)); + fs::remove_all(path); + fs::create_directories(path); + } + ~TempDir() + { + std::error_code ec; + fs::remove_all(path, ec); + CHECK(!ec); // a directory that will not go is a failure, not a leak + } +}; + +fs::path makePedal(const fs::path& root) +{ + const fs::path volume = root / "BOSS RC-5"; + fs::create_directories(volume / "ROLAND" / "WAVE"); + fs::create_directories(volume::dataDir(volume)); + const std::string text = testkit::syntheticMemoryText(); + for (const int fileNo : { 1, 2 }) + commands::writeFileBytes(volume::memoryPath(volume, fileNo), + rc0::setTailMarker(text, fileNo)); + return volume; +} + +juce::File juceFile(const fs::path& file) +{ + const std::u8string utf8 = file.u8string(); + return juce::File(juce::String::fromUTF8(reinterpret_cast(utf8.data()), + static_cast(utf8.size()))); +} + +// The platform's own stamp for a file, as the history stores it. +std::int64_t stampOf(const fs::path& file) +{ + return juceFile(file).getLastModificationTime().toMilliseconds(); +} + +#if !defined(_WIN32) +// A file stamped at the epoch, set the POSIX way: JUCE's own setter reads a +// zero time as "leave it as it is". +bool stampAtEpoch(const fs::path& file) +{ + const struct utimbuf epoch { 0, 0 }; + return ::utime(file.c_str(), &epoch) == 0; +} +#endif + +} // namespace + +int main() +{ + TempDir tmp; + + // --- a regular file: name, size and the exact stamp --- + { + const fs::path file = tmp.path / "001_1.WAV"; + commands::writeFileBytes(file, "not audio at all"); // 16 bytes: the facts do not care + const auto facts = takeFacts(file); + CHECK(facts.has_value()); + if (facts) { + CHECK_EQ(facts->name, std::string("001_1.WAV")); + CHECK_EQ(facts->size, 16); + CHECK_EQ(facts->modifiedMs, stampOf(file)); // equal, not close + CHECK(facts->modifiedMs > 0); + CHECK(*facts == (TakeFacts { "001_1.WAV", 16, stampOf(file) })); + } + // asked again of the unchanged file: the very same facts, to the + // millisecond — the history compares them for equality + CHECK(takeFacts(file) == facts); + } + + // --- an entry that is not a take: no facts, no throw --- + { + const fs::path folder = tmp.path / "002_1.WAV"; + fs::create_directories(folder); + CHECK(!takeFacts(folder).has_value()); + CHECK(!takeFacts(tmp.path / "003_1.WAV").has_value()); // nothing behind the name + CHECK(!takeFacts(tmp.path / "nowhere" / "004_1.WAV").has_value()); // nor a folder for it +#if !defined(_WIN32) // the epoch stamp is set through POSIX, and a symbolic + // link needs a privilege a Windows test run lacks + // a file whose stamp is the epoch: JUCE answers the same for a file + // it cannot stat, so the stamp says nothing, and nothing is claimed + const fs::path unstamped = tmp.path / "005_1.WAV"; + commands::writeFileBytes(unstamped, "sixteen bytes!!!"); + CHECK(stampAtEpoch(unstamped)); + CHECK_EQ(stampOf(unstamped), 0); + CHECK(!takeFacts(unstamped).has_value()); + // a link whose target is gone: no regular file behind the name + const fs::path dangling = tmp.path / "006_1.WAV"; + fs::create_symlink(tmp.path / "gone.wav", dangling); + CHECK(!takeFacts(dangling).has_value()); +#endif + } + + // --- the scan carries the facts, and carries on where there are none --- + { + const fs::path volume = makePedal(tmp.path); + fs::create_directories(volume::wavDir(volume, 1)); + commands::writeFileBytes(volume::wavDir(volume, 1) / "001_1.WAV", "RIFF but not a WAV"); + fs::create_directories(volume::wavDir(volume, 2) / "002_1.WAV"); // a folder under the take's name +#if !defined(_WIN32) + fs::create_directories(volume::wavDir(volume, 4)); + const fs::path unstamped = volume::wavDir(volume, 4) / "004_1.WAV"; + commands::writeFileBytes(unstamped, "RIFF but not a WAV"); + CHECK(stampAtEpoch(unstamped)); // no stamp to tell +#endif + PedalWorker scanner(volume.string(), [](const PedalSnapshot&) {}); + const PedalSnapshot seen = scanner.scanOnce(); + CHECK_EQ(seen.error, std::string()); + CHECK_EQ(seen.slots.size(), 99u); + if (seen.slots.size() == 99) { + const SlotRow& first = seen.slots[0]; + CHECK_EQ(first.wavFile, std::string("001_1.WAV")); + CHECK(first.take.has_value()); + CHECK(first.take + == std::optional(TakeFacts { + "001_1.WAV", 18, stampOf(volume::wavDir(volume, 1) / "001_1.WAV") })); + const SlotRow& second = seen.slots[1]; + CHECK_EQ(second.wavFile, std::string("002_1.WAV")); // still listed by name + CHECK(!second.take.has_value()); // but vouched for by nothing + CHECK(!second.wavPath.empty()); + const SlotRow& third = seen.slots[2]; + CHECK(third.wavFile.empty()); + CHECK(!third.take.has_value()); +#if !defined(_WIN32) + const SlotRow& fourth = seen.slots[3]; + CHECK_EQ(fourth.wavFile, std::string("004_1.WAV")); // listed, playable by path + CHECK(!fourth.wavPath.empty()); + CHECK(!fourth.take.has_value()); // and vouched for by nothing +#endif + } + // the facts follow the file: a rewrite is another sighting + commands::writeFileBytes(volume::wavDir(volume, 1) / "001_1.WAV", "RIFF but not a WAV, longer"); + const PedalSnapshot after = scanner.scanOnce(); + CHECK(after.slots.size() == 99 && after.slots[0].take.has_value() + && after.slots[0].take->size == 26); + CHECK(!(after == seen)); // a changed take is a changed snapshot + } + + return testkit::summary("take_facts_tests"); +} From 2e98b8bff627a8b34420c49dc034ba3b258ccd14 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 10:39:41 +0200 Subject: [PATCH 02/14] Tell which recorded take a file in a slot is from its free facts, and what it measured hashOfSighting answers with the hash of the take a card's slot holds when the slot's newest row for that file on track 1 carries a hash, its size and its stamp are the entry's to the millisecond, and the body recorded with it says the same WavLen - and only while nothing else about the slot was recorded after that row. A clear, a swap or a failed write after it makes the row history: the pedal stamps what it records with a clock that does not keep the date, so a loop of the same length recorded again in the slot can carry the very same name, size and stamp. A row without a stamp, older than version 9, gets no allowance here as it does in hashHeldBefore: nothing is at stake but a number on screen, and it must not be a guess. cardFor finds a card by its marker without touching last_seen. inferLoudness (InferredLoudness.h) joins that to the readings of #140: a slot the history cannot vouch for, or whose bytes it never measured, is not in the answer, and each answer names the sighting it answers. Neither writes anything. The store suite takes each fact off by one, the newest row, the rows after it that end its word and the ones that do not (an operation only about the slot, another slot's write), track 2, another card, and checks that asking wrote nothing. inferred_loudness_tests records real commands through withHistory: a rename keeps the reading, a swap carries a take under its own name and loses one under the pedal's name, a trim and a clear end it. And a different take under the very same four facts fools it - while the store knows nothing of the bytes really there, and normalize, which reads them, raises the loop the guess says is already at target (#142). --- CMakeLists.txt | 22 ++ app/history/HistoryStore.cpp | 64 +++++ app/history/HistoryStore.h | 33 +++ app/history/InferredLoudness.h | 66 +++++ tests/history_store_tests.cpp | 168 ++++++++++++ tests/inferred_loudness_tests.cpp | 421 ++++++++++++++++++++++++++++++ 6 files changed, 774 insertions(+) create mode 100644 app/history/InferredLoudness.h create mode 100644 tests/inferred_loudness_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ab2dbd5..9fc6354 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -798,6 +798,28 @@ target_link_libraries(history_recorder_tests PRIVATE juce::juce_recommended_warning_flags) add_test(NAME history_recorder_tests COMMAND history_recorder_tests) +# What the history says about a take it meets again without reading it (#141): +# real commands recorded on a synthetic pedal, readings filed as Check +# loudness files them, the takes sighted by their directory entries — and +# asked of the store, which must write nothing for answering. +juce_add_console_app(inferred_loudness_tests PRODUCT_NAME "inferred_loudness_tests") +target_sources(inferred_loudness_tests PRIVATE tests/inferred_loudness_tests.cpp + app/history/HistoryRecorder.cpp + app/history/HistoryStore.cpp) +target_compile_definitions(inferred_loudness_tests PRIVATE + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 + LOOPERCAT_GOLDEN_JSON="${CMAKE_CURRENT_SOURCE_DIR}/fixtures/golden.json") +target_link_libraries(inferred_loudness_tests PRIVATE + loopercat::core + loopercat_sqlite3 + nlohmann_json::nlohmann_json + juce::juce_core + juce::juce_cryptography + juce::juce_recommended_config_flags + juce::juce_recommended_warning_flags) +add_test(NAME inferred_loudness_tests COMMAND inferred_loudness_tests) + # First sighting, interruption and foreground writes on synthetic cards. juce_add_console_app(first_seen_tests PRODUCT_NAME "first_seen_tests") target_sources(first_seen_tests PRIVATE tests/first_seen_tests.cpp diff --git a/app/history/HistoryStore.cpp b/app/history/HistoryStore.cpp index 844664c..2f66978 100644 --- a/app/history/HistoryStore.cpp +++ b/app/history/HistoryStore.cpp @@ -7,6 +7,7 @@ #include "Schema.h" #include "Undo.h" +#include #include #include @@ -434,6 +435,69 @@ std::optional HistoryStore::hashHeldBefore(std::int64_t op, int slo return read.blob(0); } +std::optional HistoryStore::cardFor(const std::string& markerId) +{ + sqlite::Statement find(db_, "SELECT id FROM cards WHERE marker_id = ?1"); + find.bindText(1, markerId); + if (!find.step()) + return std::nullopt; + return find.integer(0); +} + +std::optional HistoryStore::hashOfSighting(std::int64_t card, int slot, + const TakeSighting& seen) +{ + constexpr int kFirstTrack = 1; // the take the LUFS column measures (SlotLoudness.h) + // The newest row for this file on this card's slot, whatever it says. + sqlite::Statement row(db_, "SELECT a.op, a.hash, a.size, a.modified FROM slot_audio a " + "JOIN ops o ON o.seq = a.op JOIN sessions s ON s.id = o.session " + "WHERE s.card = ?1 AND a.slot = ?2 AND a.side = 'after' " + "AND a.track = ?3 AND a.name = ?4 ORDER BY a.op DESC LIMIT 1"); + row.bind(1, card).bind(2, slot).bind(3, kFirstTrack).bindText(4, seen.name); + if (!row.step()) + return std::nullopt; + const std::int64_t op = row.integer(0); + // Anything recorded about the slot after that row makes it history: an + // operation that left the slot empty (a clear, a swap with an empty slot, + // an undo of a push) or failed half-way writes no row for the file, and a + // file met there later under the old name, size and stamp is a file the + // row never saw. The pedal stamps what it records with a clock that does + // not keep the date (it read May 1 2019 on 2026-08-10, hardware), so two + // of its recordings of one length can carry the same three facts. + sqlite::Statement later(db_, "SELECT 1 FROM (SELECT op FROM slot_changes WHERE slot = ?2 AND op > ?3 " + "UNION ALL SELECT op FROM slot_audio WHERE slot = ?2 AND op > ?3) t " + "JOIN ops o ON o.seq = t.op JOIN sessions s ON s.id = o.session " + "WHERE s.card = ?1 LIMIT 1"); + later.bind(1, card).bind(2, slot).bind(3, op); + if (later.step()) + return std::nullopt; + if (row.isNull(1)) + return std::nullopt; // the bytes were strange to the store when it last looked + if (row.integer(2) != seen.size) + return std::nullopt; + // Stored and sighted stamps are the same platform number for the same + // file, so equal or not — FAT's two-second steps make a tolerance a way + // of mistaking two files for one. A row without a stamp cannot match. + if (row.isNull(3) || row.integer(3) != seen.modifiedMs) + return std::nullopt; + const std::string hash = row.blob(1); + // The body the slot had when that row was written: the newest recorded + // at or before it, since an operation that rewrites the take without + // touching the memory (a normalize) records no body of its own. A slot + // with no body on record — its history forgotten, or its first sighting + // failed — has nothing to check WavLen against, and so answers nothing. + sqlite::Statement body(db_, "SELECT c.after_body FROM slot_changes c " + "JOIN ops o ON o.seq = c.op JOIN sessions s ON s.id = o.session " + "WHERE s.card = ?1 AND c.slot = ?2 AND c.op <= ?3 " + "AND c.after_body IS NOT NULL ORDER BY c.op DESC LIMIT 1"); + body.bind(1, card).bind(2, slot).bind(3, op); + if (!body.step()) + return std::nullopt; + if (rc0::sectionField(body.blob(0), rc0::trackSectionName(kFirstTrack), "WavLen") != seen.frames) + return std::nullopt; + return hash; +} + std::vector HistoryStore::slotTimeline(int slot) { std::vector rows; diff --git a/app/history/HistoryStore.h b/app/history/HistoryStore.h index 8d86b7a..32ace5e 100644 --- a/app/history/HistoryStore.h +++ b/app/history/HistoryStore.h @@ -192,6 +192,39 @@ class HistoryStore std::optional hashHeldBefore(std::int64_t op, int slot, const std::string& name, std::int64_t size, std::int64_t modifiedMs); + // --- what a connect can tell from a directory entry (#141) --- + + // The card a marker names, when the store has met it. A lookup and + // nothing else: card() above writes last_seen and the name, which a + // question about the history must not do. + std::optional cardFor(const std::string& markerId); + + // A take as a connect scan sees it without reading it: the directory + // entry's name, size and stamp (TakeFacts.h), and the frame count the + // slot's config says the take has (WavLen). + struct TakeSighting { + std::string name; + std::int64_t size = 0; + std::int64_t modifiedMs = 0; + std::int64_t frames = 0; + }; + // The hash of the take this card's slot holds, when the history can tell + // it is the one sighted: the slot's newest row for the file name on the + // first track — the take the LUFS column measures — carries a hash, its + // size and its stamp are the entry's, and the body recorded with it (the + // newest at or before that row) says the same WavLen. All four or + // nothing, and only from the slot's last word: a row without a hash, + // with another size or stamp, or without a stamp at all (older than + // store version 9) answers nothing and no older row is asked instead; + // and once anything else about the slot was recorded after that row — a + // clear, a swap, a failed write — the row answers nothing either. Unlike + // hashHeldBefore, a stampless row gets no allowance here: nothing is at + // stake but a number on screen, and that number must not be a guess. + // Scoped to the card: another card's rows are another card's. A read: + // asking writes nothing. + std::optional hashOfSighting(std::int64_t card, int slot, + const TakeSighting& seen); + sqlite::Db& db() { return db_; } // --- Undo and Redo over the timeline (Undo.h): the store's side --- diff --git a/app/history/InferredLoudness.h b/app/history/InferredLoudness.h new file mode 100644 index 0000000..747e40a --- /dev/null +++ b/app/history/InferredLoudness.h @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE. +// SPDX-License-Identifier: AGPL-3.0-or-later + +#pragma once + +#include "HistoryStore.h" + +#include + +#include +#include +#include +#include + +//============================================================================== +// loopercat::history::inferLoudness — what the history already knows about +// the takes a connect scan found, without reading one byte of audio (#141). +// +// For each slot sighted, the store is asked which take it holds by the facts +// the directory entry and the config gave for free (HistoryStore:: +// hashOfSighting), and then whether those bytes were ever measured +// (readingFor, #140). A slot the history cannot vouch for, or whose bytes it +// never measured, is simply not in the answer: a dash stays a dash, never a +// guess. +// +// A read and nothing else — no row is written, no reading filed, no +// operation opened. What comes back informs the eye (SlotTable::LoudnessCell:: +// inferred) and decides nothing: Normalize measures from the card's bytes +// whatever this said. +// +// Worker thread, like every read of the store. +//============================================================================== +namespace loopercat::history +{ + +struct SlotSighting { + int slot = 0; + HistoryStore::TakeSighting take; +}; + +// The answer names the sighting it answers, whole: the caller holds it +// against the slot as it is by the time the answer lands, and a slot whose +// facts moved on meanwhile is not the one this reading was found for. +struct InferredReading { + SlotSighting sighted; + wav::LoudnessReading reading; + std::int64_t measuredMs = 0; // when the bytes were measured +}; + +inline std::vector inferLoudness(HistoryStore& store, std::int64_t card, + const std::vector& sightings) +{ + std::vector out; + for (const SlotSighting& sighted : sightings) { + const std::optional hash = store.hashOfSighting(card, sighted.slot, sighted.take); + if (!hash) + continue; + const std::optional stored = store.readingFor(*hash); + if (!stored) + continue; + out.push_back({ sighted, stored->reading, stored->measuredMs }); + } + return out; +} + +} // namespace loopercat::history diff --git a/tests/history_store_tests.cpp b/tests/history_store_tests.cpp index 42b9507..b79608f 100644 --- a/tests/history_store_tests.cpp +++ b/tests/history_store_tests.cpp @@ -21,12 +21,19 @@ // 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) +// - a take sighted on connect is recognised by all four free facts — name, +// size, stamp, WavLen — or not at all (#141); the newest row is the last +// word, and so is anything recorded about the slot after it (a clear, a +// failed write) — but not a subject alone, nor another slot's change; +// only track 1 is asked; another card's rows are never asked; asking +// writes nothing #include "support.hpp" #include "../app/history/HistoryStore.h" #include "../app/history/Schema.h" +#include #include #include #include @@ -769,5 +776,166 @@ int main() CHECK(!r.store.hashHeldBefore(back, 9, "009_1.WAV", 3000, 1000).has_value()); } + // --- what a connect can tell from a directory entry (#141) --- + // + // Theory: a take is recognised without reading it only when every free + // fact agrees — the entry's name, size and stamp against the slot's + // newest row for that name, and the config's WavLen against the body on + // record with that row. The newest row is the slot's last word: a row + // that cannot match (no hash, another size or stamp, no stamp at all) + // answers nothing, and no older row speaks instead. Stamps are compared + // as the integers they are. Another card's rows are never consulted. The + // lookup writes nothing. + { + TempDir tmp; + Ready r(tmp.path); + const auto bodyWith = [](long long frames) { + std::string body = rc0::setSectionField(testkit::syntheticSlotBody("Loop"), + rc0::trackSectionName(1), "WavStat", 1); + return rc0::setSectionField(body, rc0::trackSectionName(1), "WavLen", frames); + }; + const std::string a = take(3000, 161); + const std::string hashA = HistoryStore::contentHash(a); + constexpr std::int64_t kStamp = 1'700'000'000'000; // ms, like every time in the store + const auto push = r.store.beginOp(r.session, "op-push", "push", 2000); + r.store.recordBodies(push, { { 5, testkit::syntheticSlotBody("Memory 05"), bodyWith(132300) } }); + r.store.recordLanded(push, 5, 1, "005_1.WAV", a, kStamp); + r.store.finishOp(push, OpStatus::done, ""); + const std::int64_t card = *r.store.selectedCard(); + using Sighting = HistoryStore::TakeSighting; + // all four facts agree: the take is recognised + CHECK(r.store.hashOfSighting(card, 5, Sighting { "005_1.WAV", 3000, kStamp, 132300 }) == hashA); + // any one of them off: nothing — a name, a size, a stamp, a WavLen + CHECK(!r.store.hashOfSighting(card, 5, Sighting { "005_2.WAV", 3000, kStamp, 132300 }).has_value()); + CHECK(!r.store.hashOfSighting(card, 5, Sighting { "005_1.WAV", 2999, kStamp, 132300 }).has_value()); + CHECK(!r.store.hashOfSighting(card, 5, Sighting { "005_1.WAV", 3001, kStamp, 132300 }).has_value()); + CHECK(!r.store.hashOfSighting(card, 5, Sighting { "005_1.WAV", 3000, kStamp + 2000, 132300 }).has_value()); + CHECK(!r.store.hashOfSighting(card, 5, Sighting { "005_1.WAV", 3000, kStamp + 1, 132300 }).has_value()); // no tolerance + CHECK(!r.store.hashOfSighting(card, 5, Sighting { "005_1.WAV", 3000, kStamp - 1, 132300 }).has_value()); + CHECK(!r.store.hashOfSighting(card, 5, Sighting { "005_1.WAV", 3000, kStamp, 132301 }).has_value()); + CHECK(!r.store.hashOfSighting(card, 5, Sighting { "005_1.WAV", 3000, kStamp, 0 }).has_value()); + // another slot: nothing, even for the same facts + CHECK(!r.store.hashOfSighting(card, 6, Sighting { "005_1.WAV", 3000, kStamp, 132300 }).has_value()); + // a rewrite of the take without a body of its own (a normalize): the + // newest row wins, the body on record with it is the one before + const std::string b = take(3000, 162); + const std::string hashB = HistoryStore::contentHash(b); + const auto normalize = r.store.beginOp(r.session, "op-normalize", "normalize", 3000); + r.store.recordLanded(normalize, 5, 1, "005_1.WAV", b, kStamp + 4000); + r.store.finishOp(normalize, OpStatus::done, ""); + CHECK(r.store.hashOfSighting(card, 5, Sighting { "005_1.WAV", 3000, kStamp + 4000, 132300 }) == hashB); + // the file as the older row saw it: the newest row is the last word, + // and the older one is not consulted + CHECK(!r.store.hashOfSighting(card, 5, Sighting { "005_1.WAV", 3000, kStamp, 132300 }).has_value()); + // a later body with another WavLen, and a newest row that names the + // file without a hash (bytes the store did not know): the slot's + // last word cannot vouch, so nothing — not for the old facts either + const auto trim = r.store.beginOp(r.session, "op-trim", "trim", 4000); + r.store.recordBodies(trim, { { 5, bodyWith(132300), bodyWith(88200) } }); + r.store.recordPresentAudio(trim, 5, 1, "005_1.WAV", 2000, std::nullopt, kStamp + 6000); + r.store.finishOp(trim, OpStatus::done, ""); + CHECK(!r.store.hashOfSighting(card, 5, Sighting { "005_1.WAV", 2000, kStamp + 6000, 88200 }).has_value()); + CHECK(!r.store.hashOfSighting(card, 5, Sighting { "005_1.WAV", 3000, kStamp + 4000, 132300 }).has_value()); + // a row without a stamp (older than store version 9) gets no + // allowance here: three facts are not four + const auto old = r.store.beginOp(r.session, "op-old", "push", 5000); + r.store.recordBodies(old, { { 7, testkit::syntheticSlotBody("Memory 07"), bodyWith(44100) } }); + r.store.recordLanded(old, 7, 1, "007_1.WAV", a, kStamp); + r.store.finishOp(old, OpStatus::done, ""); + CHECK(r.store.hashOfSighting(card, 7, Sighting { "007_1.WAV", 3000, kStamp, 44100 }) == hashA); + sqlite::Statement unstamp(r.store.db(), "UPDATE slot_audio SET modified = NULL WHERE slot = 7"); + unstamp.run(); + CHECK(!r.store.hashOfSighting(card, 7, Sighting { "007_1.WAV", 3000, kStamp, 44100 }).has_value()); + CHECK(!r.store.hashOfSighting(card, 7, Sighting { "007_1.WAV", 3000, 0, 44100 }).has_value()); + // a take row with no body on record for the slot: nothing to hold + // WavLen against, so nothing + const auto bodiless = r.store.beginOp(r.session, "op-bodiless", "push", 6000); + r.store.recordLanded(bodiless, 8, 1, "008_1.WAV", a, kStamp); + r.store.finishOp(bodiless, OpStatus::done, ""); + CHECK(!r.store.hashOfSighting(card, 8, Sighting { "008_1.WAV", 3000, kStamp, 132300 }).has_value()); + // another card: its rows are its own, in both directions + const auto otherCard = r.store.card("other", "RC-5", "Other", 7000); + const auto other = r.store.openSession(otherCard, 7000); + const auto elsewhere = r.store.beginOp(other, "op-elsewhere", "push", 8000); + r.store.recordBodies(elsewhere, { { 9, testkit::syntheticSlotBody("Memory 09"), bodyWith(132300) } }); + r.store.recordLanded(elsewhere, 9, 1, "009_1.WAV", a, kStamp); + r.store.finishOp(elsewhere, OpStatus::done, ""); + CHECK(r.store.hashOfSighting(otherCard, 9, Sighting { "009_1.WAV", 3000, kStamp, 132300 }) == hashA); + CHECK(!r.store.hashOfSighting(card, 9, Sighting { "009_1.WAV", 3000, kStamp, 132300 }).has_value()); + CHECK(!r.store.hashOfSighting(otherCard, 7, Sighting { "007_1.WAV", 3000, kStamp, 44100 }).has_value()); + // the card a marker names, looked up and not touched: last_seen stays + CHECK(r.store.cardFor("other") == otherCard); + CHECK(r.store.cardFor("test-RC-5") == card); + CHECK(!r.store.cardFor("never-met").has_value()); + CHECK_EQ(count(r.store.db(), "SELECT last_seen FROM cards WHERE marker_id = 'other'"), 7000); + + // Back on this card, in its own session. + r.store.selectCard(card); + const auto landedOn = [&](int slot, const std::string& name, const std::string& bytes, + const std::string& opId, std::int64_t at, bool withBody) { + const auto op = r.store.beginOp(r.session, opId, "push", at); + if (withBody) + r.store.recordBodies(op, { { slot, testkit::syntheticSlotBody("Memory"), bodyWith(132300) } }); + r.store.recordLanded(op, slot, 1, name, bytes, kStamp); + r.store.finishOp(op, OpStatus::done, ""); + }; + // a clear after the row: the slot was seen emptied, and the very + // same facts met there again are not the take the row saw — the + // pedal records with a clock that does not keep the date + landedOn(12, "012_1.WAV", a, "op-12", 9000, true); + CHECK(r.store.hashOfSighting(card, 12, Sighting { "012_1.WAV", 3000, kStamp, 132300 }) == hashA); + const auto clear = r.store.beginOp(r.session, "op-clear-12", "clear", 9100); + r.store.recordBodies(clear, { { 12, bodyWith(132300), testkit::syntheticSlotBody("Memory 12") } }); + r.store.keepAudio(clear, 12, 1, "012_1.WAV", a, 9100); + r.store.finishOp(clear, OpStatus::done, ""); + CHECK(!r.store.hashOfSighting(card, 12, Sighting { "012_1.WAV", 3000, kStamp, 132300 }).has_value()); + // a write that failed after the row: the slot is where the failure + // left it, and the row is not its word any more either + landedOn(13, "013_1.WAV", a, "op-13", 9200, true); + const auto failed = r.store.beginOp(r.session, "op-failed-13", "trim", 9300); + r.store.recordBodies(failed, { { 13, bodyWith(132300), bodyWith(88200) } }); + r.store.finishOp(failed, OpStatus::failed, "the card went away"); + CHECK(!r.store.hashOfSighting(card, 13, Sighting { "013_1.WAV", 3000, kStamp, 132300 }).has_value()); + // what does not unsay it: an operation only about the slot that + // changed nothing (#144), and another slot's change + landedOn(14, "014_1.WAV", a, "op-14", 9400, true); + const auto aboutOnly = r.store.beginOp(r.session, "op-about-14", "normalize", 9500); + r.store.recordSubject(aboutOnly, 14); + r.store.finishOp(aboutOnly, OpStatus::done, "already at -18.0 LUFS"); + landedOn(15, "015_1.WAV", b, "op-15", 9600, true); + CHECK(r.store.hashOfSighting(card, 14, Sighting { "014_1.WAV", 3000, kStamp, 132300 }) == hashA); + // only track 1 is asked: the LUFS column measures track 1's take + const auto second = r.store.beginOp(r.session, "op-16", "push", 9700); + r.store.recordBodies(second, { { 16, testkit::syntheticSlotBody("Memory 16"), bodyWith(132300) } }); + r.store.recordPresentAudio(second, 16, 2, "016_1.WAV", 3000, hashA, kStamp); + r.store.finishOp(second, OpStatus::done, ""); + CHECK(!r.store.hashOfSighting(card, 16, Sighting { "016_1.WAV", 3000, kStamp, 132300 }).has_value()); + // two rows agreeing on every fact and not on the bytes (the pedal's + // clock again): the newest one speaks + landedOn(17, "017_1.WAV", a, "op-17a", 9800, true); + landedOn(17, "017_1.WAV", b, "op-17b", 9900, false); + CHECK(r.store.hashOfSighting(card, 17, Sighting { "017_1.WAV", 3000, kStamp, 132300 }) == hashB); + + // and asking wrote nothing anywhere + const auto everything = [&r] { + return std::array { + count(r.store.db(), "SELECT count(*) FROM ops"), + count(r.store.db(), "SELECT count(*) FROM slot_audio"), + count(r.store.db(), "SELECT count(*) FROM slot_changes"), + count(r.store.db(), "SELECT count(*) FROM loudness_readings"), + count(r.store.db(), "SELECT count(*) FROM sessions"), + count(r.store.db(), "SELECT sum(last_seen) FROM cards"), + }; + }; + const auto before = everything(); + for (int slot = 1; slot <= 17; ++slot) + (void) r.store.hashOfSighting(card, slot, Sighting { "005_1.WAV", 3000, kStamp, 132300 }); + (void) r.store.hashOfSighting(card, 17, Sighting { "017_1.WAV", 3000, kStamp, 132300 }); + (void) r.store.cardFor("test-RC-5"); + (void) r.store.cardFor("never-met"); + CHECK(everything() == before); + CHECK_EQ(count(r.store.db(), "SELECT count(*) FROM loudness_readings"), 0); + } + return testkit::summary("history_store_tests"); } diff --git a/tests/inferred_loudness_tests.cpp b/tests/inferred_loudness_tests.cpp new file mode 100644 index 0000000..b1d18a2 --- /dev/null +++ b/tests/inferred_loudness_tests.cpp @@ -0,0 +1,421 @@ +// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE. +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// What the history can say about a take it meets again without reading it +// (#141), end to end on a synthetic pedal: real core commands recorded through +// history::withHistory as the app records them, readings filed as Check +// loudness files them, and the takes then sighted the way the connect scan +// sights them (TakeFacts.h) — name, size and stamp off the directory entry, +// WavLen off the config. What must hold, and what these try to break: +// +// - a take the history measured is recognised on sight and its reading +// comes back as it was taken; a take it never measured comes back as +// nothing — a dash stays a dash, never a guess +// - asking writes nothing: no operation, no row, no reading, no take +// - a rename leaves the take what it was: still recognised +// - a swap moves a take that keeps its own name, and its reading moves +// with it; it renames a take under the pedal's own name (NNN_1.WAV) to +// its new address, and the history cannot follow that one; the slot a +// take left knows nothing about it any more +// - a trim leaves bytes nobody measured: nothing, and nothing for the old +// facts either — until a read measures the new take +// - a card the history never met, and another card, know nothing about +// this card's takes +// - the same bytes pushed to another slot carry their reading there +// - a slot the history saw emptied (a clear) answers nothing for the facts +// it had before, even met again exactly: the pedal's clock does not tell +// two of its recordings of one length apart +// - the limit, and why it is harmless (#142): a different take under the +// very same name, size, stamp and WavLen fools the inference — and the +// store still knows nothing about the bytes really there, so a step that +// reads and hashes them must measure, and normalize, which reads them, +// decides by them against a target the guess says is already met + +#include "support.hpp" + +#include "../app/TakeFacts.h" +#include "../app/history/InferredLoudness.h" +#include "../app/history/SlotLoudness.h" +#include "../app/history/WriteOptionsFactory.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace loopercat; +using history::HistoryRecorder; +using history::HistoryStore; +namespace fs = std::filesystem; + +namespace { + +struct TempDir { + fs::path path; + TempDir() + { + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + path = fs::temp_directory_path() / ("loopercat-inferred-" + std::to_string(stamp)); + fs::remove_all(path); + fs::create_directories(path); + } + ~TempDir() + { + // The store is closed by now (declared after the directory, gone + // before it); a directory that will not go is a failure, not a leak. + std::error_code ec; + fs::remove_all(path, ec); + CHECK(!ec); + } +}; + +fs::path makePedal(const fs::path& root) +{ + const fs::path volume = root / "BOSS RC-5"; + fs::create_directories(volume / "ROLAND" / "WAVE"); + fs::create_directories(volume::dataDir(volume)); + const std::string text = testkit::syntheticMemoryText(); + for (const int fileNo : { 1, 2 }) + commands::writeFileBytes(volume::memoryPath(volume, fileNo), + rc0::setTailMarker(text, fileNo)); + return volume; +} + +// A float32 stereo 997 Hz sine at `dbfs` peak — the pedal's own shape, and +// the tone BS.1770 calibrates on, so a reading can be told from another. +void writeSine(const fs::path& file, 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); + } + commands::writeFileBytes(file, std::string_view(reinterpret_cast(b.data()), b.size())); +} + +// What the pedal worker does around every recorded job: open, run, close with +// the outcome. +template +std::string run(HistoryRecorder& rec, const std::string& opId, const std::string& kind, + const fs::path& volume, Work work) +{ + std::string error; + try { + rec.begin(opId, kind, volume); + work(); + } catch (const std::exception& e) { + error = e.what(); + } + rec.finish(opId, error); + return error; +} + +std::int64_t clockAt = 1'000'000; +std::int64_t tick() { return ++clockAt; } + +commands::WriteOptions options(const std::shared_ptr& rec, const std::string& opId) +{ + return history::withHistory(rec, { .opId = opId }); +} + +std::int64_t count(sqlite::Db& db, const std::string& sql) +{ + sqlite::Statement read(db, sql); + if (!read.step()) + throw Error("count returned no row: " + sql); + return read.integer(0); +} + +// Everything the store holds, counted: the proof that asking wrote nothing. +std::array rows(sqlite::Db& db) +{ + return { count(db, "SELECT count(*) FROM ops"), count(db, "SELECT count(*) FROM slot_audio"), + count(db, "SELECT count(*) FROM slot_changes"), + count(db, "SELECT count(*) FROM loudness_readings"), + count(db, "SELECT count(*) FROM blobs") }; +} + +// A slot as the connect scan sights it: the first take's entry, and the +// config's WavLen — without reading the take. +history::SlotSighting sight(const fs::path& volume, int slot) +{ + const auto files = volume::listSlotWavs(volume, slot); + if (files.empty()) + throw Error("slot " + std::to_string(slot) + " has no take to sight"); + const auto facts = takeFacts(volume::wavDir(volume, slot) / files.front()); + if (!facts) + throw Error("the entry of slot " + std::to_string(slot) + "'s take gave no facts"); + const catalog::SlotInfo info = catalog::readSlot(commands::readMemory(volume), slot); + return { slot, { facts->name, facts->size, facts->modifiedMs, info.frames } }; +} + +// A file's stamp set the way the platform reports it back (FileTime.h). +void setStamp(const fs::path& file, std::int64_t ms) +{ + const std::u8string utf8 = file.u8string(); + const juce::File entry(juce::String::fromUTF8(reinterpret_cast(utf8.data()), + static_cast(utf8.size()))); + if (!entry.setLastModificationTime(juce::Time(ms))) + throw Error("cannot set the modification time of " + file.string()); +} + +bool same(const wav::LoudnessReading& a, const wav::LoudnessReading& b) +{ + return a.integratedLufs.has_value() == b.integratedLufs.has_value() + && (!a.integratedLufs || std::abs(*a.integratedLufs - *b.integratedLufs) <= 1.0e-12) + && std::abs(a.samplePeak - b.samplePeak) <= 1.0e-12 + && std::abs(a.truePeakDb - b.truePeakDb) <= 1.0e-12 + && a.wildSamples == b.wildSamples; +} + +} // namespace + +int main() +{ + TempDir tmp; + const fs::path volume = makePedal(tmp.path); + auto rec = std::make_shared(tmp.path / "history", tick); + + // Two takes land through the app's own wiring: one is then measured the + // way Check loudness measures it, the other never is. + const fs::path loud = tmp.path / "loud.wav"; + const fs::path quiet = tmp.path / "quiet.wav"; + writeSine(loud, 88200, -20.0); + writeSine(quiet, 88200, -28.0); + CHECK_EQ(run(*rec, "op-push-3", "push", volume, [&] { + commands::push(volume, loud, 3, { .write = options(rec, "op-push-3") }); + }), + std::string()); + CHECK_EQ(run(*rec, "op-push-4", "push", volume, [&] { + commands::push(volume, quiet, 4, { .write = options(rec, "op-push-4") }); + }), + std::string()); + HistoryStore& store = rec->store(); + const std::int64_t card = *store.selectedCard(); + const history::SlotLoudness measured = history::readSlotLoudness(volume, 3, *rec); + CHECK(measured.kept); + CHECK(measured.reading.integratedLufs.has_value()); + + // --- the measured take is recognised on sight; the other is nothing --- + { + const auto before = rows(store.db()); + const auto found = history::inferLoudness(store, card, { sight(volume, 3), sight(volume, 4) }); + CHECK_EQ(found.size(), 1u); + if (!found.empty()) { + CHECK_EQ(found.front().sighted.slot, 3); + CHECK_EQ(found.front().sighted.take.name, std::string("loud.wav")); // the sighting it answers + CHECK(same(found.front().reading, measured.reading)); + CHECK(found.front().measuredMs > 1'000'000); // the clock's own time, not a default + } + // asking wrote nothing anywhere + CHECK(rows(store.db()) == before); + // and the sighting itself read no audio: the facts are the entry's + const auto sighted = sight(volume, 3); + CHECK_EQ(sighted.take.name, std::string("loud.wav")); // a pushed take keeps its own name + CHECK_EQ(sighted.take.size, + static_cast(fs::file_size(volume::wavDir(volume, 3) / "loud.wav"))); + CHECK_EQ(sighted.take.frames, 88200); + } + + // --- a card never met, and another card: nothing about this one's takes --- + { + CHECK(!store.cardFor("never-met").has_value()); + const auto other = store.card("other-card", "RC-5", "Other", 5); + CHECK(history::inferLoudness(store, other, { sight(volume, 3) }).empty()); + store.selectCard(card); + } + + // --- a rename leaves the take what it was --- + { + CHECK_EQ(run(*rec, "op-rename", "rename", volume, [&] { + commands::rename(volume, 3, "Intro", options(rec, "op-rename")); + }), + std::string()); + const auto found = history::inferLoudness(store, card, { sight(volume, 3) }); + CHECK_EQ(found.size(), 1u); + CHECK(!found.empty() && same(found.front().reading, measured.reading)); + } + + // --- a swap: a take under its own name moves with its reading --- + { + CHECK_EQ(run(*rec, "op-swap", "swap", volume, [&] { + commands::swap(volume, 3, 7, options(rec, "op-swap")); + }), + std::string()); + CHECK(volume::listSlotWavs(volume, 3).empty()); // the take went to 7 + const auto moved = sight(volume, 7); + CHECK_EQ(moved.take.name, std::string("loud.wav")); + const auto found = history::inferLoudness(store, card, { moved }); + CHECK_EQ(found.size(), 1u); + CHECK(!found.empty() && same(found.front().reading, measured.reading)); + // the slot it left: the swap is that slot's last word, whatever the facts + history::SlotSighting left = moved; + left.slot = 3; + CHECK(history::inferLoudness(store, card, { left }).empty()); + } + + // --- a swap renames a take under the pedal's own name: nothing follows --- + { + const fs::path pedalNamed = tmp.path / "005_1.WAV"; // the name the pedal gives slot 5's take + writeSine(pedalNamed, 88200, -24.0); + CHECK_EQ(run(*rec, "op-push-5", "push", volume, [&] { + commands::push(volume, pedalNamed, 5, { .write = options(rec, "op-push-5") }); + }), + std::string()); + const history::SlotLoudness five = history::readSlotLoudness(volume, 5, *rec); + CHECK(five.kept); + CHECK_EQ(history::inferLoudness(store, card, { sight(volume, 5) }).size(), 1u); + CHECK_EQ(run(*rec, "op-swap-5-8", "swap", volume, [&] { + commands::swap(volume, 5, 8, options(rec, "op-swap-5-8")); + }), + std::string()); + const auto renamed = sight(volume, 8); + CHECK_EQ(renamed.take.name, std::string("008_1.WAV")); // retitled to its new address + CHECK(history::inferLoudness(store, card, { renamed }).empty()); + // the same bytes under the old name, asked of either slot: nothing + history::SlotSighting asBefore = renamed; + asBefore.take.name = "005_1.WAV"; + CHECK(history::inferLoudness(store, card, { asBefore }).empty()); + asBefore.slot = 5; + CHECK(history::inferLoudness(store, card, { asBefore }).empty()); + // the reading itself is still in the history, under the bytes + CHECK(store.readingFor(five.hash).has_value()); + } + + // --- a trim leaves bytes nobody measured: nothing, until a read measures them --- + { + const history::SlotSighting whole = sight(volume, 7); + CHECK_EQ(run(*rec, "op-trim", "trim", volume, [&] { + commands::trim(volume, 7, 0, 44100, { .write = options(rec, "op-trim") }); + }), + std::string()); + const history::SlotSighting trimmed = sight(volume, 7); + CHECK_EQ(trimmed.take.frames, 44100); + CHECK(trimmed.take.size < whole.take.size); + CHECK(history::inferLoudness(store, card, { trimmed }).empty()); + CHECK(history::inferLoudness(store, card, { whole }).empty()); // the old facts: the newest row is the last word + // a read measures the new take and files it: recognised from then on + const history::SlotLoudness again = history::readSlotLoudness(volume, 7, *rec); + CHECK(again.kept); + const auto found = history::inferLoudness(store, card, { trimmed }); + CHECK_EQ(found.size(), 1u); + CHECK(!found.empty() && same(found.front().reading, again.reading)); + // and a stamp one step off is another file: nothing + history::SlotSighting later = trimmed; + later.take.modifiedMs += 2000; // FAT's step + CHECK(history::inferLoudness(store, card, { later }).empty()); + } + + // --- the same bytes in another slot: their reading travels with them --- + // --- then a clear, and the old facts met again: nothing --- + { + CHECK_EQ(run(*rec, "op-push-10", "push", volume, [&] { + commands::push(volume, loud, 10, { .write = options(rec, "op-push-10") }); + }), + std::string()); + const history::SlotSighting pushed = sight(volume, 10); + const auto found = history::inferLoudness(store, card, { pushed }); + CHECK_EQ(found.size(), 1u); // never measured in slot 10, measured as bytes in slot 3 + CHECK(!found.empty() && same(found.front().reading, measured.reading)); + CHECK_EQ(run(*rec, "op-clear-10", "clear", volume, [&] { + commands::clear(volume, { 10 }, { .write = options(rec, "op-clear-10") }); + }), + std::string()); + CHECK(volume::listSlotWavs(volume, 10).empty()); + // The slot is met again showing the very facts the row had — a copy + // put back with its stamp, or the pedal recording one loop of the + // same length off a clock that does not keep the date. The history + // saw the slot emptied after its row: that row is not the slot's + // word any more. + CHECK(history::inferLoudness(store, card, { pushed }).empty()); + } + + // --- fooled, and harmless: the decision reads the bytes (#142) --- + { + CHECK_EQ(run(*rec, "op-push-11", "push", volume, [&] { + commands::push(volume, loud, 11, { .write = options(rec, "op-push-11") }); + }), + std::string()); + const history::SlotSighting before = sight(volume, 11); + // Another take under the very same facts: slot 4's quiet loop, the + // same length and so the same size, written over slot 11's file + // outside the app and stamped as the old one was. WavLen untouched. + const fs::path take11 = volume::wavDir(volume, 11) / before.take.name; + const std::string quietBytes = commands::readFileBytes( + volume::wavDir(volume, 4) / volume::listSlotWavs(volume, 4).front()); + CHECK_EQ(static_cast(quietBytes.size()), before.take.size); + commands::writeFileBytes(take11, quietBytes); + setStamp(take11, before.take.modifiedMs); + const history::SlotSighting after = sight(volume, 11); + CHECK_EQ(after.take.name, before.take.name); + CHECK_EQ(after.take.size, before.take.size); + CHECK_EQ(after.take.modifiedMs, before.take.modifiedMs); + CHECK_EQ(after.take.frames, before.take.frames); + // The limit the issue names: the inference cannot tell, and says the + // loud take's number for the quiet take's bytes. + const auto fooled = history::inferLoudness(store, card, { after }); + CHECK_EQ(fooled.size(), 1u); + CHECK(!fooled.empty() && same(fooled.front().reading, measured.reading)); + // What a decision asks instead: the store, by the hash of the bytes + // really in the slot — and it knows nothing about them. + const std::string onCard = commands::readFileBytes(take11); + const std::string onCardHash = HistoryStore::contentHash(onCard); + CHECK(onCardHash != measured.hash); + CHECK(!store.readingFor(onCardHash).has_value()); + // Normalize to the very loudness the guess shows: by the guess there + // is nothing to do. It reads the bytes, finds the quiet loop, and + // raises it. + const double guessed = *measured.reading.integratedLufs; + commands::NormalizeResult result; + CHECK_EQ(run(*rec, "op-normalize-11", "normalize", volume, [&] { + result = commands::normalize(volume, 11, + { .targetLufs = guessed, + .write = options(rec, "op-normalize-11") }); + }), + std::string()); + CHECK(result.applied); + CHECK(result.measuredLufs < guessed - 6.0); // -28 dBFS against -20: about 8 LU apart + CHECK(result.gainDb > 6.0); + // and the history learned the truth about those bytes from the bytes + const auto learned = store.readingFor(onCardHash); + CHECK(learned.has_value()); + CHECK(learned && learned->reading.integratedLufs + && std::abs(*learned->reading.integratedLufs - result.measuredLufs) <= 1.0e-9); + // while the loud take's reading stands as it was + const auto loudStill = store.readingFor(measured.hash); + CHECK(loudStill && same(loudStill->reading, measured.reading)); + } + + return testkit::summary("inferred_loudness_tests"); +} From e672fb52de292d96593fc4078a504b39b393306b Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 10:39:48 +0200 Subject: [PATCH 03/14] Show a LUFS reading out of the history in lighter ink, with where it came from in its hint A LoudnessCell now says whether its number was read off the card or came out of the history for the bytes the slot appears to hold (#141). fromHistory wears a measured cell's words and verdict with the provenance first in the hint: when it was measured, and that it was not re-measured now. Any reading set over it replaces it; a mutation's clearing takes it away like any cell; a dropped read clears only a pending cell, never this. It draws in the same ink at 60%: the column's own way of saying "less present", as the dash and the empty rows do, rather than a mark that in a 68 px number column would read as part of the number. The LUFS cell now shows its reading's hint on hover; a read in flight and a slot with no loop have none. slot_table_harness checks the flag, the hint, the replacement and the clearing; harnesses need a window server and are not run here. --- app/SlotTable.cpp | 36 +++++++++++++++++-- app/SlotTable.h | 14 +++++++- tests/slot_table_harness.cpp | 68 ++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 3 deletions(-) diff --git a/app/SlotTable.cpp b/app/SlotTable.cpp index 7b62090..57abb84 100644 --- a/app/SlotTable.cpp +++ b/app/SlotTable.cpp @@ -89,6 +89,18 @@ int SlotTable::slotOfRow(int rowIndex) const : 0; } +SlotTable::LoudnessCell SlotTable::LoudnessCell::fromHistory(LoudnessCell measured, + juce::Time measuredAt) +{ + // The provenance leads: a player reading the hint learns first that + // this number was not read off the card today, then what it says. + measured.tooltip = "From the history: measured " + measuredAt.formatted("%d %b %Y %H:%M") + + ", not re-measured now. Selecting the slot or Check loudness reads it again." + + (measured.tooltip.isEmpty() ? juce::String() : " " + measured.tooltip); + measured.inferred = true; + return measured; +} + void SlotTable::setLoudness(int slot, LoudnessCell cell) { loudness_[slot] = std::move(cell); @@ -247,6 +259,21 @@ void SlotTable::selectedRowsChanged(int lastRowSelected) onSlotSelected(slotOfRow(lastRowSelected)); } +// The LUFS cell carries its reading's explanation as a hover hint — where +// the number came from first of all, when it came out of the history rather +// than a read (#141). A read in flight explains nothing yet, and a row whose +// cell is not drawn (no loop in the slot) has nothing to explain. +juce::String SlotTable::getCellTooltip(int row, int columnId) +{ + const int slot = slotOfRow(row); + if (columnId != kLufs || slot <= 0 || !rows_[static_cast(row)].info.hasAudio) + return {}; + const auto found = loudness_.find(slot); + if (found == loudness_.end() || found->second.pending) + return {}; + return found->second.tooltip; +} + void SlotTable::cellDoubleClicked(int row, int columnId, const juce::MouseEvent&) { // Double-click = edit in place, where the card may be written to; on a @@ -573,9 +600,14 @@ void SlotTable::paintCell(juce::Graphics& g, int row, int columnId, int width, i } return; } + // A number out of the history rather than a read (#141) wears the + // same ink, lighter: the column's own vocabulary for "less present" + // (the dash, the empty rows) rather than a mark, which in a 68 px + // number column would read as part of the number. The hint says why. + const float ink = known && found->second.inferred ? 0.6f : 1.0f; g.setColour(!known ? kDim.withAlpha(0.55f) - : found->second.attention ? felitronics::appkit::brand::orange - : kText); + : found->second.attention ? felitronics::appkit::brand::orange.withAlpha(ink) + : kText.withAlpha(ink)); g.drawText(known ? found->second.text : (loaded ? juce::String::fromUTF8("\xe2\x80\x94") : juce::String()), area, juce::Justification::centredRight, true); diff --git a/app/SlotTable.h b/app/SlotTable.h index 92cb9f6..b6459dd 100644 --- a/app/SlotTable.h +++ b/app/SlotTable.h @@ -68,7 +68,18 @@ class SlotTable final : public juce::Component, bool pending = false; // a read is queued or in flight: "…", or the beacon juce::String detail {}; // the sentence the player row shows for this reading bool damaged = false; - juce::String tooltip {}; // the explanation the player row's hint carries + juce::String tooltip {}; // the explanation the cell's and the player row's hint carry + // The number came out of the history for the bytes the slot appears + // to hold, not out of a read of them (#141): drawn in lighter ink, + // its hint says so, and it is never what a decision is made on — + // Normalize reads the card's bytes whatever this cell says. Any real + // read of the slot replaces it with a measured cell. + bool inferred = false; + + // The cell a measured reading would get, worn as inferred: the same + // words, the provenance first in its hint, and the flag set. + // `measuredAt` is when the history took the number. + static LoudnessCell fromHistory(LoudnessCell measured, juce::Time measuredAt); }; void setLoudness(int slot, LoudnessCell cell); void clearLoudness(int slot); @@ -105,6 +116,7 @@ class SlotTable final : public juce::Component, int getNumRows() override; void paintRowBackground(juce::Graphics&, int row, int width, int height, bool selected) override; void paintCell(juce::Graphics&, int row, int columnId, int width, int height, bool selected) override; + juce::String getCellTooltip(int row, int columnId) override; // the LUFS cell's hint void selectedRowsChanged(int lastRowSelected) override; void cellDoubleClicked(int row, int columnId, const juce::MouseEvent&) override; void cellClicked(int row, int columnId, const juce::MouseEvent&) override; diff --git a/tests/slot_table_harness.cpp b/tests/slot_table_harness.cpp index e9c74db..13c6a1f 100644 --- a/tests/slot_table_harness.cpp +++ b/tests/slot_table_harness.cpp @@ -14,6 +14,10 @@ // - a table never told what it may do offers nothing // - the permission table itself: everything for the RC-5, nothing for the // two-track model or an unknown name, and the sentence names the RC-5 +// - a LUFS cell out of the history (#141) is flagged inferred, its hint +// leads with where the number came from, a measured reading replaces +// it, a mutation's clearing takes it away, a dropped read does not; a +// read in flight and a slot with no loop have no hint #include "support.hpp" @@ -165,5 +169,69 @@ int main() model->cellClicked(0, nameColumn, clickAt(table, juce::ModifierKeys::rightButtonModifier)); CHECK_EQ(menus, 1); + // --- the LUFS cell from the history (#141) --- + { + const int lufsColumn = columnNamed(*box, "LUFS"); + CHECK(lufsColumn > 0); + // Row 2 holds a loop (the column draws a cell only for one); row 3 + // does not, whatever the table remembers for its slot. + const int row = 2; + rows[static_cast(row)].info.hasAudio = true; + table.setRows(rows); + const int slot = rows[static_cast(row)].info.slot; + const int emptySlot = rows[3].info.slot; + CHECK(!rows[3].info.hasAudio); + const SlotTable::LoudnessCell measured { "-22.8", true, false, + juce::String::fromUTF8("-22.8 LUFS \xc2\xb7 4.8 dB below target -18.0"), + false, + juce::String::fromUTF8("Peak -3.1 dBTP \xc2\xb7 target -18.0 LUFS.") }; + const juce::Time when(2026, 8, 14, 9, 30); // 14 Sep 2026 09:30, local: JUCE counts months from 0 + // inferred: flagged, the reading's own words and verdict, the + // provenance first in the hint + table.setLoudness(slot, SlotTable::LoudnessCell::fromHistory(measured, when)); + const SlotTable::LoudnessCell* cell = table.loudnessFor(slot); + CHECK(cell != nullptr); + if (cell != nullptr) { + CHECK(cell->inferred); + CHECK(cell->text == measured.text && cell->attention && cell->detail == measured.detail); + CHECK(!cell->pending && !cell->damaged); + } + const juce::String hint = model->getCellTooltip(row, lufsColumn); + CHECK(hint.startsWith("From the history")); + CHECK(hint.contains("measured 14 Sep 2026 09:30")); + CHECK(hint.contains("not re-measured")); + CHECK(hint.endsWith(measured.tooltip)); + // a cell that was measured is never mistaken for one out of the history + CHECK(!measured.inferred); + // a measured reading replaces it: no flag, its own hint + table.setLoudness(slot, measured); + CHECK(table.loudnessFor(slot) != nullptr && !table.loudnessFor(slot)->inferred); + CHECK(model->getCellTooltip(row, lufsColumn) == measured.tooltip); + // a read in flight has nothing to explain yet + table.setLoudness(slot, { juce::String::fromUTF8("\xe2\x80\xa6"), false, true }); + CHECK(model->getCellTooltip(row, lufsColumn).isEmpty()); + // the slot's audio changed: cleared like any cell + table.setLoudness(slot, SlotTable::LoudnessCell::fromHistory(measured, when)); + table.clearLoudness(slot); + CHECK(table.loudnessFor(slot) == nullptr); + CHECK(model->getCellTooltip(row, lufsColumn).isEmpty()); + // a read that will never land takes back only a pending cell: an + // inferred one is not a read in flight + table.setLoudness(slot, SlotTable::LoudnessCell::fromHistory(measured, when)); + table.clearPendingLoudness(slot); + CHECK(table.loudnessFor(slot) != nullptr && table.loudnessFor(slot)->inferred); + // a slot with no loop draws no cell, so its hint is empty too + table.setLoudness(emptySlot, SlotTable::LoudnessCell::fromHistory(measured, when)); + CHECK(model->getCellTooltip(3, lufsColumn).isEmpty()); + // another column, a row that is not there: no hint + CHECK(model->getCellTooltip(row, nameColumn).isEmpty()); + CHECK(model->getCellTooltip(500, lufsColumn).isEmpty()); + CHECK(model->getCellTooltip(-1, lufsColumn).isEmpty()); + // another card: every cell goes, the inferred with the rest + table.clearAllLoudness(); + CHECK(table.loudnessFor(slot) == nullptr); + CHECK(table.loudnessFor(emptySlot) == nullptr); + } + return testkit::summary("slot_table_harness"); } From fbcda29c7aa1c6746a62cebe590ca879ac3507ab Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 10:39:55 +0200 Subject: [PATCH 04/14] Fill the LUFS column on connect from what the history already knows The column started empty on every connect and filled only where something read a take (#141). Now, when a snapshot lands and when a job's busy state drops - the job that reads the card's name included - every slot with a take and an empty cell is put to the store by its scan facts and WavLen, in a quiet background job that needs no card. A reading the history has for those bytes lands as an inferred cell, and only for a slot that still shows the very facts sighted, on the card its marker named, into a cell still empty: a read that came first, or a mutation's clearing, outranks it. It holds off while a foreground job runs, as restoreListening does: the job's rescan lands before its result clears the cells it moved, so the busy drop is the hook after a job and the snapshot hook covers mounts and idle rescans. The player row leaves an inferred cell alone, since its own pass reads the loaded bytes and replaces it. An inferred cell informs the eye and decides nothing: no write path reads the column, and normalize measures the card's bytes before it writes. --- app/MainComponent.cpp | 91 ++++++++++++++++++++++++++++++++++++++++++- app/MainComponent.h | 5 +++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/app/MainComponent.cpp b/app/MainComponent.cpp index 075e47a..89475b4 100644 --- a/app/MainComponent.cpp +++ b/app/MainComponent.cpp @@ -593,6 +593,12 @@ MainComponent::MainComponent(std::string explicitVolume, juce::File dataOverride refreshUndoOffer(); if (historyHost != nullptr && historyHost->isVisible()) feedHistoryWindow(); + // The job's result cleared the cells of the audio it moved, and + // the history may know those bytes (an undo put a measured take + // back); the rescan that came first still saw the old cells. The + // job that read the card's name ends here too, so this is also + // where a freshly named card is first asked about. + inferLoudnessFromHistory(); } }; worker.onJobResult = [this](juce::String description, juce::String error, int batch, @@ -1806,6 +1812,7 @@ void MainComponent::applySnapshot(const PedalSnapshot& latest) updateInspector(); restoreListening(); + inferLoudnessFromHistory(); // the dashes the history can fill, off the worker } // A mutation releases the preview of the slot it rewrites (issue #26 — @@ -1873,10 +1880,12 @@ void MainComponent::slotChosen(int slot, bool startPlaying) player.setTempoNote(tempoNoteFor(row.info)); // The loudness readout follows the loaded loop: whatever the column knows // about it — a check's answer, a read in flight — shows in the player row. + // Not a number out of the history (#141): the pane's own pass is reading + // these very bytes now, and its answer replaces the inferred cell too. if (const SlotTable::LoudnessCell* cell = table.loudnessFor(row.info.slot)) { if (cell->pending) player.setLoudnessPending(row.info.slot); - else + else if (!cell->inferred) player.setLoudness(row.info.slot, cell->detail, cell->attention, cell->damaged, cell->tooltip); } @@ -2456,6 +2465,86 @@ void MainComponent::keepReading(std::string hash, wav::LoudnessReading reading) false }); // and it needs no card: the store is on this computer } +// The LUFS column from what the history already knows (#141). Every slot with +// a take and nothing in its cell is put to the store by the facts the scan +// read off its directory entry and the config — name, size, stamp, WavLen — +// and a reading the history has for those very bytes lands as an inferred +// cell. Off the message thread and without the card, like the history reads: +// the store is on this computer. The card is named by its marker, read on +// this mount, so a store still holding another card's session cannot answer +// for this one; a card the history has never met knows nothing. +// +// Hands off while a job runs, like restoreListening and for the same reason: +// the job's rescan lands before its result clears the cells it moved, so the +// busy drop is the hook after a job, and the snapshot hook covers mounts and +// idle rescans. +// +// What lands informs the eye and decides nothing: a read that came first +// outranks it on arrival, every real read replaces it, every mutation clears +// it, and Normalize measures the card's bytes whatever the column shows. +void MainComponent::inferLoudnessFromHistory() +{ + if (pedalBusy || !card || snapshot.state != lifecycle::State::connected || !snapshot.error.empty()) + return; + std::vector sightings; + for (const SlotRow& row : snapshot.slots) + if (row.take && row.info.hasAudio && table.loudnessFor(row.info.slot) == nullptr) + sightings.push_back({ row.info.slot, + { row.take->name, row.take->size, row.take->modifiedMs, + row.info.frames } }); + if (sightings.empty()) + return; + juce::Component::SafePointer safe(this); + worker.enqueue( + { "Read loudness readings from the history", + 0, + [rec = recorder, markerId = card->id, sightings, target = currentTargetLufs(), safe, + alive = uiAlive](const volume::fs::path&) { + const std::optional cardRow = rec->store().cardFor(markerId); + if (!cardRow) + return; + struct Found { + history::SlotSighting sighted; + LoudnessReport report; + std::int64_t measuredMs; + }; + std::vector found; + for (const history::InferredReading& inferred : + history::inferLoudness(rec->store(), *cardRow, sightings)) + found.push_back({ inferred.sighted, describeReading(inferred.reading, target), + inferred.measuredMs }); + if (found.empty()) + return; + juce::MessageManager::callAsync([safe, alive, markerId, found] { + if (!*alive || safe == nullptr || !safe->card || safe->card->id != markerId) + return; + for (const Found& f : found) { + // The slot still shows the very take that was sighted, + // and nothing landed in its cell meanwhile — a read, or + // a mutation's clearing — both outrank the history. + const SlotRow* row = safe->slotRowFor(f.sighted.slot); + const history::HistoryStore::TakeSighting& seen = f.sighted.take; + if (row == nullptr || !row->take || row->take->name != seen.name + || row->take->size != seen.size || row->take->modifiedMs != seen.modifiedMs + || row->info.frames != seen.frames + || safe->table.loudnessFor(f.sighted.slot) != nullptr) + continue; + safe->table.setLoudness( + f.sighted.slot, + SlotTable::LoudnessCell::fromHistory( + { f.report.cellText, f.report.attention, false, f.report.rowText, + f.report.damaged, f.report.tooltipText }, + juce::Time(f.measuredMs))); + } + }); + }, + nullptr, + 0, + true, // background: nobody sat down to wait for it + true, // quiet: the column is the report; a failure still speaks + 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 3e4c03c..a6e2ca1 100644 --- a/app/MainComponent.h +++ b/app/MainComponent.h @@ -30,6 +30,7 @@ #include "history/SlotRows.h" #include "history/TakeAudition.h" #include "history/HistoryRecorder.h" +#include "history/InferredLoudness.h" #include "history/FirstSeenJob.h" #include "history/UndoRun.h" #include "history/CardRestore.h" @@ -307,6 +308,10 @@ class MainComponent final : public juce::Component, // 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); + // The column from what the history already knows (#141): the slots with + // a take and an empty cell, put to the store by the facts the scan read + // off their directory entries — no audio read, no card needed. + void inferLoudnessFromHistory(); void measureSlotLoudness(int slot); void startLoudnessCheck(const std::vector& slots); void stopLoudnessCheck(); From d558ee63bd56eb62954285779b72f0b6f0b5731e Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 11:32:24 +0200 Subject: [PATCH 05/14] Read a take's size and stamp from one stat The connect scan asked three stats of every take on every 1.5 s poll over a USB mount: is it a file, how big, and when written - each free to see a different file (review of #141). history::statFile asks once and answers both or nothing, and modifiedMs is that same answer's time, so the scan, the history rows and the player's pass all read one number. The number is the one JUCE's getLastModificationTime gives on each platform - the milliseconds on macOS, whole seconds on Linux, the FILETIME's on Windows - so stamps stored before compare equal. A direct stat also tells a stamp of zero from no answer at all, which JUCE could not: an epoch stamp is now described as it is, and only an entry that gives no answer has no facts. --- app/TakeFacts.h | 32 +++++-------- app/history/FileTime.h | 92 +++++++++++++++++++++++++++++--------- tests/take_facts_tests.cpp | 33 ++++++++------ 3 files changed, 100 insertions(+), 57 deletions(-) diff --git a/app/TakeFacts.h b/app/TakeFacts.h index 93d66c9..6ff5020 100644 --- a/app/TakeFacts.h +++ b/app/TakeFacts.h @@ -5,8 +5,6 @@ #include "history/FileTime.h" -#include - #include #include #include @@ -20,15 +18,15 @@ // slot's WavLen these facts identify a take well enough for the history to // say what it already knows about those bytes (HistoryStore::hashOfSighting). // -// The stamp is read the way the store writes it (history::modifiedMs): the -// platform's own number, exactly, because the history compares two sightings -// of one unchanged file for equality — FAT keeps it at two-second steps, and a -// tolerance would make two files one. +// The facts come from one stat (history::statFile), read the way the store +// writes them: the platform's own stamp, exactly, because the history +// compares two sightings of one unchanged file for equality — FAT keeps it +// at two-second steps, and a tolerance would make two files one. // -// An entry the file system will not describe whole — not a regular file, no -// size, no stamp — has no facts: the slot keeps its name on screen and -// nothing is inferred about it, rather than the whole scan failing on one -// take. +// An entry the file system will not describe whole — not a regular file, a +// link to nothing, a name with nothing behind it — has no facts: the slot +// keeps its name on screen and nothing is inferred about it, rather than the +// whole scan failing on one take. //============================================================================== namespace loopercat { @@ -44,18 +42,10 @@ struct TakeFacts inline std::optional takeFacts(const std::filesystem::path& file) { - std::error_code ec; - if (!std::filesystem::is_regular_file(file, ec) || ec) - return std::nullopt; - const std::uintmax_t size = std::filesystem::file_size(file, ec); - if (ec) + const std::optional stat = history::statFile(file); + if (!stat) return std::nullopt; - try { - return TakeFacts { file.filename().string(), static_cast(size), - history::modifiedMs(file) }; - } catch (const Error&) { - return std::nullopt; // the platform gave no stamp for it - } + return TakeFacts { file.filename().string(), stat->size, stat->modifiedMs }; } } // namespace loopercat diff --git a/app/history/FileTime.h b/app/history/FileTime.h index 43ff58f..6ce6a2a 100644 --- a/app/history/FileTime.h +++ b/app/history/FileTime.h @@ -5,41 +5,89 @@ #include -#include - #include #include +#include + +#if !defined(_WIN32) +#include +#endif //============================================================================== -// 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). +// loopercat::history::statFile — what a file's directory entry says about +// it, size and modification time, from ONE query of the file system. The +// connect scan asks this of every take on every poll over a USB mount (#141), +// so the facts come together or not at all, never from three separate stats +// that could each see another file. // -// 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. +// The time is in milliseconds since the epoch, the clock every other time in +// the store is kept in (ops.at, blobs_meta.created), and it is the number +// JUCE's File::getLastModificationTime gives for the same file on each +// platform — macOS keeps the milliseconds, Linux whole seconds, Windows the +// FILETIME's — so stamps stored before this function existed compare equal +// to the ones it reads. #141 compares two sightings of one unchanged file for +// equality, so the number has to be the platform's own, exactly, every time. // -// 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. +// No answer for an entry that is not a regular file (a folder, a link to +// nothing) or that the platform will not describe: the caller decides what +// an undescribed file means. modifiedMs is the time alone, for a caller that +// must have it: an entry that gives none is an error there, since +// slot_audio.modified is NULL only where a row may not carry a stamp +// (Schema.h), and a null that meant "could not tell" would read as one. //============================================================================== namespace loopercat::history { +struct FileStat +{ + std::int64_t size = 0; + std::int64_t modifiedMs = 0; + + bool operator==(const FileStat&) const = default; +}; + +inline std::optional statFile(const std::filesystem::path& file) +{ +#if defined(_WIN32) + // A directory entry built from a path queries the file once and keeps + // what it learned: the type, the size and the write time below are read + // from that one answer. The write time is a FILETIME (100 ns ticks since + // 1601-01-01), which JUCE turns into milliseconds since 1970 this way. + constexpr std::int64_t kFiletimeAtUnixEpoch = 116444736000000000LL; + constexpr std::int64_t kFiletimeTicksPerMs = 10000; + std::error_code ec; + const std::filesystem::directory_entry entry(file, ec); + if (ec || !entry.is_regular_file(ec) || ec) + return std::nullopt; + const std::uintmax_t size = entry.file_size(ec); + if (ec) + return std::nullopt; + const auto written = entry.last_write_time(ec); + if (ec) + return std::nullopt; + return FileStat { static_cast(size), + (static_cast(written.time_since_epoch().count()) - kFiletimeAtUnixEpoch) + / kFiletimeTicksPerMs }; +#else + struct stat info {}; + if (::stat(file.c_str(), &info) != 0 || !S_ISREG(info.st_mode)) + return std::nullopt; +#if defined(__APPLE__) + const std::int64_t ms = static_cast(info.st_mtimespec.tv_sec) * 1000 + + static_cast(info.st_mtimespec.tv_nsec) / 1000000; +#else + const std::int64_t ms = static_cast(info.st_mtime) * 1000; +#endif + return FileStat { static_cast(info.st_size), ms }; +#endif +} + inline std::int64_t modifiedMs(const std::filesystem::path& file) { - 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) + const std::optional stat = statFile(file); + if (!stat) throw Error("cannot read the modification time of " + file.string()); - return stamp; + return stat->modifiedMs; } } // namespace loopercat::history diff --git a/tests/take_facts_tests.cpp b/tests/take_facts_tests.cpp index e67df0f..8493ec9 100644 --- a/tests/take_facts_tests.cpp +++ b/tests/take_facts_tests.cpp @@ -7,10 +7,13 @@ // // - a regular file: its name, its size, and the platform's own stamp, // exactly — the number the history stores for the same file +// - one stat gives the size and the stamp together (history::statFile), +// the stamp JUCE gives for the same file, to the millisecond; a stamp of +// zero is a stamp, told apart from no answer at all — JUCE's getter +// cannot tell the two apart, which is why the facts no longer ask it // - an entry the file system will not describe as a take — a folder under -// the take's name, a name with no file behind it, a link to nothing, a -// file whose stamp reads as the epoch (what the platform answers for a -// file it cannot stat) — has no facts, and no throw +// the take's name, a name with no file behind it, a link to nothing — +// has no facts, and no throw // - the scan reads no audio for any of this: a take that is not a WAV at // all is sighted like any other, and a slot whose entry gives no facts // keeps its name on screen while the scan carries on, no error @@ -83,7 +86,8 @@ std::int64_t stampOf(const fs::path& file) #if !defined(_WIN32) // A file stamped at the epoch, set the POSIX way: JUCE's own setter reads a -// zero time as "leave it as it is". +// zero time as "leave it as it is", and its getter answers zero for a file +// it cannot stat. bool stampAtEpoch(const fs::path& file) { const struct utimbuf epoch { 0, 0 }; @@ -110,6 +114,9 @@ int main() CHECK(facts->modifiedMs > 0); CHECK(*facts == (TakeFacts { "001_1.WAV", 16, stampOf(file) })); } + // the one stat behind them says the same, size and stamp together + CHECK(history::statFile(file) == (history::FileStat { 16, stampOf(file) })); + CHECK_EQ(history::modifiedMs(file), stampOf(file)); // asked again of the unchanged file: the very same facts, to the // millisecond — the history compares them for equality CHECK(takeFacts(file) == facts); @@ -122,15 +129,15 @@ int main() CHECK(!takeFacts(folder).has_value()); CHECK(!takeFacts(tmp.path / "003_1.WAV").has_value()); // nothing behind the name CHECK(!takeFacts(tmp.path / "nowhere" / "004_1.WAV").has_value()); // nor a folder for it + CHECK_THROWS(history::modifiedMs(tmp.path / "003_1.WAV"), "cannot read the modification time"); #if !defined(_WIN32) // the epoch stamp is set through POSIX, and a symbolic // link needs a privilege a Windows test run lacks - // a file whose stamp is the epoch: JUCE answers the same for a file - // it cannot stat, so the stamp says nothing, and nothing is claimed - const fs::path unstamped = tmp.path / "005_1.WAV"; - commands::writeFileBytes(unstamped, "sixteen bytes!!!"); - CHECK(stampAtEpoch(unstamped)); - CHECK_EQ(stampOf(unstamped), 0); - CHECK(!takeFacts(unstamped).has_value()); + // a file stamped at the epoch is described as the stat has it: zero + // is the file's stamp, not "could not tell" + const fs::path epochal = tmp.path / "005_1.WAV"; + commands::writeFileBytes(epochal, "sixteen bytes!!!"); + CHECK(stampAtEpoch(epochal)); + CHECK(takeFacts(epochal) == (std::optional(TakeFacts { "005_1.WAV", 16, 0 }))); // a link whose target is gone: no regular file behind the name const fs::path dangling = tmp.path / "006_1.WAV"; fs::create_symlink(tmp.path / "gone.wav", dangling); @@ -146,9 +153,7 @@ int main() fs::create_directories(volume::wavDir(volume, 2) / "002_1.WAV"); // a folder under the take's name #if !defined(_WIN32) fs::create_directories(volume::wavDir(volume, 4)); - const fs::path unstamped = volume::wavDir(volume, 4) / "004_1.WAV"; - commands::writeFileBytes(unstamped, "RIFF but not a WAV"); - CHECK(stampAtEpoch(unstamped)); // no stamp to tell + fs::create_symlink(tmp.path / "gone.wav", volume::wavDir(volume, 4) / "004_1.WAV"); // a link to nothing #endif PedalWorker scanner(volume.string(), [](const PedalSnapshot&) {}); const PedalSnapshot seen = scanner.scanOnce(); From fb82e620e8a6d6998bc23fb1ca8716342e5103c1 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 11:34:48 +0200 Subject: [PATCH 06/14] Let a real read say which file a slot holds, and keep a guess by name and size a guess Three ways the history vouched for a file it had not seen (review of #141): - A migrated store's rows have no stamp and vouch by name and size, so that a 0.9.5 history stays restorable. recordWhatSlotsHold then wrote that hash beside the file's fresh stamp, and the next connect trusted the row as naming the very file: a take overwritten outside the app with one of the same size, then renamed, showed the old loop's number (S1). heldBefore now hands such a hash on with no stamp - restorable through the allowance, never inferred - and the stamp is the file's in every other case. The comment that said the staleness stays confined said what this makes true. - The pedal's clock does not keep the date, and in the app's own layout the size follows the length, so a loop of the same length recorded again keeps name, size, stamp and WavLen. Check loudness measured the new bytes and the next connect showed the old reading anyway (S3). Store version 10 adds take_sightings: what a real read saw, the file's facts with the hash of the bytes read under them, by card and slot. readSlotLoudness files one when the file stood still under the read, and the player's pass hands its facts up with its hash (one stat before the decode, one after) for keepReading to file. hashOfSighting answers a read of these facts made after the slot's last operation first, and falls back to the row; forgetting a slot takes its sightings along. - A write that failed after its take landed left a row that still answered. Only a finished operation's row does now, and a first sighting's, which commits slot by slot. And one unreadable recorded body no longer fails the whole job (and a banner on every snapshot): inferLoudness reports that slot and asks the rest, and the app logs it once a connection. --- app/MainComponent.cpp | 34 +++++-- app/MainComponent.h | 7 +- app/PlayerPane.cpp | 56 ++++++++---- app/PlayerPane.h | 12 ++- app/history/HistoryRecorder.cpp | 33 ++++--- app/history/HistoryRecorder.h | 14 ++- app/history/HistoryStore.cpp | 106 +++++++++++++++++----- app/history/HistoryStore.h | 72 ++++++++++----- app/history/InferredLoudness.h | 40 ++++++--- app/history/Schema.h | 31 ++++++- app/history/SlotLoudness.h | 18 +++- tests/history_recorder_tests.cpp | 36 ++++++-- tests/history_schema_tests.cpp | 62 ++++++++++--- tests/history_store_tests.cpp | 117 +++++++++++++++++++++++- tests/inferred_loudness_tests.cpp | 144 ++++++++++++++++++++++++++---- tests/player_pane_harness.cpp | 19 +++- 16 files changed, 661 insertions(+), 140 deletions(-) diff --git a/app/MainComponent.cpp b/app/MainComponent.cpp index 89475b4..79f0198 100644 --- a/app/MainComponent.cpp +++ b/app/MainComponent.cpp @@ -366,10 +366,10 @@ MainComponent::MainComponent(std::string explicitVolume, juce::File dataOverride // 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) { + const std::string& hash, const std::optional& seen) { applyLoudnessReport(slot, describeReading(reading, currentTargetLufs()), 0); if (!hash.empty()) // empty: the core would not measure these bytes to this number - keepReading(hash, reading); + keepReading(hash, reading, slot, seen); }; player.onNormalize = [this](int slot) { if (const SlotRow* row = pedalBusy ? nullptr : slotRowFor(slot)) @@ -1716,8 +1716,10 @@ void MainComponent::applySnapshot(const PedalSnapshot& latest) { const lifecycle::State previousState = snapshot.state; const bool anotherVolume = latest.volume != snapshot.volume; - if (anotherVolume) + if (anotherVolume) { table.clearAllLoudness(); // another card is another set of files; readings do not travel + inferenceProblemsLogged = std::make_shared>(); // and its problems were its own (#141) + } snapshot = latest; if (anotherVolume) refreshUndoOffer(); // what Undo offers is read for the card in front of it @@ -2444,15 +2446,20 @@ void MainComponent::enqueueLoudnessRead(int slot, double target, int batch) // 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) +// and a history with no card in front of it simply takes nothing. What the +// pass saw goes with it (#141): the file's facts under the hash, filed for +// the card mounted now, so the next connect knows the file by its bytes. +void MainComponent::keepReading(std::string hash, wav::LoudnessReading reading, int slot, + std::optional seen) { worker.enqueue({ "Keep a loudness reading", 0, - [rec = recorder, key = std::move(hash), reading, + [rec = recorder, key = std::move(hash), reading, slot, facts = std::move(seen), + mounted = volume::fs::path(snapshot.volume), logDir = settings.dataDir()](const volume::fs::path&) { try { - rec->reading(key, reading); + if (rec->reading(key, reading) && facts) + rec->sighted(mounted, slot, facts->name, facts->size, facts->modifiedMs, key); } catch (const std::exception& e) { oplog::append(logDir, "loudness reading not kept in the history: " + juce::String::fromUTF8(e.what())); @@ -2499,18 +2506,27 @@ void MainComponent::inferLoudnessFromHistory() { "Read loudness readings from the history", 0, [rec = recorder, markerId = card->id, sightings, target = currentTargetLufs(), safe, + logged = inferenceProblemsLogged, logDir = settings.dataDir(), alive = uiAlive](const volume::fs::path&) { const std::optional cardRow = rec->store().cardFor(markerId); if (!cardRow) return; + const history::Inference inference = history::inferLoudness(rec->store(), *cardRow, sightings); + // A slot whose record could not be read answers nothing; it is + // said in the log once a connection, not on every snapshot. + for (const history::SlotProblem& problem : inference.problems) + if (logged->insert(problem.slot).second) + oplog::append(logDir, "slot " + juce::String(problem.slot) + + ": the history's record of its take could not be read " + "for the LUFS column: " + + juce::String::fromUTF8(problem.what.c_str())); struct Found { history::SlotSighting sighted; LoudnessReport report; std::int64_t measuredMs; }; std::vector found; - for (const history::InferredReading& inferred : - history::inferLoudness(rec->store(), *cardRow, sightings)) + for (const history::InferredReading& inferred : inference.found) found.push_back({ inferred.sighted, describeReading(inferred.reading, target), inferred.measuredMs }); if (found.empty()) diff --git a/app/MainComponent.h b/app/MainComponent.h index a6e2ca1..d965d25 100644 --- a/app/MainComponent.h +++ b/app/MainComponent.h @@ -306,7 +306,8 @@ class MainComponent final : public juce::Component, 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 keepReading(std::string hash, wav::LoudnessReading reading, int slot, + std::optional seen); void applyLoudnessReport(int slot, const LoudnessReport& report, int batch); // The column from what the history already knows (#141): the slots with // a take and an empty cell, put to the store by the facts the scan read @@ -363,6 +364,10 @@ class MainComponent final : public juce::Component, int checkTotal = 0, checkDone = 0, checkFailed = 0, checkAttention = 0, checkDamaged = 0; bool checkStopping = false; std::vector checkSlots; // to un-pend the cells of a dropped tail + // The slots whose record the LUFS column from the history (#141) could + // not read, logged already for this connection: touched by the worker's + // jobs only, and a new set per card. + std::shared_ptr> inferenceProblemsLogged = std::make_shared>(); PlayerPane player { engine }; juce::String deviceError; int selectedSlot = 0; // what the Properties tab is showing (0 = nothing) diff --git a/app/PlayerPane.cpp b/app/PlayerPane.cpp index 29e5877..368503b 100644 --- a/app/PlayerPane.cpp +++ b/app/PlayerPane.cpp @@ -332,26 +332,38 @@ void PlayerPane::ReadPass::start(std::vector files, int slot) startThread(); } +namespace { +std::filesystem::path pathOf(const juce::File& file) +{ + const juce::String full = file.getFullPathName(); + const char* utf8 = full.toRawUTF8(); + return std::filesystem::path(std::u8string(reinterpret_cast(utf8))); +} +} // namespace + // 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 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. +// file the core reads whole), when the file changed under the pass (its one +// stat — size and stamp — differs from before the decode, or there was none: +// 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 std::optional& before) { + if (!before) + return {}; const auto info = readerloudness::measurableInfo(file); if (!info || !readerloudness::decodesAsCore(reader, *info)) return {}; const auto hash = history::fileContentHash(file, [this] { return threadShouldExit(); }); if (!hash) return {}; - if (file.getSize() != sizeBefore || file.getLastModificationTime() != modifiedBefore) + if (history::statFile(pathOf(file)) != before) return {}; return *hash; } @@ -361,10 +373,11 @@ void PlayerPane::ReadPass::run() const int slot = slot_; juce::Component::SafePointer owner(&owner_); const auto finish = [owner, slot](std::optional reading, - std::string hash) { - juce::MessageManager::callAsync([owner, slot, reading, key = std::move(hash)] { + std::string hash, std::optional seen) { + juce::MessageManager::callAsync([owner, slot, reading, key = std::move(hash), + facts = std::move(seen)] { if (owner != nullptr) - owner->passFinished(slot, reading, key); + owner->passFinished(slot, reading, key, facts); }); }; // The meter measures one loop; a multi-track memory draws its lanes and @@ -372,21 +385,22 @@ void PlayerPane::ReadPass::run() const bool measure = files_.size() == 1; std::optional reading; std::string hash; // of the bytes metered: the key the reading is filed under (#140) + std::optional seen; // the file the hash is of, as its stat read it (#141) 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; } - // 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(); + // What the file was before the decode began, from one stat: a + // replace under the pass shows as a changed size or stamp afterwards, + // and then nothing is filed. + const std::optional before = history::statFile(pathOf(files_[lane])); std::unique_ptr reader( owner_.engine_.formats().createReaderFor(files_[lane])); if (reader == nullptr || reader->numChannels < 1) { if (measure) - finish(std::nullopt, {}); + finish(std::nullopt, {}, std::nullopt); return; } const int channels = static_cast(reader->numChannels); @@ -426,21 +440,25 @@ 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, {}, std::nullopt); // a short read is not a reading return; } reading = meter->reading(); - hash = fileKey(files_[lane], *reader, sizeBefore, modifiedBefore); + hash = fileKey(files_[lane], *reader, before); if (threadShouldExit()) return; // the hash read was cut short for a newer file + if (!hash.empty()) // fileKey proved `before` is the file the hash is of; the + // name spelled as the scan spells it (TakeFacts.h) + seen = TakeFacts { pathOf(files_[lane]).filename().string(), before->size, + before->modifiedMs }; } } if (measure) - finish(reading, hash); + finish(reading, hash, seen); } void PlayerPane::passFinished(int slot, std::optional reading, - std::string contentHash) + std::string contentHash, std::optional seen) { if (slot != slot_) return; // the pass for a slot no longer loaded @@ -452,7 +470,7 @@ void PlayerPane::passFinished(int slot, std::optional read return; } if (onLoudnessRead) - onLoudnessRead(slot, *reading, contentHash); // the owner answers with setLoudness + onLoudnessRead(slot, *reading, contentHash, seen); // 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 924a378..ed74db2 100644 --- a/app/PlayerPane.h +++ b/app/PlayerPane.h @@ -5,6 +5,7 @@ #include "AudioEngine.h" #include "CardPermissions.h" +#include "TakeFacts.h" #include @@ -107,11 +108,16 @@ class PlayerPane final : public juce::Component, // 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. + // With the hash go the file's name, size and stamp as one stat read + // them before the decode and again after the hash (#141): what the read + // saw, for the history to know the file by its bytes next time. Present + // exactly when the hash is. // 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 + std::function& seen)> onLoudnessRead; void setLoudness(int slot, const juce::String& text, bool attention, bool damaged, const juce::String& tooltip); @@ -156,7 +162,7 @@ class PlayerPane final : public juce::Component, void updateLoudnessButtons(); void layoutReadout(); void passFinished(int slot, std::optional reading, - std::string contentHash); // message thread + std::string contentHash, std::optional seen); // 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 @@ -183,7 +189,7 @@ class PlayerPane final : public juce::Component, private: std::string fileKey(const juce::File& file, const juce::AudioFormatReader& reader, - juce::int64 sizeBefore, juce::Time modifiedBefore); + const std::optional& before); PlayerPane& owner_; std::vector files_; diff --git a/app/history/HistoryRecorder.cpp b/app/history/HistoryRecorder.cpp index c835822..f2b6335 100644 --- a/app/history/HistoryRecorder.cpp +++ b/app/history/HistoryRecorder.cpp @@ -224,6 +224,18 @@ bool HistoryRecorder::reading(const std::string& hash, const wav::LoudnessReadin return true; } +bool HistoryRecorder::sighted(const std::filesystem::path& volume, int slot, const std::string& name, + std::int64_t size, std::int64_t modifiedMs, const std::string& hash) +{ + if (!sessionOn(volume)) + return false; + const std::optional card = store().cardFor(sessionMarker_); + if (!card) + throw Error("the open session's card " + sessionMarker_ + " is not in the history"); + store().recordSighting(*card, slot, name, size, modifiedMs, hash, clock_()); + return true; +} + void HistoryRecorder::recordWhatSlotsHold(const Operation& op) { const std::vector slots = store().touchedSlots(op.row); @@ -236,16 +248,17 @@ void HistoryRecorder::recordWhatSlotsHold(const Operation& op) const int from = swapped ? (slot == slots.front() ? slots.back() : slots.front()) : slot; const std::filesystem::path dir = volume::wavDir(op.volume, slot); for (const std::string& name : volume::listSlotWavs(op.volume, slot)) { - std::error_code ec; - 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, modified), - modified); + // One stat for the size and the stamp, asked of the earlier row + // too: a hash is carried over only for the very file that row + // saw, and a hash a stampless row vouched for travels without a + // stamp (HistoryStore::heldBefore). + const std::optional seen = statFile(dir / name); + if (!seen) + throw Error("cannot read the directory entry of " + (dir / name).string()); + const HistoryStore::Held held = + store().heldBefore(op.row, from, name, seen->size, seen->modifiedMs); + store().recordPresentAudio(op.row, slot, kTrack, name, seen->size, held.hash, + held.modifiedMs); } } } diff --git a/app/history/HistoryRecorder.h b/app/history/HistoryRecorder.h index d7f533c..02eb9b3 100644 --- a/app/history/HistoryRecorder.h +++ b/app/history/HistoryRecorder.h @@ -75,6 +75,13 @@ class HistoryRecorder // 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); + // What a real read of a take on `volume` saw (#141): the file's name, + // size and stamp, and the hash of the bytes read under them — filed for + // the card whose session is open on that volume, and not otherwise: a + // read of another card, or of a card no session has opened yet, is not + // this history's to place. Returns whether the sighting went in. + bool sighted(const std::filesystem::path& volume, int slot, const std::string& name, + std::int64_t size, std::int64_t modifiedMs, const std::string& hash); // `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 @@ -141,9 +148,10 @@ class HistoryRecorder // 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, 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. + // when that state's size and stamp are the file's now — or, from a row + // older than the stamps, on name and size, and then without a stamp of + // its own (HistoryStore::heldBefore). 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 2f66978..1a8d8a2 100644 --- a/app/history/HistoryStore.cpp +++ b/app/history/HistoryStore.cpp @@ -277,7 +277,7 @@ 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, - std::int64_t modifiedMs) + std::optional modifiedMs) { 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)"); @@ -286,7 +286,10 @@ void HistoryStore::recordPresentAudio(std::int64_t op, int slot, int track, row.bindBlob(6, *hash); else row.bindNull(6); - row.bind(7, modifiedMs); + if (modifiedMs) + row.bind(7, *modifiedMs); + else + row.bindNull(7); row.run(); } @@ -402,9 +405,8 @@ bool HistoryStore::hasAfterAudio(std::int64_t op, int slot) return read.step(); } -std::optional HistoryStore::hashHeldBefore(std::int64_t op, int slot, - const std::string& name, std::int64_t size, - std::int64_t modifiedMs) +HistoryStore::Held HistoryStore::heldBefore(std::int64_t op, int slot, 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 @@ -417,22 +419,33 @@ std::optional HistoryStore::hashHeldBefore(std::int64_t op, int slo "ORDER BY op DESC LIMIT 1"); read.bind(1, op).bind(2, slot).bindText(3, name); if (!read.step()) - return std::nullopt; + return { std::nullopt, modifiedMs }; if (read.isNull(0)) - return std::nullopt; // no hash: the file changed while the app was away + return { std::nullopt, modifiedMs }; // 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 + return { std::nullopt, modifiedMs }; // 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. + // restored from the History window. The hash it vouches for goes on + // WITHOUT the file's stamp: a stamp beside it would turn a guess by name + // and size into a row that names the very file it saw, and #141 trusts + // such a row (a file overwritten outside the app with one of the same + // size, then renamed, read as the old take). So the allowance stays with + // those rows and the rows they vouch for, until audio lands in the slot. if (read.isNull(2)) - return read.blob(0); + return { read.blob(0), std::nullopt }; if (read.integer(2) != modifiedMs) - return std::nullopt; // the same name and size on another file - return read.blob(0); + return { std::nullopt, modifiedMs }; // the same name and size on another file + return { read.blob(0), modifiedMs }; +} + +std::optional HistoryStore::hashHeldBefore(std::int64_t op, int slot, + const std::string& name, std::int64_t size, + std::int64_t modifiedMs) +{ + return heldBefore(op, slot, name, size, modifiedMs).hash; } std::optional HistoryStore::cardFor(const std::string& markerId) @@ -444,12 +457,57 @@ std::optional HistoryStore::cardFor(const std::string& markerId) return find.integer(0); } +void HistoryStore::recordSighting(std::int64_t card, int slot, const std::string& name, + std::int64_t size, std::int64_t modifiedMs, + const std::string& hash, std::int64_t nowMs) +{ + if (hash.size() != 32) + throw Error("a content hash is 32 bytes, not " + std::to_string(hash.size())); + sqlite::Statement row(db_, "INSERT INTO take_sightings(card, slot, name, size, modified, hash, at) " + "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) " + "ON CONFLICT(card, slot, name, size, modified) " + "DO UPDATE SET hash = excluded.hash, at = excluded.at"); + row.bind(1, card).bind(2, slot).bindText(3, name).bind(4, size).bind(5, modifiedMs) + .bindBlob(6, hash).bind(7, nowMs).run(); +} + std::optional HistoryStore::hashOfSighting(std::int64_t card, int slot, const TakeSighting& seen) { constexpr int kFirstTrack = 1; // the take the LUFS column measures (SlotLoudness.h) + // The slot's last operation on this card: the newest that recorded + // anything about it, a body or a take, either side. + struct Touch { + std::int64_t op; + std::int64_t at; + }; + std::optional last; + { + sqlite::Statement read(db_, "SELECT o.seq, o.at FROM ops o JOIN sessions s ON s.id = o.session " + "WHERE s.card = ?1 AND o.seq IN (SELECT op FROM slot_changes WHERE slot = ?2 " + "UNION SELECT op FROM slot_audio WHERE slot = ?2) " + "ORDER BY o.seq DESC LIMIT 1"); + read.bind(1, card).bind(2, slot); + if (read.step()) + last = Touch { read.integer(0), read.integer(1) }; + } + // A read of these very facts since then is the newest word: the bytes + // were hashed under them, so it outranks a row that only named the file + // — the pedal's clock can give a new recording the old row's facts, and + // a read is how the history learns that. WavLen is not asked of a read: + // the hash is of the bytes themselves. A read older than the slot's last + // operation is history (a clear, a swap, a write came after it). + { + sqlite::Statement read(db_, "SELECT hash, at FROM take_sightings WHERE card = ?1 AND slot = ?2 " + "AND name = ?3 AND size = ?4 AND modified = ?5"); + read.bind(1, card).bind(2, slot).bindText(3, seen.name).bind(4, seen.size).bind(5, seen.modifiedMs); + if (read.step() && (!last || read.integer(1) > last->at)) + return read.blob(0); + } + if (!last) + return std::nullopt; // the history never recorded this slot // The newest row for this file on this card's slot, whatever it says. - sqlite::Statement row(db_, "SELECT a.op, a.hash, a.size, a.modified FROM slot_audio a " + sqlite::Statement row(db_, "SELECT a.op, a.hash, a.size, a.modified, o.status, o.kind FROM slot_audio a " "JOIN ops o ON o.seq = a.op JOIN sessions s ON s.id = o.session " "WHERE s.card = ?1 AND a.slot = ?2 AND a.side = 'after' " "AND a.track = ?3 AND a.name = ?4 ORDER BY a.op DESC LIMIT 1"); @@ -464,12 +522,13 @@ std::optional HistoryStore::hashOfSighting(std::int64_t card, int s // row never saw. The pedal stamps what it records with a clock that does // not keep the date (it read May 1 2019 on 2026-08-10, hardware), so two // of its recordings of one length can carry the same three facts. - sqlite::Statement later(db_, "SELECT 1 FROM (SELECT op FROM slot_changes WHERE slot = ?2 AND op > ?3 " - "UNION ALL SELECT op FROM slot_audio WHERE slot = ?2 AND op > ?3) t " - "JOIN ops o ON o.seq = t.op JOIN sessions s ON s.id = o.session " - "WHERE s.card = ?1 LIMIT 1"); - later.bind(1, card).bind(2, slot).bind(3, op); - if (later.step()) + if (op != last->op) + return std::nullopt; + // Only a finished operation says what the slot holds: a write that failed + // after its take landed left the card wherever the failure did. A first + // sighting is the exception — it commits each slot whole, body and takes, + // so its rows stand while it is still running or was interrupted. + if (row.text(4) != "done" && row.text(5) != "snapshot") return std::nullopt; if (row.isNull(1)) return std::nullopt; // the bytes were strange to the store when it last looked @@ -477,7 +536,8 @@ std::optional HistoryStore::hashOfSighting(std::int64_t card, int s return std::nullopt; // Stored and sighted stamps are the same platform number for the same // file, so equal or not — FAT's two-second steps make a tolerance a way - // of mistaking two files for one. A row without a stamp cannot match. + // of mistaking two files for one. A row without a stamp cannot match: + // older than version 9, or vouched for by such a row. if (row.isNull(3) || row.integer(3) != seen.modifiedMs) return std::nullopt; const std::string hash = row.blob(1); @@ -861,6 +921,10 @@ HistoryStore::ForgetPlan HistoryStore::forgetSlot(std::int64_t cardId, int slot, if (plan.inFlight) throw Error("the slot's history is still being recorded; try again when it finishes"); if (plan.hasHolds() && !confirmHolds) throw Error("clearing pinned entries or Undo/Redo targets needs a separate confirmation"); + // What reads saw in the slot (#141) goes with what the operations said: + // a forgotten slot leaves nothing that names its takes. + sqlite::Statement sightings(db_, "DELETE FROM take_sightings WHERE card = ?1 AND slot = ?2"); + sightings.bind(1, cardId).bind(2, slot).run(); if (!plan.operations.empty()) { // A forgotten slot is only an omission of the FIRST SIGHTING when the // snapshot is what recorded it. A slot the snapshot never reached must diff --git a/app/history/HistoryStore.h b/app/history/HistoryStore.h index 32ace5e..aa456b3 100644 --- a/app/history/HistoryStore.h +++ b/app/history/HistoryStore.h @@ -91,9 +91,10 @@ class HistoryStore 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. + // holds carries one, except rows older than store version 9 and the + // rows whose hash such a row vouched for by name and size (heldBefore, + // #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::int64_t modifiedMs); void finishOp(std::int64_t op, OpStatus status, const std::string& note); @@ -101,10 +102,11 @@ class HistoryStore // 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. `modifiedMs` as in recordLanded. + // and no take can be fetched for it. `modifiedMs` as in recordLanded, + // and absent only where heldBefore says the row may not carry one. 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 modifiedMs); + std::optional modifiedMs); // --- loudness readings (#140): facts about bytes, under their hash --- @@ -180,15 +182,26 @@ 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 the slot's newest row before `op` gives a file of this name — - // 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 + // What a new row for a file of this name inherits from the slot's newest + // row before `op`: that row's hash — only when it carries one, its size + // is the file's, and its modification time is the file's now — and the + // stamp the new row may carry. 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: no hash, 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. + // size alone, as it was written, so migrated histories keep their slots + // restorable — and the hash it vouches for is handed on with NO stamp, + // so the guess stays a guess: a stampless row never tells a connect + // which file is in the slot (hashOfSighting). The stamp is the file's + // otherwise, a row without a hash included. + struct Held { + std::optional hash; + std::optional modifiedMs; + }; + Held heldBefore(std::int64_t op, int slot, const std::string& name, std::int64_t size, + std::int64_t modifiedMs); + // heldBefore's hash alone. std::optional hashHeldBefore(std::int64_t op, int slot, const std::string& name, std::int64_t size, std::int64_t modifiedMs); @@ -208,18 +221,31 @@ class HistoryStore std::int64_t modifiedMs = 0; std::int64_t frames = 0; }; + // What a real read of a take saw (#141): the file's name, size and stamp + // on this card's slot, and the hash of the bytes read under them — the + // check job's read and the player's pass both have all of it. The same + // facts read again keep the newest hash. Refused: a hash that is not 32 + // bytes, and (by the table) a slot outside 1..99. + void recordSighting(std::int64_t card, int slot, const std::string& name, std::int64_t size, + std::int64_t modifiedMs, const std::string& hash, std::int64_t nowMs); + // The hash of the take this card's slot holds, when the history can tell - // it is the one sighted: the slot's newest row for the file name on the - // first track — the take the LUFS column measures — carries a hash, its - // size and its stamp are the entry's, and the body recorded with it (the - // newest at or before that row) says the same WavLen. All four or - // nothing, and only from the slot's last word: a row without a hash, - // with another size or stamp, or without a stamp at all (older than - // store version 9) answers nothing and no older row is asked instead; - // and once anything else about the slot was recorded after that row — a - // clear, a swap, a failed write — the row answers nothing either. Unlike - // hashHeldBefore, a stampless row gets no allowance here: nothing is at - // stake but a number on screen, and that number must not be a guess. + // it is the one sighted, from the newest word about the slot: + // - a read of these very facts (recordSighting) made after the slot's + // last operation: its hash — the bytes were read under them; + // - otherwise the slot's newest row for the file name on the first + // track — the take the LUFS column measures — when it is that last + // operation's own, the operation finished (or is the first + // sighting, which commits slot by slot), it carries a hash, its size + // and its stamp are the entry's, and the body recorded with it (the + // newest at or before it) says the same WavLen. + // All of it or nothing: a row without a hash, with another size or + // stamp, or without a stamp at all (older than store version 9, or + // vouched for by such a row) answers nothing and no older row is asked + // instead; and once anything else about the slot was recorded after it + // — a clear, a swap, a failed write — the row answers nothing either. + // Unlike heldBefore, a stampless row gets no allowance here: nothing is + // at stake but a number on screen, and that number must not be a guess. // Scoped to the card: another card's rows are another card's. A read: // asking writes nothing. std::optional hashOfSighting(std::int64_t card, int slot, diff --git a/app/history/InferredLoudness.h b/app/history/InferredLoudness.h index 747e40a..884e93e 100644 --- a/app/history/InferredLoudness.h +++ b/app/history/InferredLoudness.h @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -23,6 +24,11 @@ // never measured, is simply not in the answer: a dash stays a dash, never a // guess. // +// One slot whose record cannot be read — a stored body the parser refuses, +// a row the store will not give back — answers nothing and is reported by +// its slot in `problems`; the other slots are still asked. (A store that +// cannot be opened fails before any of this, as every history read does.) +// // A read and nothing else — no row is written, no reading filed, no // operation opened. What comes back informs the eye (SlotTable::LoudnessCell:: // inferred) and decides nothing: Normalize measures from the card's bytes @@ -47,18 +53,32 @@ struct InferredReading { std::int64_t measuredMs = 0; // when the bytes were measured }; -inline std::vector inferLoudness(HistoryStore& store, std::int64_t card, - const std::vector& sightings) +struct SlotProblem { + int slot = 0; + std::string what; +}; + +struct Inference { + std::vector found; + std::vector problems; +}; + +inline Inference inferLoudness(HistoryStore& store, std::int64_t card, + const std::vector& sightings) { - std::vector out; + Inference out; for (const SlotSighting& sighted : sightings) { - const std::optional hash = store.hashOfSighting(card, sighted.slot, sighted.take); - if (!hash) - continue; - const std::optional stored = store.readingFor(*hash); - if (!stored) - continue; - out.push_back({ sighted, stored->reading, stored->measuredMs }); + try { + const std::optional hash = store.hashOfSighting(card, sighted.slot, sighted.take); + if (!hash) + continue; + const std::optional stored = store.readingFor(*hash); + if (!stored) + continue; + out.found.push_back({ sighted, stored->reading, stored->measuredMs }); + } catch (const std::exception& e) { + out.problems.push_back({ sighted.slot, e.what() }); + } } return out; } diff --git a/app/history/Schema.h b/app/history/Schema.h index b04a747..c5728b0 100644 --- a/app/history/Schema.h +++ b/app/history/Schema.h @@ -53,8 +53,20 @@ // 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 +// the column, on the rows whose hash such a row vouched for +// by name and size alone (HistoryStore::heldBefore: a guess +// stays a guess), and on `before` rows: an archived take is // leaving its slot, and no connect will meet it there. +// take_sightings +// what a real read of a take saw (#141): the directory +// entry's name, size and stamp, the hash of the bytes read +// under them, and when. A connect believes the newest word +// about a slot, and a read is one: the pedal stamps what it +// records off a clock that does not keep the date, so a loop +// of the same length recorded again can carry the very same +// name, size and stamp as the take a row names, and only a +// read of the bytes tells the two apart. By card and slot; +// forgetting a slot's history takes its sightings with it. // // 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 @@ -78,7 +90,7 @@ namespace loopercat::history::schema { -inline constexpr std::int64_t kVersion = 9; +inline constexpr std::int64_t kVersion = 10; // Version 5 is the first supported store. Future steps append to this array; // kSteps[N] creates version kBaseVersion + N in the same transaction. @@ -216,6 +228,21 @@ CREATE TABLE loudness_readings( -- version, and on the rows of archived takes. ALTER TABLE slot_audio ADD COLUMN modified INTEGER; )sql", +R"sql( +-- What a real read of a take saw (#141), by card and slot: the directory +-- entry's facts and the hash of the bytes read under them. One row per file +-- the slot was seen to hold; the same facts read again keep the newest hash. +CREATE TABLE take_sightings( + card INTEGER NOT NULL REFERENCES cards(id), + slot INTEGER NOT NULL CHECK (slot BETWEEN 1 AND 99), + name TEXT NOT NULL, + size INTEGER NOT NULL CHECK (size >= 0), + modified INTEGER NOT NULL, + hash BLOB NOT NULL CHECK (length(hash) = 32), + at INTEGER NOT NULL, + PRIMARY KEY (card, slot, name, size, modified) +) STRICT; +)sql", }; static_assert(sizeof(kSteps) / sizeof(kSteps[0]) == kVersion - kBaseVersion + 1, diff --git a/app/history/SlotLoudness.h b/app/history/SlotLoudness.h index 6bb03b6..4535061 100644 --- a/app/history/SlotLoudness.h +++ b/app/history/SlotLoudness.h @@ -3,13 +3,16 @@ #pragma once +#include "FileTime.h" #include "HistoryRecorder.h" #include #include #include +#include #include +#include #include //============================================================================== @@ -19,6 +22,12 @@ // is filed in the history under the hash of those very bytes (#140) before // the caller turns it into words. // +// It files what the read saw too (#141): the file's name, size and stamp +// with the hash of the bytes read under them, so the next connect knows the +// file in the slot by the bytes, not by a row that only named it — when the +// file stood still under the read (one stat before, one after, and the bytes +// as long as the entry said), and not otherwise. +// // 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 — @@ -44,13 +53,20 @@ inline SlotLoudness readSlotLoudness(const volume::fs::path& volume, int slot, 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()); + const volume::fs::path file = volume::wavDir(volume, slot) / files.front(); + const std::optional before = statFile(file); + const std::string raw = commands::readFileBytes(file); + const std::optional after = statFile(file); SlotLoudness out; out.reading = wav::measureLoudness( wav::BytesView(reinterpret_cast(raw.data()), raw.size())); out.hash = HistoryStore::contentHash(raw); + const bool stoodStill = before && after && *before == *after + && before->size == static_cast(raw.size()); try { out.kept = recorder.reading(out.hash, out.reading); + if (out.kept && stoodStill) + recorder.sighted(volume, slot, files.front(), before->size, before->modifiedMs, out.hash); } catch (const std::exception& e) { out.failure = e.what(); // the store's refusal, or anything else the filing threw } diff --git a/tests/history_recorder_tests.cpp b/tests/history_recorder_tests.cpp index bc5685a..53c8f13 100644 --- a/tests/history_recorder_tests.cpp +++ b/tests/history_recorder_tests.cpp @@ -641,7 +641,9 @@ int main() // 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. + // and the row it hands the hash to carries no stamp either, so a + // guess by name and size never becomes a row a connect trusts to + // name the very file (#141, review S1). TempDir tmp; const fs::path volume = makePedal(tmp.path); putWav(volume, 5, "005_1.WAV", 132300); @@ -676,11 +678,11 @@ int main() hexFive += digits[b & 0xF]; } CHECK_EQ(rowFive.text(0), hexFive); - CHECK_EQ(rowFive.integer(1), 0); // and the new row is stamped + CHECK_EQ(rowFive.integer(1), 1); // and the new row carries no stamp: a guess stays one } // 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 + // name and size — the allowance's price — and the row it vouches for + // is stampless too, so the price is paid in restorability alone std::string six = commands::readFileBytes(fileSix); six.back() = static_cast(six.back() ^ 0x5a); commands::writeFileBytes(fileSix, six); @@ -690,9 +692,11 @@ int main() }), 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"), + "WHERE o.id = 'op-six' AND a.slot = 6 AND a.hash IS NOT NULL AND a.modified IS NULL"), 1); - // now re-record again under the stamped row: another stamp, no hash + // re-recorded again: the newest row is still a stampless one, so the + // slot stays in the allowance — vouched by name and size, no stamp — + // until audio lands in it 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))); @@ -701,7 +705,25 @@ int main() }), 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"), + "WHERE o.id = 'op-six-again' AND a.slot = 6 AND a.hash IS NOT NULL AND a.modified IS NULL"), + 1); + // audio landing ends the allowance: a trim lands its own hash with + // its stamp, and the next row is held to both + CHECK_EQ(run(*rec, "op-six-trim", "trim", volume, [&] { + commands::trim(volume, 6, 0, 66150, { .write = options(rec, "op-six-trim") }); + }), + 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-trim' AND a.slot = 6 AND a.side = 'after' " + "AND a.hash IS NOT NULL AND a.modified IS NOT NULL"), + 1); + CHECK_EQ(run(*rec, "op-six-named", "rename", volume, [&] { + commands::rename(volume, 6, "Six named", options(rec, "op-six-named")); + }), + 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-named' AND a.slot = 6 AND a.hash IS NOT NULL " + "AND a.modified IS NOT NULL"), 1); } diff --git a/tests/history_schema_tests.cpp b/tests/history_schema_tests.cpp index ed5ed4d..6a93ac3 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, 9); + CHECK_EQ(schema::kVersion, 10); 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"), 9); - CHECK_EQ(count(db, "SELECT count(*) FROM sqlite_master WHERE type = 'table'"), 11); + CHECK_EQ(schema::pragmaInteger(db, "user_version"), 10); + CHECK_EQ(count(db, "SELECT count(*) FROM sqlite_master WHERE type = 'table'"), 12); 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"), 9); + CHECK_EQ(schema::pragmaInteger(reopened.db(), "user_version"), 10); 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"), 9); + CHECK_EQ(schema::pragmaInteger(migrated.db(), "user_version"), 10); 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"), 9); + CHECK_EQ(schema::pragmaInteger(migrated.db(), "user_version"), 10); 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); @@ -294,7 +294,7 @@ int main() HistoryStore migrated(tmp.path); migrated.selectCard(1); auto& db = migrated.db(); - CHECK_EQ(schema::pragmaInteger(db, "user_version"), 9); + CHECK_EQ(schema::pragmaInteger(db, "user_version"), 10); 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()); @@ -323,7 +323,7 @@ int main() CHECK_EQ(count(db, "SELECT count(*) FROM op_subjects"), 1); } { - // A real v8 file opens at v9 with an empty readings table and every + // A real v8 file opens at v10 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. @@ -345,7 +345,7 @@ int main() HistoryStore migrated(tmp.path); migrated.selectCard(1); auto& db = migrated.db(); - CHECK_EQ(schema::pragmaInteger(db, "user_version"), 9); + CHECK_EQ(schema::pragmaInteger(db, "user_version"), 10); 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); @@ -377,11 +377,53 @@ int main() + "', '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 + // and a store already at the current version 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); } + { + // A real v9 file opens at v10 with an empty sightings table and every + // row it had as it was, stamps and NULLs alike: what a read saw is + // only ever what a read saw, and no read is invented (#141). + 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 < 5; ++step) + db.exec(schema::kSteps[step]); + db.exec("PRAGMA user_version = 9"); + db.exec("INSERT INTO cards(model, label, first_seen, last_seen, marker_id, undo_floor) VALUES ('RC-5', 'A', 1, 1, 'v9', 0)"); + db.exec("INSERT INTO sessions(card, connected_at) VALUES (1, 1)"); + db.exec("INSERT INTO ops(id, session, kind, actor, status, at) VALUES ('v9-push', 1, 'push', 'app', 'done', 2)"); + db.exec("INSERT INTO slot_audio(op, slot, side, track, name, size, hash, modified) " + "VALUES (1, 4, 'after', 1, '004_1.WAV', 3000, NULL, 1700000000000)"); + db.exec("INSERT INTO slot_audio(op, slot, side, track, name, size, hash, modified) " + "VALUES (1, 5, 'after', 1, '005_1.WAV', 3000, NULL, NULL)"); + } + HistoryStore migrated(tmp.path); + auto& db = migrated.db(); + CHECK_EQ(schema::pragmaInteger(db, "user_version"), 10); + CHECK_EQ(count(db, "SELECT count(*) FROM sqlite_master WHERE name = 'take_sightings'"), 1); + CHECK_EQ(count(db, "SELECT count(*) FROM take_sightings"), 0); + CHECK_EQ(count(db, "SELECT modified FROM slot_audio WHERE slot = 4"), 1'700'000'000'000); + CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio WHERE slot = 5 AND modified IS NULL"), 1); + // the new table takes a read the way the store files one, and holds its rules + migrated.recordSighting(1, 4, "004_1.WAV", 3000, 1'700'000'000'000, std::string(32, 'h'), 3); + CHECK_EQ(count(db, "SELECT count(*) FROM take_sightings"), 1); + CHECK_THROWS(db.exec("INSERT INTO take_sightings VALUES (1, 4, 'x', 1, 1, x'00', 1)"), "CHECK"); + CHECK_THROWS(db.exec("INSERT INTO take_sightings VALUES (1, 0, 'x', 1, 1, x'" + + std::string(64, 'a') + "', 1)"), + "CHECK"); + CHECK_THROWS(db.exec("INSERT INTO take_sightings VALUES (9, 4, 'x', 1, 1, x'" + + std::string(64, 'a') + "', 1)"), + "FOREIGN KEY"); + CHECK_THROWS(db.exec("INSERT INTO take_sightings VALUES (1, 4, 'x', 1, NULL, x'" + + std::string(64, 'a') + "', 1)"), + "NOT NULL"); + CHECK_EQ(count(db, "SELECT count(*) FROM pragma_foreign_key_check"), 0); + } { TempDir freshDir, migratedDir; { diff --git a/tests/history_store_tests.cpp b/tests/history_store_tests.cpp index b79608f..202a888 100644 --- a/tests/history_store_tests.cpp +++ b/tests/history_store_tests.cpp @@ -25,8 +25,16 @@ // size, stamp, WavLen — or not at all (#141); the newest row is the last // word, and so is anything recorded about the slot after it (a clear, a // failed write) — but not a subject alone, nor another slot's change; -// only track 1 is asked; another card's rows are never asked; asking -// writes nothing +// only track 1 is asked; another card's rows are never asked; only a +// finished operation's row speaks (a first sighting's always does); +// asking writes nothing +// - a real read's sighting is the newest word when it came after the +// slot's last operation: a different hash under the very same facts +// wins over the row, an older read is ignored, another card's or other +// facts' never count; the same facts read again keep the newest hash; +// forgetting the slot takes its sightings, and only its own +// - heldBefore hands a hash vouched for by a stampless row on without a +// stamp, and the file's own stamp in every other case #include "support.hpp" @@ -915,21 +923,122 @@ int main() landedOn(17, "017_1.WAV", a, "op-17a", 9800, true); landedOn(17, "017_1.WAV", b, "op-17b", 9900, false); CHECK(r.store.hashOfSighting(card, 17, Sighting { "017_1.WAV", 3000, kStamp, 132300 }) == hashB); + // a write that failed after its take landed: the row is the slot's + // last word and still says nothing — the operation never finished + { + const auto failedPush = r.store.beginOp(r.session, "op-18", "push", 10000); + r.store.recordLanded(failedPush, 18, 1, "018_1.WAV", a, kStamp); + r.store.recordBodies(failedPush, { { 18, testkit::syntheticSlotBody("Memory 18"), bodyWith(132300) } }); + r.store.finishOp(failedPush, OpStatus::failed, "the pair write failed"); + CHECK(!r.store.hashOfSighting(card, 18, Sighting { "018_1.WAV", 3000, kStamp, 132300 }).has_value()); + } + // a first sighting commits slot by slot: its rows speak while it is + // still running, and after it was interrupted + { + const auto baseline = r.store.firstSeen(r.session, "op-first", 10100); + r.store.snapshotSlot(baseline, 19, bodyWith(132300), { { 1, "019_1.WAV", a, kStamp } }, 10100); + CHECK_EQ(r.store.opStatus(baseline), std::string("pending")); + CHECK(r.store.hashOfSighting(card, 19, Sighting { "019_1.WAV", 3000, kStamp, 132300 }) == hashA); + r.store.finishOp(baseline, OpStatus::interrupted, "the card went away"); + CHECK(r.store.hashOfSighting(card, 19, Sighting { "019_1.WAV", 3000, kStamp, 132300 }) == hashA); + } + + // --- what a real read saw (take_sightings) --- + // + // Theory: a read hashed the bytes under these facts, so a read made + // after the slot's last operation is the newest word about the file + // and outranks a row that only named it; a read older than that + // operation is history; a read of other facts, or on another card, + // says nothing about these. + const std::string c = take(3000, 163); + const std::string hashC = HistoryStore::contentHash(c); + landedOn(20, "020_1.WAV", a, "op-20", 11000, true); + const Sighting twenty { "020_1.WAV", 3000, kStamp, 132300 }; + CHECK(r.store.hashOfSighting(card, 20, twenty) == hashA); + // the pedal recorded the slot again under the same facts; a read saw C + r.store.recordSighting(card, 20, "020_1.WAV", 3000, kStamp, hashC, 11100); + CHECK(r.store.hashOfSighting(card, 20, twenty) == hashC); + // the read needs no WavLen of its own: the hash is of the bytes + CHECK(r.store.hashOfSighting(card, 20, Sighting { "020_1.WAV", 3000, kStamp, 1 }) == hashC); + // read again under the same facts: the newest hash is kept, one row + r.store.recordSighting(card, 20, "020_1.WAV", 3000, kStamp, hashB, 11200); + CHECK(r.store.hashOfSighting(card, 20, twenty) == hashB); + CHECK_EQ(count(r.store.db(), "SELECT count(*) FROM take_sightings WHERE slot = 20"), 1); + // a read of other facts says nothing about these + r.store.recordSighting(card, 21, "021_1.WAV", 3000, kStamp + 2000, hashC, 11300); + CHECK(!r.store.hashOfSighting(card, 21, Sighting { "021_1.WAV", 3000, kStamp, 132300 }).has_value()); + CHECK(!r.store.hashOfSighting(card, 21, Sighting { "021_1.WAV", 2999, kStamp + 2000, 132300 }).has_value()); + CHECK(!r.store.hashOfSighting(card, 21, Sighting { "021_2.WAV", 3000, kStamp + 2000, 132300 }).has_value()); + // ...and a slot nothing but a read ever saw answers by the read alone + CHECK(r.store.hashOfSighting(card, 21, Sighting { "021_1.WAV", 3000, kStamp + 2000, 132300 }) == hashC); + // an operation after the read: the read is history, the row speaks + landedOn(20, "020_1.WAV", a, "op-20-again", 11400, true); + CHECK(r.store.hashOfSighting(card, 20, twenty) == hashA); + // an operation in the same millisecond as a read outranks it + r.store.recordSighting(card, 20, "020_1.WAV", 3000, kStamp, hashC, 11400); + CHECK(r.store.hashOfSighting(card, 20, twenty) == hashA); + // another card's read is never asked + r.store.recordSighting(otherCard, 20, "020_1.WAV", 3000, kStamp, hashB, 12000); + CHECK(r.store.hashOfSighting(card, 20, twenty) == hashA); + CHECK(r.store.hashOfSighting(otherCard, 20, twenty) == hashB); + // refused: a key that is not a hash, a slot the pedal does not have + CHECK_THROWS(r.store.recordSighting(card, 20, "020_1.WAV", 3000, kStamp, "short", 12100), "32 bytes"); + CHECK_THROWS(r.store.recordSighting(card, 100, "100_1.WAV", 3000, kStamp, hashA, 12100), "CHECK"); + // forgetting the slot takes its sightings with it, and only its own + CHECK_EQ(count(r.store.db(), "SELECT count(*) FROM take_sightings WHERE card = " + + std::to_string(card) + " AND slot = 21"), + 1); + r.store.forgetSlot(card, 21, 12200); + CHECK_EQ(count(r.store.db(), "SELECT count(*) FROM take_sightings WHERE card = " + + std::to_string(card) + " AND slot = 21"), + 0); + CHECK(!r.store.hashOfSighting(card, 21, Sighting { "021_1.WAV", 3000, kStamp + 2000, 132300 }).has_value()); + CHECK_EQ(count(r.store.db(), "SELECT count(*) FROM take_sightings WHERE slot = 20"), 2); // both cards' + + // --- what a new row inherits (heldBefore) --- + { + const auto next = r.store.beginOp(r.session, "op-held", "rename", 13000); + // slot 20's newest row is stamped and is the file's: hash and stamp + const HistoryStore::Held same = r.store.heldBefore(next, 20, "020_1.WAV", 3000, kStamp); + CHECK(same.hash == hashA && same.modifiedMs == kStamp); + // another stamp: another file — no hash, the file's own stamp + const HistoryStore::Held moved = r.store.heldBefore(next, 20, "020_1.WAV", 3000, kStamp + 2000); + CHECK(!moved.hash.has_value() && moved.modifiedMs == kStamp + 2000); + // nothing on record: no hash, the file's stamp + const HistoryStore::Held none = r.store.heldBefore(next, 22, "022_1.WAV", 3000, kStamp); + CHECK(!none.hash.has_value() && none.modifiedMs == kStamp); + // a stampless row (older than version 9) vouches by name and size, + // and the hash goes on without a stamp — whatever the file's is + r.store.db().exec("UPDATE slot_audio SET modified = NULL WHERE slot = 20"); + const HistoryStore::Held vouched = r.store.heldBefore(next, 20, "020_1.WAV", 3000, kStamp + 4000); + CHECK(vouched.hash == hashA && !vouched.modifiedMs.has_value()); + const HistoryStore::Held otherSize = r.store.heldBefore(next, 20, "020_1.WAV", 2999, kStamp + 4000); + CHECK(!otherSize.hash.has_value() && otherSize.modifiedMs == kStamp + 4000); + CHECK(r.store.hashHeldBefore(next, 20, "020_1.WAV", 3000, kStamp + 4000) == hashA); + // and a row written that way never tells a connect which file it is + r.store.recordPresentAudio(next, 20, 1, "020_1.WAV", 3000, vouched.hash, vouched.modifiedMs); + r.store.finishOp(next, OpStatus::done, ""); + CHECK(!r.store.hashOfSighting(card, 20, Sighting { "020_1.WAV", 3000, kStamp + 4000, 132300 }).has_value()); + CHECK(!r.store.hashOfSighting(card, 20, twenty).has_value()); + } // and asking wrote nothing anywhere const auto everything = [&r] { - return std::array { + return std::array { count(r.store.db(), "SELECT count(*) FROM ops"), count(r.store.db(), "SELECT count(*) FROM slot_audio"), count(r.store.db(), "SELECT count(*) FROM slot_changes"), count(r.store.db(), "SELECT count(*) FROM loudness_readings"), count(r.store.db(), "SELECT count(*) FROM sessions"), count(r.store.db(), "SELECT sum(last_seen) FROM cards"), + count(r.store.db(), "SELECT count(*) FROM take_sightings"), }; }; const auto before = everything(); - for (int slot = 1; slot <= 17; ++slot) + for (int slot = 1; slot <= 22; ++slot) (void) r.store.hashOfSighting(card, slot, Sighting { "005_1.WAV", 3000, kStamp, 132300 }); + (void) r.store.hashOfSighting(card, 20, twenty); + (void) r.store.heldBefore(1, 20, "020_1.WAV", 3000, kStamp); (void) r.store.hashOfSighting(card, 17, Sighting { "017_1.WAV", 3000, kStamp, 132300 }); (void) r.store.cardFor("test-RC-5"); (void) r.store.cardFor("never-met"); diff --git a/tests/inferred_loudness_tests.cpp b/tests/inferred_loudness_tests.cpp index b1d18a2..8bf5a13 100644 --- a/tests/inferred_loudness_tests.cpp +++ b/tests/inferred_loudness_tests.cpp @@ -30,6 +30,17 @@ // store still knows nothing about the bytes really there, so a step that // reads and hashes them must measure, and normalize, which reads them, // decides by them against a target the guess says is already met +// - and a real read corrects it (review S3): after Check loudness reads the +// new bytes under the same facts, the next inference answers their +// reading, never the old one — and a read whose bytes have no reading +// answers nothing +// - a migrated history (rows with no stamp) never infers through a row it +// vouched for by name and size: a file overwritten outside the app with +// one of the same size, then renamed, is not read as the old take +// (review S1), while the slot stays restorable; on a v9 history the +// same steps carry no hash at all +// - a slot whose recorded body cannot be read answers nothing and is +// reported by its slot; the other slots are still answered #include "support.hpp" @@ -230,7 +241,7 @@ int main() // --- the measured take is recognised on sight; the other is nothing --- { const auto before = rows(store.db()); - const auto found = history::inferLoudness(store, card, { sight(volume, 3), sight(volume, 4) }); + const auto found = history::inferLoudness(store, card, { sight(volume, 3), sight(volume, 4) }).found; CHECK_EQ(found.size(), 1u); if (!found.empty()) { CHECK_EQ(found.front().sighted.slot, 3); @@ -252,7 +263,7 @@ int main() { CHECK(!store.cardFor("never-met").has_value()); const auto other = store.card("other-card", "RC-5", "Other", 5); - CHECK(history::inferLoudness(store, other, { sight(volume, 3) }).empty()); + CHECK(history::inferLoudness(store, other, { sight(volume, 3) }).found.empty()); store.selectCard(card); } @@ -262,7 +273,7 @@ int main() commands::rename(volume, 3, "Intro", options(rec, "op-rename")); }), std::string()); - const auto found = history::inferLoudness(store, card, { sight(volume, 3) }); + const auto found = history::inferLoudness(store, card, { sight(volume, 3) }).found; CHECK_EQ(found.size(), 1u); CHECK(!found.empty() && same(found.front().reading, measured.reading)); } @@ -276,13 +287,13 @@ int main() CHECK(volume::listSlotWavs(volume, 3).empty()); // the take went to 7 const auto moved = sight(volume, 7); CHECK_EQ(moved.take.name, std::string("loud.wav")); - const auto found = history::inferLoudness(store, card, { moved }); + const auto found = history::inferLoudness(store, card, { moved }).found; CHECK_EQ(found.size(), 1u); CHECK(!found.empty() && same(found.front().reading, measured.reading)); // the slot it left: the swap is that slot's last word, whatever the facts history::SlotSighting left = moved; left.slot = 3; - CHECK(history::inferLoudness(store, card, { left }).empty()); + CHECK(history::inferLoudness(store, card, { left }).found.empty()); } // --- a swap renames a take under the pedal's own name: nothing follows --- @@ -295,20 +306,20 @@ int main() std::string()); const history::SlotLoudness five = history::readSlotLoudness(volume, 5, *rec); CHECK(five.kept); - CHECK_EQ(history::inferLoudness(store, card, { sight(volume, 5) }).size(), 1u); + CHECK_EQ(history::inferLoudness(store, card, { sight(volume, 5) }).found.size(), 1u); CHECK_EQ(run(*rec, "op-swap-5-8", "swap", volume, [&] { commands::swap(volume, 5, 8, options(rec, "op-swap-5-8")); }), std::string()); const auto renamed = sight(volume, 8); CHECK_EQ(renamed.take.name, std::string("008_1.WAV")); // retitled to its new address - CHECK(history::inferLoudness(store, card, { renamed }).empty()); + CHECK(history::inferLoudness(store, card, { renamed }).found.empty()); // the same bytes under the old name, asked of either slot: nothing history::SlotSighting asBefore = renamed; asBefore.take.name = "005_1.WAV"; - CHECK(history::inferLoudness(store, card, { asBefore }).empty()); + CHECK(history::inferLoudness(store, card, { asBefore }).found.empty()); asBefore.slot = 5; - CHECK(history::inferLoudness(store, card, { asBefore }).empty()); + CHECK(history::inferLoudness(store, card, { asBefore }).found.empty()); // the reading itself is still in the history, under the bytes CHECK(store.readingFor(five.hash).has_value()); } @@ -323,18 +334,18 @@ int main() const history::SlotSighting trimmed = sight(volume, 7); CHECK_EQ(trimmed.take.frames, 44100); CHECK(trimmed.take.size < whole.take.size); - CHECK(history::inferLoudness(store, card, { trimmed }).empty()); - CHECK(history::inferLoudness(store, card, { whole }).empty()); // the old facts: the newest row is the last word + CHECK(history::inferLoudness(store, card, { trimmed }).found.empty()); + CHECK(history::inferLoudness(store, card, { whole }).found.empty()); // the old facts: the newest row is the last word // a read measures the new take and files it: recognised from then on const history::SlotLoudness again = history::readSlotLoudness(volume, 7, *rec); CHECK(again.kept); - const auto found = history::inferLoudness(store, card, { trimmed }); + const auto found = history::inferLoudness(store, card, { trimmed }).found; CHECK_EQ(found.size(), 1u); CHECK(!found.empty() && same(found.front().reading, again.reading)); // and a stamp one step off is another file: nothing history::SlotSighting later = trimmed; later.take.modifiedMs += 2000; // FAT's step - CHECK(history::inferLoudness(store, card, { later }).empty()); + CHECK(history::inferLoudness(store, card, { later }).found.empty()); } // --- the same bytes in another slot: their reading travels with them --- @@ -345,7 +356,7 @@ int main() }), std::string()); const history::SlotSighting pushed = sight(volume, 10); - const auto found = history::inferLoudness(store, card, { pushed }); + const auto found = history::inferLoudness(store, card, { pushed }).found; CHECK_EQ(found.size(), 1u); // never measured in slot 10, measured as bytes in slot 3 CHECK(!found.empty() && same(found.front().reading, measured.reading)); CHECK_EQ(run(*rec, "op-clear-10", "clear", volume, [&] { @@ -358,7 +369,7 @@ int main() // same length off a clock that does not keep the date. The history // saw the slot emptied after its row: that row is not the slot's // word any more. - CHECK(history::inferLoudness(store, card, { pushed }).empty()); + CHECK(history::inferLoudness(store, card, { pushed }).found.empty()); } // --- fooled, and harmless: the decision reads the bytes (#142) --- @@ -384,7 +395,7 @@ int main() CHECK_EQ(after.take.frames, before.take.frames); // The limit the issue names: the inference cannot tell, and says the // loud take's number for the quiet take's bytes. - const auto fooled = history::inferLoudness(store, card, { after }); + const auto fooled = history::inferLoudness(store, card, { after }).found; CHECK_EQ(fooled.size(), 1u); CHECK(!fooled.empty() && same(fooled.front().reading, measured.reading)); // What a decision asks instead: the store, by the hash of the bytes @@ -417,5 +428,106 @@ int main() CHECK(loudStill && same(loudStill->reading, measured.reading)); } + // --- a real read corrects the guess (review S3) --- + { + CHECK_EQ(run(*rec, "op-push-12", "push", volume, [&] { + commands::push(volume, loud, 12, { .write = options(rec, "op-push-12") }); + }), + std::string()); + const history::SlotSighting before = sight(volume, 12); + // the pedal records the slot again, the same length, off a clock that + // does not keep the date: the same name, size, stamp and WavLen + const fs::path take12 = volume::wavDir(volume, 12) / before.take.name; + commands::writeFileBytes(take12, commands::readFileBytes( + volume::wavDir(volume, 4) / volume::listSlotWavs(volume, 4).front())); + setStamp(take12, before.take.modifiedMs); + const history::SlotSighting again = sight(volume, 12); + CHECK_EQ(again.take.modifiedMs, before.take.modifiedMs); + CHECK_EQ(again.take.size, before.take.size); + const auto guessed = history::inferLoudness(store, card, { again }).found; + CHECK(guessed.size() == 1u && same(guessed.front().reading, measured.reading)); // fooled, as before + // Check loudness reads the bytes: the new reading, and what the read saw + const history::SlotLoudness checked = history::readSlotLoudness(volume, 12, *rec); + CHECK(checked.kept); + CHECK(checked.failure.empty()); + CHECK(checked.hash != measured.hash); + CHECK_EQ(count(store.db(), "SELECT count(*) FROM take_sightings WHERE slot = 12"), 1); + // the next connect: the read's word, never the old take's + const auto corrected = history::inferLoudness(store, card, { again }).found; + CHECK_EQ(corrected.size(), 1u); + CHECK(!corrected.empty() && same(corrected.front().reading, checked.reading)); + CHECK(!corrected.empty() && !same(corrected.front().reading, measured.reading)); + // a read whose bytes have no reading on file answers nothing — not the old take + store.recordSighting(card, 12, again.take.name, again.take.size, again.take.modifiedMs, + HistoryStore::contentHash("bytes nobody measured"), tick()); + CHECK(history::inferLoudness(store, card, { again }).found.empty()); + } + + // --- a migrated history never infers through a row it vouched for (review S1) --- + { + CHECK_EQ(run(*rec, "op-push-13", "push", volume, [&] { + commands::push(volume, loud, 13, { .write = options(rec, "op-push-13") }); + }), + std::string()); + // the push's row as a store older than version 9 left it: no stamp + store.db().exec("UPDATE slot_audio SET modified = NULL WHERE slot = 13"); + // overwritten outside the app: another loop, the same size, a new stamp + const history::SlotSighting pushed = sight(volume, 13); + const fs::path take13 = volume::wavDir(volume, 13) / pushed.take.name; + const std::string quietBytes = + commands::readFileBytes(volume::wavDir(volume, 4) / volume::listSlotWavs(volume, 4).front()); + commands::writeFileBytes(take13, quietBytes); + setStamp(take13, pushed.take.modifiedMs + 4000); + CHECK_EQ(run(*rec, "op-rename-13", "rename", volume, [&] { + commands::rename(volume, 13, "Thirteen", options(rec, "op-rename-13")); + }), + std::string()); + // the rename's row: the old hash, vouched for by name and size so the + // slot stays restorable — and no stamp beside it + { + sqlite::Statement row(store.db(), "SELECT a.hash, a.modified FROM slot_audio a JOIN ops o ON o.seq = a.op " + "WHERE o.id = 'op-rename-13' AND a.slot = 13 AND a.side = 'after'"); + CHECK(row.step()); + CHECK(!row.isNull(0) && row.blob(0) == measured.hash); + CHECK(row.isNull(1)); + } + // and the connect does not read the quiet loop as the loud one + CHECK(history::inferLoudness(store, card, { sight(volume, 13) }).found.empty()); + // the v9 control: the same steps on a stamped row carry no hash at all + CHECK_EQ(run(*rec, "op-push-14", "push", volume, [&] { + commands::push(volume, loud, 14, { .write = options(rec, "op-push-14") }); + }), + std::string()); + const history::SlotSighting stamped = sight(volume, 14); + const fs::path take14 = volume::wavDir(volume, 14) / stamped.take.name; + commands::writeFileBytes(take14, quietBytes); + setStamp(take14, stamped.take.modifiedMs + 4000); + CHECK_EQ(run(*rec, "op-rename-14", "rename", volume, [&] { + commands::rename(volume, 14, "Fourteen", options(rec, "op-rename-14")); + }), + std::string()); + CHECK_EQ(count(store.db(), "SELECT count(*) FROM slot_audio a JOIN ops o ON o.seq = a.op " + "WHERE o.id = 'op-rename-14' AND a.slot = 14 AND a.hash IS NULL " + "AND a.modified IS NOT NULL"), + 1); + CHECK(history::inferLoudness(store, card, { sight(volume, 14) }).found.empty()); + } + + // --- one slot's unreadable record: that slot answers nothing, the others still answer --- + { + CHECK_EQ(run(*rec, "op-push-15", "push", volume, [&] { + commands::push(volume, loud, 15, { .write = options(rec, "op-push-15") }); + }), + std::string()); + const history::SlotSighting fifteen = sight(volume, 15); + CHECK_EQ(history::inferLoudness(store, card, { fifteen }).found.size(), 1u); + store.db().exec("UPDATE slot_changes SET after_body = x'6e6f742061206d656d6f7279' WHERE slot = 15"); + const history::Inference both = history::inferLoudness(store, card, { fifteen, sight(volume, 7) }); + CHECK_EQ(both.problems.size(), 1u); + CHECK(!both.problems.empty() && both.problems.front().slot == 15 && !both.problems.front().what.empty()); + CHECK_EQ(both.found.size(), 1u); // slot 7, read and measured after its trim + CHECK(!both.found.empty() && both.found.front().sighted.slot == 7); + } + return testkit::summary("inferred_loudness_tests"); } diff --git a/tests/player_pane_harness.cpp b/tests/player_pane_harness.cpp index c76c909..b3d4553 100644 --- a/tests/player_pane_harness.cpp +++ b/tests/player_pane_harness.cpp @@ -16,12 +16,15 @@ // - the pane asks the card every time: a card that changes under a loaded // loop changes the buttons on the next layout, no reload needed // - a pane never told what it may do offers nothing +// - the facts of the file go up with its hash and only with it (#141): +// name, size and stamp as one stat reads them #include "support.hpp" #include "../app/CardPermissions.h" #include "../app/PlayerPane.h" #include "../app/history/ContentHash.h" +#include "../app/history/FileTime.h" #include @@ -127,9 +130,12 @@ int main() // 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) { + std::optional readSeen; + pane.onLoudnessRead = [&](int slot, const wav::LoudnessReading&, const std::string& hash, + const std::optional& seen) { readCame = true; readHash = hash; + readSeen = seen; pane.setLoudness(slot, "-14.0 LUFS", false, false, ""); }; @@ -143,6 +149,7 @@ int main() CHECK(normalize->isEnabled()); // so a dropped press below is the card's doing CHECK(readCame); // the number came up for the screen... CHECK(readHash.empty()); // ...with no key for the history: pcm16 is not the pedal's + CHECK(!readSeen.has_value()); // ...and so no sighting either }; // --- a pane never told what it may do offers nothing --- @@ -231,6 +238,16 @@ int main() settle(pane); CHECK(readCame); CHECK(readHash == floatHash); // the hash of the file's own bytes, every one of them + // ...and what the read saw: the file's name, size and stamp, as one stat has them + const auto floatStat = history::statFile(floatFile.getFullPathName().toStdString()); + CHECK(floatStat.has_value()); + CHECK(readSeen.has_value()); + if (readSeen && floatStat) { + CHECK_EQ(readSeen->name, floatFile.getFileName().toStdString()); + CHECK_EQ(readSeen->size, static_cast(floatBytes.size())); + CHECK_EQ(readSeen->size, floatStat->size); + CHECK_EQ(readSeen->modifiedMs, floatStat->modifiedMs); + } pane.clear(); wavFile.deleteFile(); From 71f740384a7fd167fd25762c9c10c21fe1f9de1f Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 11:37:08 +0200 Subject: [PATCH 07/14] Paint a guess without a verdict, and name the double-click in its hint An inferred cell kept the reading's attention colour, which says "Normalize would change this" - a verdict, painted on a number nobody read today (review of #141). It now carries no attention: the number alone, in light ink. Its hint promised "Selecting the slot or Check loudness reads it again", and twice that was not true: the slot already in the player is not read again on selection, and a two-track memory on a card this app only reads has no Check loudness. It now names the one gesture that measures on every card and every slot: "Double-click to measure it now." The table also says whether its LUFS column is shown, for the app to leave a hidden column alone. --- app/SlotTable.cpp | 27 +++++++++++++++++++-------- app/SlotTable.h | 15 +++++++++------ tests/slot_table_harness.cpp | 26 +++++++++++++++++++------- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/app/SlotTable.cpp b/app/SlotTable.cpp index 57abb84..f3d9cee 100644 --- a/app/SlotTable.cpp +++ b/app/SlotTable.cpp @@ -93,10 +93,15 @@ SlotTable::LoudnessCell SlotTable::LoudnessCell::fromHistory(LoudnessCell measur juce::Time measuredAt) { // The provenance leads: a player reading the hint learns first that - // this number was not read off the card today, then what it says. + // this number was not read off the card today, then the one gesture + // that reads it on every card and every slot — the double-click on this + // very cell — then what the number says. measured.tooltip = "From the history: measured " + measuredAt.formatted("%d %b %Y %H:%M") - + ", not re-measured now. Selecting the slot or Check loudness reads it again." + + ", not re-measured. Double-click to measure it now." + (measured.tooltip.isEmpty() ? juce::String() : " " + measured.tooltip); + // No attention: "Normalize would change this" is a verdict, and a guess + // passes none. The number alone, in light ink. + measured.attention = false; measured.inferred = true; return measured; } @@ -150,6 +155,11 @@ void SlotTable::setOptionalColumns(bool oneShot, bool countIn, bool loudness) header.setColumnVisible(kLufs, loudness); } +bool SlotTable::loudnessColumnVisible() const +{ + return table_.getHeader().isColumnVisible(kLufs); +} + void SlotTable::selectSlot(int slot) { const int row = rowOfSlot(slot); @@ -601,13 +611,14 @@ void SlotTable::paintCell(juce::Graphics& g, int row, int columnId, int width, i return; } // A number out of the history rather than a read (#141) wears the - // same ink, lighter: the column's own vocabulary for "less present" + // text ink, lighter: the column's own vocabulary for "less present" // (the dash, the empty rows) rather than a mark, which in a 68 px - // number column would read as part of the number. The hint says why. - const float ink = known && found->second.inferred ? 0.6f : 1.0f; - g.setColour(!known ? kDim.withAlpha(0.55f) - : found->second.attention ? felitronics::appkit::brand::orange.withAlpha(ink) - : kText.withAlpha(ink)); + // number column would read as part of the number. It never wears + // the attention colour (LoudnessCell::fromHistory). The hint says why. + g.setColour(!known ? kDim.withAlpha(0.55f) + : found->second.inferred ? kText.withAlpha(0.6f) + : found->second.attention ? felitronics::appkit::brand::orange + : kText); g.drawText(known ? found->second.text : (loaded ? juce::String::fromUTF8("\xe2\x80\x94") : juce::String()), area, juce::Justification::centredRight, true); diff --git a/app/SlotTable.h b/app/SlotTable.h index b6459dd..04dea98 100644 --- a/app/SlotTable.h +++ b/app/SlotTable.h @@ -58,6 +58,7 @@ class SlotTable final : public juce::Component, // The behaviour columns and the LUFS column are a preference (Settings -> // Columns): the pedal's own facts always show, these are opted into. void setOptionalColumns(bool oneShot, bool countIn, bool loudness); + bool loudnessColumnVisible() const; // The LUFS column's cells (issue #61): filled by a measure or a check, // cleared when the slot's audio changes. Keyed by slot, so they survive @@ -70,15 +71,17 @@ class SlotTable final : public juce::Component, bool damaged = false; juce::String tooltip {}; // the explanation the cell's and the player row's hint carry // The number came out of the history for the bytes the slot appears - // to hold, not out of a read of them (#141): drawn in lighter ink, - // its hint says so, and it is never what a decision is made on — - // Normalize reads the card's bytes whatever this cell says. Any real - // read of the slot replaces it with a measured cell. + // to hold, not out of a read of them (#141): drawn in lighter ink and + // never in the attention colour, its hint says so, and it is never + // what a decision is made on — Normalize reads the card's bytes + // whatever this cell says. Any real read of the slot replaces it + // with a measured cell. bool inferred = false; // The cell a measured reading would get, worn as inferred: the same - // words, the provenance first in its hint, and the flag set. - // `measuredAt` is when the history took the number. + // words, no attention, the provenance and the double-click first in + // its hint, and the flag set. `measuredAt` is when the history took + // the number. static LoudnessCell fromHistory(LoudnessCell measured, juce::Time measuredAt); }; void setLoudness(int slot, LoudnessCell cell); diff --git a/tests/slot_table_harness.cpp b/tests/slot_table_harness.cpp index 13c6a1f..b5ca699 100644 --- a/tests/slot_table_harness.cpp +++ b/tests/slot_table_harness.cpp @@ -14,10 +14,12 @@ // - a table never told what it may do offers nothing // - the permission table itself: everything for the RC-5, nothing for the // two-track model or an unknown name, and the sentence names the RC-5 -// - a LUFS cell out of the history (#141) is flagged inferred, its hint -// leads with where the number came from, a measured reading replaces -// it, a mutation's clearing takes it away, a dropped read does not; a -// read in flight and a slot with no loop have no hint +// - a LUFS cell out of the history (#141) is flagged inferred, wears no +// attention whatever the reading's verdict, its hint leads with where +// the number came from and the double-click that measures it, a +// measured reading replaces it, a mutation's clearing takes it away, a +// dropped read does not; a read in flight and a slot with no loop have +// no hint; the table says whether the LUFS column is shown #include "support.hpp" @@ -186,20 +188,25 @@ int main() false, juce::String::fromUTF8("Peak -3.1 dBTP \xc2\xb7 target -18.0 LUFS.") }; const juce::Time when(2026, 8, 14, 9, 30); // 14 Sep 2026 09:30, local: JUCE counts months from 0 - // inferred: flagged, the reading's own words and verdict, the - // provenance first in the hint + // inferred: flagged, the reading's own words, no verdict — the + // measured reading asked for attention, a guess does not — and the + // provenance first in the hint, then the gesture that measures it + CHECK(measured.attention); table.setLoudness(slot, SlotTable::LoudnessCell::fromHistory(measured, when)); const SlotTable::LoudnessCell* cell = table.loudnessFor(slot); CHECK(cell != nullptr); if (cell != nullptr) { CHECK(cell->inferred); - CHECK(cell->text == measured.text && cell->attention && cell->detail == measured.detail); + CHECK(cell->text == measured.text && cell->detail == measured.detail); + CHECK(!cell->attention); CHECK(!cell->pending && !cell->damaged); } const juce::String hint = model->getCellTooltip(row, lufsColumn); CHECK(hint.startsWith("From the history")); CHECK(hint.contains("measured 14 Sep 2026 09:30")); CHECK(hint.contains("not re-measured")); + CHECK(hint.contains("Double-click to measure it now.")); + CHECK(!hint.contains("Check loudness")); // not on every card, so not promised CHECK(hint.endsWith(measured.tooltip)); // a cell that was measured is never mistaken for one out of the history CHECK(!measured.inferred); @@ -231,6 +238,11 @@ int main() table.clearAllLoudness(); CHECK(table.loudnessFor(slot) == nullptr); CHECK(table.loudnessFor(emptySlot) == nullptr); + // whether the column is on screen, as the preference set it + table.setOptionalColumns(true, false, true); + CHECK(table.loudnessColumnVisible()); + table.setOptionalColumns(true, false, false); + CHECK(!table.loudnessColumnVisible()); } return testkit::summary("slot_table_harness"); From 7366a3b6e0860b495f3349cdf5e7eee546a0b41c Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 11:39:17 +0200 Subject: [PATCH 08/14] Measure the player's slot instead of guessing it, and keep a failed read's slot a dash The slot loaded in the player was refilled as inferred after an Undo, Redo, Swap or Restore on another slot cleared every cell, and clicking its row did nothing: the player does not read again a file it already has (review of #141). The inference now never answers for the player's slot. It is the one slot the app can measure at once, so when nothing is reading it, the existing background read does. A guess no longer outlives a read that could not confirm it. A player pass that cannot measure the file tells the owner, and the inferred cell goes; that file, and the file of a failed Check, is not asked of the history again in this connection. A normalize meters the slot's bytes on its way, the bulk run included: the recorder hands the reading on, and it replaces the cell as a measurement - a write that follows clears it, as every rewrite does. Nothing is inferred while the LUFS column is hidden; showing it asks the history then. --- app/MainComponent.cpp | 69 ++++++++++++++++++++++++++++----- app/MainComponent.h | 13 +++++-- app/PlayerPane.cpp | 2 + app/PlayerPane.h | 7 ++++ app/history/HistoryRecorder.cpp | 2 + app/history/HistoryRecorder.h | 5 +++ tests/player_pane_harness.cpp | 27 ++++++++++++- 7 files changed, 110 insertions(+), 15 deletions(-) diff --git a/app/MainComponent.cpp b/app/MainComponent.cpp index 79f0198..bd95840 100644 --- a/app/MainComponent.cpp +++ b/app/MainComponent.cpp @@ -371,6 +371,17 @@ MainComponent::MainComponent(std::string explicitVolume, juce::File dataOverride if (!hash.empty()) // empty: the core would not measure these bytes to this number keepReading(hash, reading, slot, seen); }; + player.onLoudnessFailed = [this](int slot) { readFailed(slot); }; + // A command that meters on its way (normalize, the bulk run included) + // read the slot's bytes: its number is a measurement, and it replaces + // whatever the cell showed — a write that follows clears it as any + // rewrite does (onJobResult). Worker thread; the alive token guards the hop. + recorder->onMeasured = [this, alive = uiAlive](int slot, const wav::LoudnessReading& reading) { + juce::MessageManager::callAsync([this, alive, slot, reading] { + if (*alive) + applyLoudnessReport(slot, describeReading(reading, currentTargetLufs()), 0); + }); + }; player.onNormalize = [this](int slot) { if (const SlotRow* row = pedalBusy ? nullptr : slotRowFor(slot)) normalizeSlot(slot, trimmedName(*row)); @@ -614,6 +625,7 @@ MainComponent::MainComponent(std::string explicitVolume, juce::File dataOverride if (description.startsWith("Check slot") && slot > 0) { player.clearLoudness(slot); // a failed read must not stay "measuring…" table.clearPendingLoudness(slot); // …nor its cell "…" + readFailed(slot); // …nor the history refill it (#141) } if (inBatch) { ++batchFailed; @@ -1401,6 +1413,7 @@ void MainComponent::applyColumnPreferences() file == nullptr || file->getBoolValue(kOneShotColumnKey, true), file != nullptr && file->getBoolValue(kCountInColumnKey, false), file != nullptr && file->getBoolValue(kLoudnessColumnKey, false)); + inferLoudnessFromHistory(); // the LUFS column just shown fills in from the history (#141) } void MainComponent::updateToolbar() @@ -1718,7 +1731,8 @@ void MainComponent::applySnapshot(const PedalSnapshot& latest) const bool anotherVolume = latest.volume != snapshot.volume; if (anotherVolume) { table.clearAllLoudness(); // another card is another set of files; readings do not travel - inferenceProblemsLogged = std::make_shared>(); // and its problems were its own (#141) + unreadableTakes.clear(); // ...and what failed to read was another card's (#141) + inferenceProblemsLogged = std::make_shared>(); } snapshot = latest; if (anotherVolume) @@ -2472,6 +2486,19 @@ void MainComponent::keepReading(std::string hash, wav::LoudnessReading reading, false }); // and it needs no card: the store is on this computer } +// A real read of the slot's take failed (#141) — the player's pass, or a +// Check: an inferred cell in its place goes, and the history is not asked +// again about the file as the scan sees it now, for the rest of this +// connection. A guess must not outlive a read that could not confirm it, +// nor come back on the next snapshot to stand where the read failed. +void MainComponent::readFailed(int slot) +{ + if (const SlotRow* row = slotRowFor(slot); row != nullptr && row->take) + unreadableTakes[slot] = *row->take; + if (const SlotTable::LoudnessCell* cell = table.loudnessFor(slot); cell != nullptr && cell->inferred) + table.clearLoudness(slot); +} + // The LUFS column from what the history already knows (#141). Every slot with // a take and nothing in its cell is put to the store by the facts the scan // read off its directory entry and the config — name, size, stamp, WavLen — @@ -2481,6 +2508,13 @@ void MainComponent::keepReading(std::string hash, wav::LoudnessReading reading, // this mount, so a store still holding another card's session cannot answer // for this one; a card the history has never met knows nothing. // +// Not for the slot loaded in the player: it is the one slot the app can +// measure at once, and a guess there would sit beside a player that does not +// re-read a file it already has (an Undo on another slot clears every cell, +// this one's included). If nothing is reading it, the background read does. +// Not while the LUFS column is hidden: nobody would see it. Not for a file a +// read already failed on in this connection (readFailed). +// // Hands off while a job runs, like restoreListening and for the same reason: // the job's rescan lands before its result clears the cells it moved, so the // busy drop is the hook after a job, and the snapshot hook covers mounts and @@ -2491,14 +2525,27 @@ void MainComponent::keepReading(std::string hash, wav::LoudnessReading reading, // it, and Normalize measures the card's bytes whatever the column shows. void MainComponent::inferLoudnessFromHistory() { - if (pedalBusy || !card || snapshot.state != lifecycle::State::connected || !snapshot.error.empty()) + if (pedalBusy || !card || snapshot.state != lifecycle::State::connected || !snapshot.error.empty() + || !table.loudnessColumnVisible()) return; + // A take, an empty cell, and not the very file a read failed on. + const auto blank = [this](const SlotRow& row) { + return row.take && row.info.hasAudio && table.loudnessFor(row.info.slot) == nullptr + && !(unreadableTakes.contains(row.info.slot) && unreadableTakes.at(row.info.slot) == *row.take); + }; + const int loaded = player.currentSlot(); std::vector sightings; - for (const SlotRow& row : snapshot.slots) - if (row.take && row.info.hasAudio && table.loudnessFor(row.info.slot) == nullptr) - sightings.push_back({ row.info.slot, - { row.take->name, row.take->size, row.take->modifiedMs, - row.info.frames } }); + for (const SlotRow& row : snapshot.slots) { + if (!blank(row)) + continue; + if (row.info.slot == loaded) { + if (!player.loudnessPending()) + enqueueLoudnessRead(loaded, currentTargetLufs(), 0); + continue; + } + sightings.push_back({ row.info.slot, + { row.take->name, row.take->size, row.take->modifiedMs, row.info.frames } }); + } if (sightings.empty()) return; juce::Component::SafePointer safe(this); @@ -2536,14 +2583,16 @@ void MainComponent::inferLoudnessFromHistory() return; for (const Found& f : found) { // The slot still shows the very take that was sighted, - // and nothing landed in its cell meanwhile — a read, or - // a mutation's clearing — both outrank the history. + // nothing landed in its cell meanwhile — a read, or a + // mutation's clearing, both outrank the history — and + // it is not the slot the player has loaded since. const SlotRow* row = safe->slotRowFor(f.sighted.slot); const history::HistoryStore::TakeSighting& seen = f.sighted.take; if (row == nullptr || !row->take || row->take->name != seen.name || row->take->size != seen.size || row->take->modifiedMs != seen.modifiedMs || row->info.frames != seen.frames - || safe->table.loudnessFor(f.sighted.slot) != nullptr) + || safe->table.loudnessFor(f.sighted.slot) != nullptr + || f.sighted.slot == safe->player.currentSlot()) continue; safe->table.setLoudness( f.sighted.slot, diff --git a/app/MainComponent.h b/app/MainComponent.h index d965d25..c28dd90 100644 --- a/app/MainComponent.h +++ b/app/MainComponent.h @@ -5,6 +5,7 @@ #include +#include #include #include #include @@ -311,8 +312,10 @@ class MainComponent final : public juce::Component, void applyLoudnessReport(int slot, const LoudnessReport& report, int batch); // The column from what the history already knows (#141): the slots with // a take and an empty cell, put to the store by the facts the scan read - // off their directory entries — no audio read, no card needed. + // off their directory entries — no audio read, no card needed. The + // player's slot is read instead; a read that failed is remembered. void inferLoudnessFromHistory(); + void readFailed(int slot); void measureSlotLoudness(int slot); void startLoudnessCheck(const std::vector& slots); void stopLoudnessCheck(); @@ -364,9 +367,11 @@ class MainComponent final : public juce::Component, int checkTotal = 0, checkDone = 0, checkFailed = 0, checkAttention = 0, checkDamaged = 0; bool checkStopping = false; std::vector checkSlots; // to un-pend the cells of a dropped tail - // The slots whose record the LUFS column from the history (#141) could - // not read, logged already for this connection: touched by the worker's - // jobs only, and a new set per card. + // What the LUFS column from the history (#141) remembers for one + // connection: the files a real read failed on, by slot (message thread; + // readFailed), and the slots whose record could not be read and were + // logged already (touched by the worker's jobs only; a new set per card). + std::map unreadableTakes; std::shared_ptr> inferenceProblemsLogged = std::make_shared>(); PlayerPane player { engine }; juce::String deviceError; diff --git a/app/PlayerPane.cpp b/app/PlayerPane.cpp index 368503b..eee4655 100644 --- a/app/PlayerPane.cpp +++ b/app/PlayerPane.cpp @@ -467,6 +467,8 @@ void PlayerPane::passFinished(int slot, std::optional read "Could not measure this file."); loudnessPending_ = false; updateLoudnessButtons(); + if (onLoudnessFailed) + onLoudnessFailed(slot); return; } if (onLoudnessRead) diff --git a/app/PlayerPane.h b/app/PlayerPane.h index ed74db2..80fb337 100644 --- a/app/PlayerPane.h +++ b/app/PlayerPane.h @@ -112,6 +112,9 @@ class PlayerPane final : public juce::Component, // them before the decode and again after the hash (#141): what the read // saw, for the history to know the file by its bytes next time. Present // exactly when the hash is. + // A pass that cannot measure the file says so on the row and tells the + // owner through onLoudnessFailed: a number the column showed for the + // slot without reading it must not outlive a read that failed. // 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 @@ -119,6 +122,10 @@ class PlayerPane final : public juce::Component, std::function& seen)> onLoudnessRead; + std::function onLoudnessFailed; // the loaded slot's file could not be measured + // A reading for the loaded slot is on its way: the pane's own pass, or a + // check the owner announced with setLoudnessPending. + bool loudnessPending() const { return loudnessPending_; } void setLoudness(int slot, const juce::String& text, bool attention, bool damaged, const juce::String& tooltip); void setLoudnessPending(int slot); diff --git a/app/history/HistoryRecorder.cpp b/app/history/HistoryRecorder.cpp index f2b6335..07a3ee0 100644 --- a/app/history/HistoryRecorder.cpp +++ b/app/history/HistoryRecorder.cpp @@ -326,6 +326,8 @@ commands::WriteOptions withHistory(const std::shared_ptr& recor 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"); + if (recorder->onMeasured) + recorder->onMeasured(slot, reading); }; // The settings pair, before it is written: each changed section, its // text before and after (sysfile::sectionChanges, in the core). A throw diff --git a/app/history/HistoryRecorder.h b/app/history/HistoryRecorder.h index 02eb9b3..a51e6c1 100644 --- a/app/history/HistoryRecorder.h +++ b/app/history/HistoryRecorder.h @@ -82,6 +82,11 @@ class HistoryRecorder // this history's to place. Returns whether the sighting went in. bool sighted(const std::filesystem::path& volume, int slot, const std::string& name, std::int64_t size, std::int64_t modifiedMs, const std::string& hash); + // Told what a command measured on its way (normalize, through + // withHistory), once the reading is filed: the slot and the reading of + // the bytes it holds before any write. Set before the worker starts; + // called on the worker thread. + std::function onMeasured; // `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 diff --git a/tests/player_pane_harness.cpp b/tests/player_pane_harness.cpp index b3d4553..b11648b 100644 --- a/tests/player_pane_harness.cpp +++ b/tests/player_pane_harness.cpp @@ -17,7 +17,9 @@ // loop changes the buttons on the next layout, no reload needed // - a pane never told what it may do offers nothing // - the facts of the file go up with its hash and only with it (#141): -// name, size and stamp as one stat reads them +// name, size and stamp as one stat reads them; a file the meter cannot +// measure is said to the owner through onLoudnessFailed, and no reading +// comes up for it #include "support.hpp" @@ -131,6 +133,7 @@ int main() bool readCame = false; std::string readHash; std::optional readSeen; + int failures = 0; pane.onLoudnessRead = [&](int slot, const wav::LoudnessReading&, const std::string& hash, const std::optional& seen) { readCame = true; @@ -138,6 +141,7 @@ int main() readSeen = seen; pane.setLoudness(slot, "-14.0 LUFS", false, false, ""); }; + pane.onLoudnessFailed = [&](int) { ++failures; }; const auto load = [&] { readCame = false; @@ -248,9 +252,30 @@ int main() CHECK_EQ(readSeen->size, floatStat->size); CHECK_EQ(readSeen->modifiedMs, floatStat->modifiedMs); } + CHECK_EQ(failures, 0); + + // --- a file the meter cannot read: the owner is told, and no reading comes up --- + // 44 101 Hz does not cut into the meter's 100 ms sub-blocks; JUCE plays it. + const juce::File oddFile = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("loopercat_player_pane_harness_odd.wav"); + { + testkit::WavSpec odd; + odd.sampleRate = 44101; + odd.frames = kFrames; + const auto oddBytes = testkit::syntheticWav(odd); + std::ofstream out(oddFile.getFullPathName().toStdString(), std::ios::binary); + out.write(reinterpret_cast(oddBytes.data()), + static_cast(oddBytes.size())); + } + readCame = false; + pane.setSlot(3, oddFile, "03 Odd", false, kFrames); + settle(pane); + CHECK(!readCame); + CHECK_EQ(failures, 1); pane.clear(); wavFile.deleteFile(); floatFile.deleteFile(); + oddFile.deleteFile(); return testkit::summary("player_pane_harness"); } From 52b17369e83d2cca2c62898792a6fbaba5f7fed7 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 11:44:43 +0200 Subject: [PATCH 09/14] Show that a normalize hands its reading on, once, for its slot The column takes a normalize's measurement as a measured cell through HistoryRecorder::onMeasured; the fooled-take case now checks the hook is called once, with the slot and the very number normalize decided by. --- tests/inferred_loudness_tests.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/inferred_loudness_tests.cpp b/tests/inferred_loudness_tests.cpp index 8bf5a13..4a1ff0f 100644 --- a/tests/inferred_loudness_tests.cpp +++ b/tests/inferred_loudness_tests.cpp @@ -29,7 +29,8 @@ // very same name, size, stamp and WavLen fools the inference — and the // store still knows nothing about the bytes really there, so a step that // reads and hashes them must measure, and normalize, which reads them, -// decides by them against a target the guess says is already met +// decides by them against a target the guess says is already met — and +// hands what it measured on through the recorder, once, for its slot // - and a real read corrects it (review S3): after Check loudness reads the // new bytes under the same facts, the next inference answers their // reading, never the old one — and a read whose bytes have no reading @@ -61,6 +62,7 @@ #include #include #include +#include #include using namespace loopercat; @@ -408,6 +410,11 @@ int main() // is nothing to do. It reads the bytes, finds the quiet loop, and // raises it. const double guessed = *measured.reading.integratedLufs; + // and whoever shows the column is told what the command measured + std::vector> told; + rec->onMeasured = [&told](int slot, const wav::LoudnessReading& reading) { + told.emplace_back(slot, reading); + }; commands::NormalizeResult result; CHECK_EQ(run(*rec, "op-normalize-11", "normalize", volume, [&] { result = commands::normalize(volume, 11, @@ -415,7 +422,11 @@ int main() .write = options(rec, "op-normalize-11") }); }), std::string()); + rec->onMeasured = nullptr; CHECK(result.applied); + CHECK_EQ(told.size(), 1u); + CHECK(!told.empty() && told.front().first == 11 && told.front().second.integratedLufs + && std::abs(*told.front().second.integratedLufs - result.measuredLufs) <= 1.0e-9); CHECK(result.measuredLufs < guessed - 6.0); // -28 dBFS against -20: about 8 LU apart CHECK(result.gainDb > 6.0); // and the history learned the truth about those bytes from the bytes From 25021b004176d010f35f7cbedf2e12bad7c66fc9 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 12:12:15 +0200 Subject: [PATCH 10/14] Place a read among the operations by their sequence, and let it hand its hash on Two holes left in how the history believed a read (re-review of #141): - An operation after a read brought the wrong number back. heldBefore read only slot_audio: after Check loudness had read a frozen-clock re-recording, a rename carried the old row's hash under the very same facts into a row newer than the read, and the next connect trusted it (S3d). heldBefore now takes the newest word for the file: a read of these facts that no earlier operation on the slot came after hands on the hash of the bytes it read, with the file's stamp. - Reads were ordered against operations by wall clock. A first sighting resumed on a later connect keeps the time it began (S7), a player read is filed by a background job a foreground operation can jump ahead of (S8), and a clock can be set back (S10). A sighting now keeps since_op: the slot's newest operation at or below the newest one begun when the read started. It is history once an operation with a higher sequence records a body or a take in the slot; a subject alone does not count, nor another slot's operation. The check job notes where its read began at its start, the player when it starts a pass on the card's take (HistoryRecorder::newestOp, published as operations begin), and the late filing carries it. A first sighting photographing a slot removes that slot's earlier reads: its sequence is old, its photograph is not. take_sightings gains since_op in version 10, which has not shipped. --- app/MainComponent.cpp | 36 ++++++--- app/MainComponent.h | 6 +- app/history/HistoryRecorder.cpp | 26 +++++- app/history/HistoryRecorder.h | 19 ++++- app/history/HistoryStore.cpp | 91 +++++++++++++++------ app/history/HistoryStore.h | 34 +++++--- app/history/Schema.h | 26 +++--- app/history/SlotLoudness.h | 10 ++- tests/history_schema_tests.cpp | 17 ++-- tests/history_store_tests.cpp | 127 ++++++++++++++++++++--------- tests/inferred_loudness_tests.cpp | 129 +++++++++++++++++++++++++++++- 11 files changed, 414 insertions(+), 107 deletions(-) diff --git a/app/MainComponent.cpp b/app/MainComponent.cpp index bd95840..ea244a6 100644 --- a/app/MainComponent.cpp +++ b/app/MainComponent.cpp @@ -368,8 +368,13 @@ MainComponent::MainComponent(std::string explicitVolume, juce::File dataOverride player.onLoudnessRead = [this](int slot, const wav::LoudnessReading& reading, const std::string& hash, const std::optional& seen) { applyLoudnessReport(slot, describeReading(reading, currentTargetLufs()), 0); + // What the pass saw is a sighting of the slot's take only when the + // pane holds that take off the card — not a take played back out of + // the history, which is a file on this computer (#141). + const SlotRow* row = slotRowFor(slot); + const bool cardTake = row != nullptr && utf8(row->wavPath) == player.currentPath(); if (!hash.empty()) // empty: the core would not measure these bytes to this number - keepReading(hash, reading, slot, seen); + keepReading(hash, reading, slot, cardTake ? seen : std::nullopt, playerReadBegan); }; player.onLoudnessFailed = [this](int slot) { readFailed(slot); }; // A command that meters on its way (normalize, the bulk run included) @@ -667,8 +672,10 @@ MainComponent::MainComponent(std::string explicitVolume, juce::File dataOverride // pushed that file onto the card. && !(description.startsWith("Normalize") && description.contains("file untouched")); - if (description.startsWith("Trim")) + if (description.startsWith("Trim")) { + playerReadBegan = recorder->newestOp(); // where the pass's read begins (#141) player.reload(); // same path, new bytes — fresh reader + thumbnail + } // A reading belongs to the audio, not the memory: a rename, a tempo // or a flag leaves it standing. Only a rewrite of the WAV itself // moves the audio on — push, trim, downmix, a normalize that applied, @@ -1337,8 +1344,10 @@ void MainComponent::playArchivedTake(int slot, std::string hash, juce::String ti .frames; const juce::File opened(juce::String(file->string())); juce::MessageManager::callAsync([safe, opened, title, slot, frames, alive] { - if (*alive && safe != nullptr) + if (*alive && safe != nullptr) { + safe->playerReadBegan.reset(); // a file on this computer, not the card's safe->player.setSlot(slot, opened, title, false, frames); + } }); }, nullptr, 0, true, true, false }); @@ -1885,13 +1894,17 @@ void MainComponent::slotChosen(int slot, bool startPlaying) player.clear(); // no take on any track: nothing to listen to return; } - if (firstPath != player.currentPath()) + if (firstPath != player.currentPath()) { + playerReadBegan = recorder->newestOp(); // where the pass's read begins (#141) player.setTracks(row.info.slot, tracks, title, row.info.oneShot); + } } else { const juce::String path = utf8(row.wavPath); - if (path != player.currentPath()) + if (path != player.currentPath()) { + playerReadBegan = recorder->newestOp(); // where the pass's read begins (#141) player.setSlot(row.info.slot, juce::File(path), title, row.info.oneShot, row.info.frames); + } } player.setTempoNote(tempoNoteFor(row.info)); // The loudness readout follows the loaded loop: whatever the column knows @@ -2462,18 +2475,21 @@ void MainComponent::enqueueLoudnessRead(int slot, double target, int batch) // 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. What the // pass saw goes with it (#141): the file's facts under the hash, filed for -// the card mounted now, so the next connect knows the file by its bytes. +// the card mounted now, so the next connect knows the file by its bytes — +// placed among the operations by where the read began, not by when this job +// runs: a foreground operation may have jumped ahead of it meanwhile. void MainComponent::keepReading(std::string hash, wav::LoudnessReading reading, int slot, - std::optional seen) + std::optional seen, std::optional readBegan) { worker.enqueue({ "Keep a loudness reading", 0, [rec = recorder, key = std::move(hash), reading, slot, facts = std::move(seen), - mounted = volume::fs::path(snapshot.volume), + began = readBegan, mounted = volume::fs::path(snapshot.volume), logDir = settings.dataDir()](const volume::fs::path&) { try { - if (rec->reading(key, reading) && facts) - rec->sighted(mounted, slot, facts->name, facts->size, facts->modifiedMs, key); + if (rec->reading(key, reading) && facts && began) + rec->sighted(mounted, slot, facts->name, facts->size, facts->modifiedMs, key, + *began); } catch (const std::exception& e) { oplog::append(logDir, "loudness reading not kept in the history: " + juce::String::fromUTF8(e.what())); diff --git a/app/MainComponent.h b/app/MainComponent.h index c28dd90..6b94f5e 100644 --- a/app/MainComponent.h +++ b/app/MainComponent.h @@ -308,7 +308,7 @@ class MainComponent final : public juce::Component, // 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, int slot, - std::optional seen); + std::optional seen, std::optional readBegan); void applyLoudnessReport(int slot, const LoudnessReport& report, int batch); // The column from what the history already knows (#141): the slots with // a take and an empty cell, put to the store by the facts the scan read @@ -372,6 +372,10 @@ class MainComponent final : public juce::Component, // readFailed), and the slots whose record could not be read and were // logged already (touched by the worker's jobs only; a new set per card). std::map unreadableTakes; + // Where the player's current read pass began among the history's + // operations (HistoryRecorder::newestOp), noted when it is started on + // the card's take; absent for a take played back out of the history. + std::optional playerReadBegan; std::shared_ptr> inferenceProblemsLogged = std::make_shared>(); PlayerPane player { engine }; juce::String deviceError; diff --git a/app/history/HistoryRecorder.cpp b/app/history/HistoryRecorder.cpp index 07a3ee0..a9f4172 100644 --- a/app/history/HistoryRecorder.cpp +++ b/app/history/HistoryRecorder.cpp @@ -39,6 +39,7 @@ HistoryStore& HistoryRecorder::store() openError_ = e.what(); throw Error("the history is unavailable: " + openError_); } + published(store_->newestOp()); return *store_; } @@ -79,6 +80,7 @@ std::optional HistoryRecorder::firstSeen(const std::f { const auto session = sessionFor(volume); const auto op = store().firstSeen(session, opid::make("first-seen"), clock_()); + published(op); if (store().opStatus(op) == "done") { snapshot_.reset(); return std::nullopt; @@ -161,6 +163,7 @@ void HistoryRecorder::begin(const std::string& opId, const std::string& kind, firstSeen(volume); // establish the baseline before a write can touch a newly minted card const std::int64_t session = *session_; ops_[opId] = Operation { store().beginOp(session, opId, kind, clock_()), kind, volume }; + published(ops_[opId].row); } void HistoryRecorder::beginMaintenance(const std::string& opId, std::int64_t card) @@ -171,6 +174,24 @@ void HistoryRecorder::beginMaintenance(const std::string& opId, std::int64_t car if (!session.step()) throw Error("no history session for this card"); const auto row = store().beginOp(session.integer(0), opId, "forget-history", clock_()); ops_[opId] = Operation { row, "forget-history", {} }; + published(row); +} + +std::optional HistoryRecorder::newestOp() const +{ + const std::int64_t newest = newestOp_.load(); + if (newest < 0) + return std::nullopt; + return newest; +} + +void HistoryRecorder::published(std::int64_t op) +{ + // Only ever forward: a read compared against it must never be placed + // before an operation that had already begun. + std::int64_t known = newestOp_.load(); + while (op > known && !newestOp_.compare_exchange_weak(known, op)) { + } } void HistoryRecorder::subject(const std::string& opId, int slot) @@ -225,14 +246,15 @@ bool HistoryRecorder::reading(const std::string& hash, const wav::LoudnessReadin } bool HistoryRecorder::sighted(const std::filesystem::path& volume, int slot, const std::string& name, - std::int64_t size, std::int64_t modifiedMs, const std::string& hash) + std::int64_t size, std::int64_t modifiedMs, const std::string& hash, + std::int64_t readBegan) { if (!sessionOn(volume)) return false; const std::optional card = store().cardFor(sessionMarker_); if (!card) throw Error("the open session's card " + sessionMarker_ + " is not in the history"); - store().recordSighting(*card, slot, name, size, modifiedMs, hash, clock_()); + store().recordSighting(*card, slot, name, size, modifiedMs, hash, readBegan, clock_()); return true; } diff --git a/app/history/HistoryRecorder.h b/app/history/HistoryRecorder.h index a51e6c1..f57eeab 100644 --- a/app/history/HistoryRecorder.h +++ b/app/history/HistoryRecorder.h @@ -6,6 +6,7 @@ #include "HistoryStore.h" #include +#include #include #include #include @@ -79,9 +80,19 @@ class HistoryRecorder // size and stamp, and the hash of the bytes read under them — filed for // the card whose session is open on that volume, and not otherwise: a // read of another card, or of a card no session has opened yet, is not - // this history's to place. Returns whether the sighting went in. + // this history's to place. `readBegan` is newestOp() as it stood when + // the read began, which places the read among the operations + // (HistoryStore::recordSighting). Returns whether the sighting went in. bool sighted(const std::filesystem::path& volume, int slot, const std::string& name, - std::int64_t size, std::int64_t modifiedMs, const std::string& hash); + std::int64_t size, std::int64_t modifiedMs, const std::string& hash, + std::int64_t readBegan); + // The newest operation the history has begun, by sequence, for a read + // about to begin to note as its place (#141): absent until the store has + // opened. Any thread — a read begins on the player's thread too. It + // moves only forward, with every operation this recorder begins; a lag + // behind the store only makes a read look older than it is, so it is + // set aside sooner, never trusted longer. + std::optional newestOp() const; // Told what a command measured on its way (normalize, through // withHistory), once the reading is filed: the slot and the reading of // the bytes it holds before any write. Set before the worker starts; @@ -173,6 +184,10 @@ class HistoryRecorder std::string sessionMarker_; std::optional snapshot_; std::map ops_; + // newestOp(): -1 until the store has opened. Written on the worker, + // read on any thread. + std::atomic newestOp_ { -1 }; + void published(std::int64_t op); }; // The one wiring from an operation's WriteOptions into the history, shared by diff --git a/app/history/HistoryStore.cpp b/app/history/HistoryStore.cpp index 1a8d8a2..f7b395b 100644 --- a/app/history/HistoryStore.cpp +++ b/app/history/HistoryStore.cpp @@ -157,6 +157,12 @@ void HistoryStore::snapshotSlot(std::int64_t op, int slot, const std::string& bo sqlite::Statement row(db_, "INSERT INTO slot_changes(op, slot, before_body, after_body) " "VALUES (?1, ?2, NULL, ?3)"); row.bind(1, op).bind(2, slot).bindBlob(3, body).run(); + // The baseline's photograph is the newest word about the slot, though its + // operation's sequence is as old as the first connect: a read made + // before it resumed here would otherwise outrank it (#141). The reads go. + sqlite::Statement reads(db_, "DELETE FROM take_sightings WHERE slot = ?2 AND card = " + "(SELECT s.card FROM ops o JOIN sessions s ON s.id = o.session WHERE o.seq = ?1)"); + reads.bind(1, op).bind(2, slot).run(); for (const auto& take : takes) { const auto hash = contentHash(take.bytes); keepBlob(hash, take.bytes, nowMs); @@ -411,6 +417,21 @@ HistoryStore::Held HistoryStore::heldBefore(std::int64_t op, int slot, const std // 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. + // A real read of these very facts that no earlier operation on the slot + // came after is newer than any row before `op`: every such row's + // operation touched the slot. It hands on the hash of the bytes it read, + // with the file's stamp — a rename after Check loudness must not bring + // back the take a row named under the same facts (#141, review S3d). + sqlite::Statement sighted(db_, "SELECT t.hash FROM take_sightings t " + "WHERE t.card = (SELECT s.card FROM ops o JOIN sessions s ON s.id = o.session " + "WHERE o.seq = ?1) AND t.slot = ?2 AND t.name = ?3 AND t.size = ?4 " + "AND t.modified = ?5 AND NOT EXISTS (SELECT 1 FROM ops o2 JOIN sessions s2 " + "ON s2.id = o2.session WHERE s2.card = t.card AND o2.seq < ?1 " + "AND o2.seq > coalesce(t.since_op, 0) AND o2.seq IN " + "(SELECT op FROM slot_changes WHERE slot = ?2 UNION SELECT op FROM slot_audio WHERE slot = ?2))"); + sighted.bind(1, op).bind(2, slot).bindText(3, name).bind(4, size).bind(5, modifiedMs); + if (sighted.step()) + return { sighted.blob(0), modifiedMs }; 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 " @@ -457,18 +478,35 @@ std::optional HistoryStore::cardFor(const std::string& markerId) return find.integer(0); } +std::int64_t HistoryStore::newestOp() +{ + sqlite::Statement read(db_, "SELECT coalesce(max(seq), 0) FROM ops"); + if (!read.step()) + throw Error("the history could not say its newest operation"); + return read.integer(0); +} + void HistoryStore::recordSighting(std::int64_t card, int slot, const std::string& name, std::int64_t size, std::int64_t modifiedMs, - const std::string& hash, std::int64_t nowMs) + const std::string& hash, std::int64_t readBegan, + std::int64_t nowMs) { if (hash.size() != 32) throw Error("a content hash is 32 bytes, not " + std::to_string(hash.size())); - sqlite::Statement row(db_, "INSERT INTO take_sightings(card, slot, name, size, modified, hash, at) " - "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) " + if (readBegan < 0) + throw Error("a read cannot begin before the first operation, got " + std::to_string(readBegan)); + // The slot's newest operation the read came after: every one with a + // higher sequence is newer than the read, whatever the clocks said. + sqlite::Statement row(db_, "INSERT INTO take_sightings(card, slot, name, size, modified, hash, since_op, at) " + "VALUES (?1, ?2, ?3, ?4, ?5, ?6, " + "(SELECT max(o.seq) FROM ops o JOIN sessions s ON s.id = o.session " + " WHERE s.card = ?1 AND o.seq <= ?7 AND o.seq IN " + " (SELECT op FROM slot_changes WHERE slot = ?2 UNION SELECT op FROM slot_audio WHERE slot = ?2)), " + "?8) " "ON CONFLICT(card, slot, name, size, modified) " - "DO UPDATE SET hash = excluded.hash, at = excluded.at"); + "DO UPDATE SET hash = excluded.hash, since_op = excluded.since_op, at = excluded.at"); row.bind(1, card).bind(2, slot).bindText(3, name).bind(4, size).bind(5, modifiedMs) - .bindBlob(6, hash).bind(7, nowMs).run(); + .bindBlob(6, hash).bind(7, readBegan).bind(8, nowMs).run(); } std::optional HistoryStore::hashOfSighting(std::int64_t card, int slot, @@ -476,33 +514,34 @@ std::optional HistoryStore::hashOfSighting(std::int64_t card, int s { constexpr int kFirstTrack = 1; // the take the LUFS column measures (SlotLoudness.h) // The slot's last operation on this card: the newest that recorded - // anything about it, a body or a take, either side. - struct Touch { - std::int64_t op; - std::int64_t at; - }; - std::optional last; + // anything about it, a body or a take, either side. A subject alone + // (#144: an operation about the slot that changed nothing) is not one. + std::optional last; { - sqlite::Statement read(db_, "SELECT o.seq, o.at FROM ops o JOIN sessions s ON s.id = o.session " + sqlite::Statement read(db_, "SELECT max(o.seq) FROM ops o JOIN sessions s ON s.id = o.session " "WHERE s.card = ?1 AND o.seq IN (SELECT op FROM slot_changes WHERE slot = ?2 " - "UNION SELECT op FROM slot_audio WHERE slot = ?2) " - "ORDER BY o.seq DESC LIMIT 1"); + "UNION SELECT op FROM slot_audio WHERE slot = ?2)"); read.bind(1, card).bind(2, slot); - if (read.step()) - last = Touch { read.integer(0), read.integer(1) }; + if (read.step() && !read.isNull(0)) + last = read.integer(0); } - // A read of these very facts since then is the newest word: the bytes - // were hashed under them, so it outranks a row that only named the file - // — the pedal's clock can give a new recording the old row's facts, and - // a read is how the history learns that. WavLen is not asked of a read: - // the hash is of the bytes themselves. A read older than the slot's last - // operation is history (a clear, a swap, a write came after it). + // A read of these very facts that no operation on the slot came after is + // the newest word: the bytes were hashed under them, so it outranks a + // row that only named the file — the pedal's clock can give a new + // recording the old row's facts, and a read is how the history learns + // that. The order is the operations' sequence (since_op), never a clock: + // a first sighting resumed later, a read filed after an operation that + // ran meanwhile, a clock set back all keep their places. WavLen is not + // asked of a read: the hash is of the bytes themselves. { - sqlite::Statement read(db_, "SELECT hash, at FROM take_sightings WHERE card = ?1 AND slot = ?2 " + sqlite::Statement read(db_, "SELECT hash, since_op FROM take_sightings WHERE card = ?1 AND slot = ?2 " "AND name = ?3 AND size = ?4 AND modified = ?5"); read.bind(1, card).bind(2, slot).bindText(3, seen.name).bind(4, seen.size).bind(5, seen.modifiedMs); - if (read.step() && (!last || read.integer(1) > last->at)) - return read.blob(0); + if (read.step()) { + const bool nothingSince = read.isNull(1) ? !last : (!last || *last <= read.integer(1)); + if (nothingSince) + return read.blob(0); + } } if (!last) return std::nullopt; // the history never recorded this slot @@ -522,7 +561,7 @@ std::optional HistoryStore::hashOfSighting(std::int64_t card, int s // row never saw. The pedal stamps what it records with a clock that does // not keep the date (it read May 1 2019 on 2026-08-10, hardware), so two // of its recordings of one length can carry the same three facts. - if (op != last->op) + if (op != *last) return std::nullopt; // Only a finished operation says what the slot holds: a write that failed // after its take landed left the card wherever the failure did. A first diff --git a/app/history/HistoryStore.h b/app/history/HistoryStore.h index aa456b3..3d64c79 100644 --- a/app/history/HistoryStore.h +++ b/app/history/HistoryStore.h @@ -182,10 +182,13 @@ 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); - // What a new row for a file of this name inherits from the slot's newest - // row before `op`: that row's hash — only when it carries one, its size - // is the file's, and its modification time is the file's now — and the - // stamp the new row may carry. A newer row without a hash is the last + // What a new row for a file of this name inherits from the newest word + // about it before `op`. First a real read of these very facts (name, + // size, stamp) that no earlier operation on the slot came after: the + // hash of the bytes it read, with the file's stamp. Otherwise the slot's + // newest row before `op`: that row's hash — only when it carries one, + // its size is the file's, and its modification time is the file's now — + // and the stamp the new row may carry. 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: no hash, never // guessed from an older row (#141 reads these hashes). A row written @@ -221,18 +224,29 @@ class HistoryStore std::int64_t modifiedMs = 0; std::int64_t frames = 0; }; + // The newest operation's sequence, 0 before the first: what a read that + // begins now comes after. + std::int64_t newestOp(); + // What a real read of a take saw (#141): the file's name, size and stamp // on this card's slot, and the hash of the bytes read under them — the - // check job's read and the player's pass both have all of it. The same - // facts read again keep the newest hash. Refused: a hash that is not 32 - // bytes, and (by the table) a slot outside 1..99. + // check job's read and the player's pass both have all of it. + // `readBegan` is newestOp() as it stood when the read began; the read is + // filed after the slot's newest operation at or below it, and every + // operation that records a body or a take in the slot with a higher + // sequence makes it history — the order of operations, not of clocks. + // The same facts read again keep the newest read. Refused: a hash that + // is not 32 bytes, a negative `readBegan`, and (by the table) a slot + // outside 1..99. void recordSighting(std::int64_t card, int slot, const std::string& name, std::int64_t size, - std::int64_t modifiedMs, const std::string& hash, std::int64_t nowMs); + std::int64_t modifiedMs, const std::string& hash, std::int64_t readBegan, + std::int64_t nowMs); // The hash of the take this card's slot holds, when the history can tell // it is the one sighted, from the newest word about the slot: - // - a read of these very facts (recordSighting) made after the slot's - // last operation: its hash — the bytes were read under them; + // - a read of these very facts (recordSighting) that no operation + // recording a body or a take in the slot came after, by sequence: + // its hash — the bytes were read under them; // - otherwise the slot's newest row for the file name on the first // track — the take the LUFS column measures — when it is that last // operation's own, the operation finished (or is the first diff --git a/app/history/Schema.h b/app/history/Schema.h index c5728b0..4e774e0 100644 --- a/app/history/Schema.h +++ b/app/history/Schema.h @@ -60,13 +60,18 @@ // take_sightings // what a real read of a take saw (#141): the directory // entry's name, size and stamp, the hash of the bytes read -// under them, and when. A connect believes the newest word -// about a slot, and a read is one: the pedal stamps what it -// records off a clock that does not keep the date, so a loop -// of the same length recorded again can carry the very same -// name, size and stamp as the take a row names, and only a -// read of the bytes tells the two apart. By card and slot; -// forgetting a slot's history takes its sightings with it. +// under them, and the slot's newest operation when the read +// began (since_op; NULL when none had touched the slot). A +// connect believes the newest word about a slot, and a read +// is one: the pedal stamps what it records off a clock that +// does not keep the date, so a loop of the same length +// recorded again can carry the very same name, size and +// stamp as the take a row names, and only a read of the bytes +// tells the two apart. The order is the operations' sequence, +// never a clock: a read is history once an operation with a +// higher sequence recorded a body or a take in the slot. By +// card and slot; forgetting a slot's history, or a first +// sighting photographing it, takes its sightings with it. // // 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 @@ -230,8 +235,10 @@ ALTER TABLE slot_audio ADD COLUMN modified INTEGER; )sql", R"sql( -- What a real read of a take saw (#141), by card and slot: the directory --- entry's facts and the hash of the bytes read under them. One row per file --- the slot was seen to hold; the same facts read again keep the newest hash. +-- entry's facts, the hash of the bytes read under them, and the slot's +-- newest operation when the read began (NULL: none had touched it), which +-- orders the read among the operations. One row per file the slot was seen +-- to hold; the same facts read again keep the newest read. CREATE TABLE take_sightings( card INTEGER NOT NULL REFERENCES cards(id), slot INTEGER NOT NULL CHECK (slot BETWEEN 1 AND 99), @@ -239,6 +246,7 @@ CREATE TABLE take_sightings( size INTEGER NOT NULL CHECK (size >= 0), modified INTEGER NOT NULL, hash BLOB NOT NULL CHECK (length(hash) = 32), + since_op INTEGER CHECK (since_op IS NULL OR since_op >= 1), at INTEGER NOT NULL, PRIMARY KEY (card, slot, name, size, modified) ) STRICT; diff --git a/app/history/SlotLoudness.h b/app/history/SlotLoudness.h index 4535061..2086d50 100644 --- a/app/history/SlotLoudness.h +++ b/app/history/SlotLoudness.h @@ -26,7 +26,9 @@ // with the hash of the bytes read under them, so the next connect knows the // file in the slot by the bytes, not by a row that only named it — when the // file stood still under the read (one stat before, one after, and the bytes -// as long as the entry said), and not otherwise. +// as long as the entry said), and not otherwise. The read is placed among the +// operations by the newest one begun when it started (newestOp): on the +// worker nothing can begin while it runs. // // 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 @@ -53,6 +55,7 @@ inline SlotLoudness readSlotLoudness(const volume::fs::path& volume, int slot, 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::optional began = recorder.newestOp(); const volume::fs::path file = volume::wavDir(volume, slot) / files.front(); const std::optional before = statFile(file); const std::string raw = commands::readFileBytes(file); @@ -65,8 +68,9 @@ inline SlotLoudness readSlotLoudness(const volume::fs::path& volume, int slot, && before->size == static_cast(raw.size()); try { out.kept = recorder.reading(out.hash, out.reading); - if (out.kept && stoodStill) - recorder.sighted(volume, slot, files.front(), before->size, before->modifiedMs, out.hash); + if (out.kept && stoodStill && began) + recorder.sighted(volume, slot, files.front(), before->size, before->modifiedMs, out.hash, + *began); } catch (const std::exception& e) { out.failure = e.what(); // the store's refusal, or anything else the filing threw } diff --git a/tests/history_schema_tests.cpp b/tests/history_schema_tests.cpp index 6a93ac3..1f8440d 100644 --- a/tests/history_schema_tests.cpp +++ b/tests/history_schema_tests.cpp @@ -410,18 +410,25 @@ int main() CHECK_EQ(count(db, "SELECT modified FROM slot_audio WHERE slot = 4"), 1'700'000'000'000); CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio WHERE slot = 5 AND modified IS NULL"), 1); // the new table takes a read the way the store files one, and holds its rules - migrated.recordSighting(1, 4, "004_1.WAV", 3000, 1'700'000'000'000, std::string(32, 'h'), 3); + // ...placed after the slot's newest operation the read came after: the + // v9 push touched slot 4, so a read begun after it stands on it + migrated.recordSighting(1, 4, "004_1.WAV", 3000, 1'700'000'000'000, std::string(32, 'h'), + migrated.newestOp(), 3); CHECK_EQ(count(db, "SELECT count(*) FROM take_sightings"), 1); - CHECK_THROWS(db.exec("INSERT INTO take_sightings VALUES (1, 4, 'x', 1, 1, x'00', 1)"), "CHECK"); + CHECK_EQ(count(db, "SELECT since_op FROM take_sightings WHERE slot = 4"), 1); + CHECK_THROWS(db.exec("INSERT INTO take_sightings VALUES (1, 4, 'x', 1, 1, x'00', NULL, 1)"), "CHECK"); CHECK_THROWS(db.exec("INSERT INTO take_sightings VALUES (1, 0, 'x', 1, 1, x'" - + std::string(64, 'a') + "', 1)"), + + std::string(64, 'a') + "', NULL, 1)"), "CHECK"); CHECK_THROWS(db.exec("INSERT INTO take_sightings VALUES (9, 4, 'x', 1, 1, x'" - + std::string(64, 'a') + "', 1)"), + + std::string(64, 'a') + "', NULL, 1)"), "FOREIGN KEY"); CHECK_THROWS(db.exec("INSERT INTO take_sightings VALUES (1, 4, 'x', 1, NULL, x'" - + std::string(64, 'a') + "', 1)"), + + std::string(64, 'a') + "', NULL, 1)"), "NOT NULL"); + CHECK_THROWS(db.exec("INSERT INTO take_sightings VALUES (1, 4, 'x', 1, 1, x'" + + std::string(64, 'a') + "', 0, 1)"), + "CHECK"); // no operation has sequence 0: none is NULL CHECK_EQ(count(db, "SELECT count(*) FROM pragma_foreign_key_check"), 0); } { diff --git a/tests/history_store_tests.cpp b/tests/history_store_tests.cpp index 202a888..3948be0 100644 --- a/tests/history_store_tests.cpp +++ b/tests/history_store_tests.cpp @@ -28,13 +28,17 @@ // only track 1 is asked; another card's rows are never asked; only a // finished operation's row speaks (a first sighting's always does); // asking writes nothing -// - a real read's sighting is the newest word when it came after the -// slot's last operation: a different hash under the very same facts -// wins over the row, an older read is ignored, another card's or other -// facts' never count; the same facts read again keep the newest hash; -// forgetting the slot takes its sightings, and only its own -// - heldBefore hands a hash vouched for by a stampless row on without a -// stamp, and the file's own stamp in every other case +// - a real read's sighting is the newest word while no operation on the +// slot came after it, by sequence and never by clock: a different hash +// under the very same facts wins over the row; an operation that began +// after the read did — clock set back, read filed late — makes it +// history, a subject alone or another slot's operation does not; +// another card's or other facts' reads never count; the same facts read +// again keep the newest read; a first sighting photographing the slot, +// and forgetting it, take its sightings, and only its own +// - heldBefore hands on a valid read's hash with the file's stamp, a hash +// vouched for by a stampless row without a stamp, and the file's own +// stamp in every other case #include "support.hpp" @@ -886,6 +890,7 @@ int main() r.store.recordBodies(op, { { slot, testkit::syntheticSlotBody("Memory"), bodyWith(132300) } }); r.store.recordLanded(op, slot, 1, name, bytes, kStamp); r.store.finishOp(op, OpStatus::done, ""); + return op; }; // a clear after the row: the slot was seen emptied, and the very // same facts met there again are not the take the row saw — the @@ -945,50 +950,88 @@ int main() // --- what a real read saw (take_sightings) --- // - // Theory: a read hashed the bytes under these facts, so a read made - // after the slot's last operation is the newest word about the file - // and outranks a row that only named it; a read older than that - // operation is history; a read of other facts, or on another card, - // says nothing about these. + // Theory: a read hashed the bytes under these facts, so a read that + // no operation on the slot came after is the newest word about the + // file and outranks a row that only named it. "After" is the + // operations' sequence, never a clock: an operation recording a body + // or a take in the slot with a higher sequence than the read began + // after makes the read history; a subject alone, or another slot's + // operation, does not. A read of other facts, or on another card, + // says nothing about these. (Review S8, S9, S10, at the store.) const std::string c = take(3000, 163); const std::string hashC = HistoryStore::contentHash(c); - landedOn(20, "020_1.WAV", a, "op-20", 11000, true); + const std::int64_t op20 = landedOn(20, "020_1.WAV", a, "op-20", 11000, true); const Sighting twenty { "020_1.WAV", 3000, kStamp, 132300 }; + const std::string sinceOf20 = "SELECT since_op FROM take_sightings WHERE card = " + + std::to_string(card) + " AND slot = 20"; CHECK(r.store.hashOfSighting(card, 20, twenty) == hashA); - // the pedal recorded the slot again under the same facts; a read saw C - r.store.recordSighting(card, 20, "020_1.WAV", 3000, kStamp, hashC, 11100); + // the pedal recorded the slot again under the same facts; a read saw C, + // placed after the slot's newest operation by sequence + CHECK_EQ(r.store.newestOp(), op20); + r.store.recordSighting(card, 20, "020_1.WAV", 3000, kStamp, hashC, r.store.newestOp(), 11100); + CHECK_EQ(count(r.store.db(), sinceOf20), op20); CHECK(r.store.hashOfSighting(card, 20, twenty) == hashC); // the read needs no WavLen of its own: the hash is of the bytes CHECK(r.store.hashOfSighting(card, 20, Sighting { "020_1.WAV", 3000, kStamp, 1 }) == hashC); - // read again under the same facts: the newest hash is kept, one row - r.store.recordSighting(card, 20, "020_1.WAV", 3000, kStamp, hashB, 11200); + // read again under the same facts: the newest read is kept, one row + r.store.recordSighting(card, 20, "020_1.WAV", 3000, kStamp, hashB, r.store.newestOp(), 11200); CHECK(r.store.hashOfSighting(card, 20, twenty) == hashB); CHECK_EQ(count(r.store.db(), "SELECT count(*) FROM take_sightings WHERE slot = 20"), 1); + // S9 by sequence: the read stands on the slot's last operation, and + // neither a subject alone nor another slot's operation unsays it + const auto about20 = r.store.beginOp(r.session, "op-about-20", "normalize", 11250); + r.store.recordSubject(about20, 20); + r.store.finishOp(about20, OpStatus::done, "already at -18.0 LUFS"); + landedOn(23, "023_1.WAV", a, "op-23", 11260, true); + CHECK(r.store.hashOfSighting(card, 20, twenty) == hashB); // a read of other facts says nothing about these - r.store.recordSighting(card, 21, "021_1.WAV", 3000, kStamp + 2000, hashC, 11300); + r.store.recordSighting(card, 21, "021_1.WAV", 3000, kStamp + 2000, hashC, r.store.newestOp(), 11300); CHECK(!r.store.hashOfSighting(card, 21, Sighting { "021_1.WAV", 3000, kStamp, 132300 }).has_value()); CHECK(!r.store.hashOfSighting(card, 21, Sighting { "021_1.WAV", 2999, kStamp + 2000, 132300 }).has_value()); CHECK(!r.store.hashOfSighting(card, 21, Sighting { "021_2.WAV", 3000, kStamp + 2000, 132300 }).has_value()); - // ...and a slot nothing but a read ever saw answers by the read alone + // ...and a slot nothing but a read ever saw answers by the read alone, + // which came after no operation of its own CHECK(r.store.hashOfSighting(card, 21, Sighting { "021_1.WAV", 3000, kStamp + 2000, 132300 }) == hashC); - // an operation after the read: the read is history, the row speaks - landedOn(20, "020_1.WAV", a, "op-20-again", 11400, true); + CHECK_EQ(count(r.store.db(), "SELECT count(*) FROM take_sightings WHERE slot = 21 AND since_op IS NULL"), 1); + // S10: an operation after the read, its clock set back an hour before + // the read's: the sequence says after, so the read is history + landedOn(20, "020_1.WAV", a, "op-20-again", 11200 - 3'600'000, true); CHECK(r.store.hashOfSighting(card, 20, twenty) == hashA); - // an operation in the same millisecond as a read outranks it - r.store.recordSighting(card, 20, "020_1.WAV", 3000, kStamp, hashC, 11400); + // S8: a read begun before an operation and filed after it is history; + // the same read begun after that operation stands + const std::int64_t began = r.store.newestOp(); + const std::int64_t meanwhile = landedOn(20, "020_1.WAV", a, "op-20-meanwhile", 11500, true); + r.store.recordSighting(card, 20, "020_1.WAV", 3000, kStamp, hashC, began, 11600); + CHECK(count(r.store.db(), sinceOf20) < meanwhile); CHECK(r.store.hashOfSighting(card, 20, twenty) == hashA); + r.store.recordSighting(card, 20, "020_1.WAV", 3000, kStamp, hashC, r.store.newestOp(), 11700); + CHECK_EQ(count(r.store.db(), sinceOf20), meanwhile); + CHECK(r.store.hashOfSighting(card, 20, twenty) == hashC); // another card's read is never asked - r.store.recordSighting(otherCard, 20, "020_1.WAV", 3000, kStamp, hashB, 12000); - CHECK(r.store.hashOfSighting(card, 20, twenty) == hashA); + r.store.recordSighting(otherCard, 20, "020_1.WAV", 3000, kStamp, hashB, r.store.newestOp(), 12000); + CHECK(r.store.hashOfSighting(card, 20, twenty) == hashC); CHECK(r.store.hashOfSighting(otherCard, 20, twenty) == hashB); - // refused: a key that is not a hash, a slot the pedal does not have - CHECK_THROWS(r.store.recordSighting(card, 20, "020_1.WAV", 3000, kStamp, "short", 12100), "32 bytes"); - CHECK_THROWS(r.store.recordSighting(card, 100, "100_1.WAV", 3000, kStamp, hashA, 12100), "CHECK"); + // refused: a key that is not a hash, a slot the pedal does not have, + // a read begun before the first operation could have + CHECK_THROWS(r.store.recordSighting(card, 20, "020_1.WAV", 3000, kStamp, "short", 1, 12100), "32 bytes"); + CHECK_THROWS(r.store.recordSighting(card, 100, "100_1.WAV", 3000, kStamp, hashA, 1, 12100), "CHECK"); + CHECK_THROWS(r.store.recordSighting(card, 20, "020_1.WAV", 3000, kStamp, hashA, -1, 12100), "before the first"); + // S7: a first sighting resumed later photographs a slot read meanwhile: + // its operation is older than the read, its photograph is not — the + // reads of that slot go + r.store.recordSighting(card, 24, "024_1.WAV", 3000, kStamp, hashC, r.store.newestOp(), 12200); + CHECK(r.store.hashOfSighting(card, 24, Sighting { "024_1.WAV", 3000, kStamp, 132300 }) == hashC); + const auto resumed = r.store.firstSeen(r.session, "op-first-again", 12300); + CHECK(resumed < r.store.newestOp()); // the baseline's sequence is the old one + r.store.snapshotSlot(resumed, 24, bodyWith(132300), { { 1, "024_1.WAV", b, kStamp } }, 12300); + CHECK_EQ(count(r.store.db(), "SELECT count(*) FROM take_sightings WHERE slot = 24"), 0); + CHECK(r.store.hashOfSighting(card, 24, Sighting { "024_1.WAV", 3000, kStamp, 132300 }) == hashB); + CHECK_EQ(count(r.store.db(), "SELECT count(*) FROM take_sightings WHERE slot = 20"), 2); // untouched // forgetting the slot takes its sightings with it, and only its own CHECK_EQ(count(r.store.db(), "SELECT count(*) FROM take_sightings WHERE card = " + std::to_string(card) + " AND slot = 21"), 1); - r.store.forgetSlot(card, 21, 12200); + r.store.forgetSlot(card, 21, 12400); CHECK_EQ(count(r.store.db(), "SELECT count(*) FROM take_sightings WHERE card = " + std::to_string(card) + " AND slot = 21"), 0); @@ -997,16 +1040,27 @@ int main() // --- what a new row inherits (heldBefore) --- { - const auto next = r.store.beginOp(r.session, "op-held", "rename", 13000); - // slot 20's newest row is stamped and is the file's: hash and stamp - const HistoryStore::Held same = r.store.heldBefore(next, 20, "020_1.WAV", 3000, kStamp); - CHECK(same.hash == hashA && same.modifiedMs == kStamp); + // S3d: a read of these very facts that nothing came after hands on + // the hash of the bytes it read, with the file's stamp — not the + // row's hash under the same frozen-clock facts + const auto renamed = r.store.beginOp(r.session, "op-held", "rename", 13000); + const HistoryStore::Held fromRead = r.store.heldBefore(renamed, 20, "020_1.WAV", 3000, kStamp); + CHECK(fromRead.hash == hashC && fromRead.modifiedMs == kStamp); // another stamp: another file — no hash, the file's own stamp - const HistoryStore::Held moved = r.store.heldBefore(next, 20, "020_1.WAV", 3000, kStamp + 2000); + const HistoryStore::Held moved = r.store.heldBefore(renamed, 20, "020_1.WAV", 3000, kStamp + 2000); CHECK(!moved.hash.has_value() && moved.modifiedMs == kStamp + 2000); // nothing on record: no hash, the file's stamp - const HistoryStore::Held none = r.store.heldBefore(next, 22, "022_1.WAV", 3000, kStamp); + const HistoryStore::Held none = r.store.heldBefore(renamed, 22, "022_1.WAV", 3000, kStamp); CHECK(!none.hash.has_value() && none.modifiedMs == kStamp); + // and the row written so, the newest word, names the read's bytes + r.store.recordPresentAudio(renamed, 20, 1, "020_1.WAV", 3000, fromRead.hash, fromRead.modifiedMs); + r.store.finishOp(renamed, OpStatus::done, ""); + CHECK(r.store.hashOfSighting(card, 20, twenty) == hashC); + // an operation on the slot after the read: the row's word again + landedOn(20, "020_1.WAV", a, "op-20-last", 13100, true); + const auto next = r.store.beginOp(r.session, "op-held-2", "rename", 13200); + const HistoryStore::Held fromRow = r.store.heldBefore(next, 20, "020_1.WAV", 3000, kStamp); + CHECK(fromRow.hash == hashA && fromRow.modifiedMs == kStamp); // a stampless row (older than version 9) vouches by name and size, // and the hash goes on without a stamp — whatever the file's is r.store.db().exec("UPDATE slot_audio SET modified = NULL WHERE slot = 20"); @@ -1035,10 +1089,11 @@ int main() }; }; const auto before = everything(); - for (int slot = 1; slot <= 22; ++slot) + for (int slot = 1; slot <= 24; ++slot) (void) r.store.hashOfSighting(card, slot, Sighting { "005_1.WAV", 3000, kStamp, 132300 }); (void) r.store.hashOfSighting(card, 20, twenty); (void) r.store.heldBefore(1, 20, "020_1.WAV", 3000, kStamp); + (void) r.store.newestOp(); (void) r.store.hashOfSighting(card, 17, Sighting { "017_1.WAV", 3000, kStamp, 132300 }); (void) r.store.cardFor("test-RC-5"); (void) r.store.cardFor("never-met"); diff --git a/tests/inferred_loudness_tests.cpp b/tests/inferred_loudness_tests.cpp index 4a1ff0f..0008e9a 100644 --- a/tests/inferred_loudness_tests.cpp +++ b/tests/inferred_loudness_tests.cpp @@ -33,8 +33,14 @@ // hands what it measured on through the recorder, once, for its slot // - and a real read corrects it (review S3): after Check loudness reads the // new bytes under the same facts, the next inference answers their -// reading, never the old one — and a read whose bytes have no reading -// answers nothing +// reading, never the old one — nor after an operation that followed the +// read (S3d: a rename hands the read's hash on) — and a read whose bytes +// have no reading answers nothing +// - reads are placed among the operations by sequence, never by clock: a +// first sighting resumed after a read photographs the slot anew (S7); a +// player read filed after an operation that ran during it does not +// outrank that operation (S8); a clock set back between a read and a +// clear does not let the read outlive the clear (S10) // - a migrated history (rows with no stamp) never infers through a row it // vouched for by name and size: a file overwritten outside the app with // one of the same size, then renamed, is not read as the old take @@ -468,9 +474,21 @@ int main() CHECK_EQ(corrected.size(), 1u); CHECK(!corrected.empty() && same(corrected.front().reading, checked.reading)); CHECK(!corrected.empty() && !same(corrected.front().reading, measured.reading)); + // S3d: an operation after the read — a rename, twice — hands the + // read's word on; the old take does not come back + for (const char* name : { "Renamed", "Again" }) { + const std::string opId = std::string("op-rename-12-") + name; + CHECK_EQ(run(*rec, opId, "rename", volume, [&] { + commands::rename(volume, 12, name, options(rec, opId)); + }), + std::string()); + const auto renamed = history::inferLoudness(store, card, { sight(volume, 12) }).found; + CHECK_EQ(renamed.size(), 1u); + CHECK(!renamed.empty() && same(renamed.front().reading, checked.reading)); + } // a read whose bytes have no reading on file answers nothing — not the old take store.recordSighting(card, 12, again.take.name, again.take.size, again.take.modifiedMs, - HistoryStore::contentHash("bytes nobody measured"), tick()); + HistoryStore::contentHash("bytes nobody measured"), store.newestOp(), tick()); CHECK(history::inferLoudness(store, card, { again }).found.empty()); } @@ -540,5 +558,110 @@ int main() CHECK(!both.found.empty() && both.found.front().sighted.slot == 7); } + // The quiet loop as the card holds it, and what the history measured it at. + const std::string quietOnCard = + commands::readFileBytes(volume::wavDir(volume, 4) / volume::listSlotWavs(volume, 4).front()); + const std::optional quietReading = + store.readingFor(HistoryStore::contentHash(quietOnCard)); + CHECK(quietReading.has_value()); + + // --- a read before an interrupted first sighting resumes (review S7) --- + // The baseline's operation keeps the sequence of the connect it began on; + // a slot it photographs on a later connect is newer than a read between. + { + const fs::path src = tmp.path / "060_1.WAV"; + writeSine(src, 88200, -20.0); + commands::WriteOptions bare; // the pedal recorded it: nothing told the history + bare.journal.bodiesChanging = [](const std::vector&) {}; + commands::push(volume, src, 60, { .write = bare }); + const auto baseline = rec->firstSeen(volume); // still running, slot 60 not reached + CHECK(baseline.has_value()); + const history::SlotSighting seen = sight(volume, 60); + const history::SlotLoudness read60 = history::readSlotLoudness(volume, 60, *rec); + CHECK(read60.kept); + const auto before = history::inferLoudness(store, card, { seen }).found; + CHECK(before.size() == 1u && same(before.front().reading, read60.reading)); // the read's word + rec->disconnect(); // the baseline is interrupted before slot 60 + // the pedal records slot 60 again: the same length, name and frozen stamp + const fs::path take60 = volume::wavDir(volume, 60) / "060_1.WAV"; + commands::writeFileBytes(take60, quietOnCard); + setStamp(take60, seen.take.modifiedMs); + CHECK_EQ(sight(volume, 60).take.modifiedMs, seen.take.modifiedMs); + const auto resumed = rec->firstSeen(volume); // the next connect resumes it + CHECK(resumed.has_value()); + if (resumed) + rec->snapshotStep(*resumed, 60); // and photographs slot 60's bytes now + CHECK_EQ(count(store.db(), "SELECT count(*) FROM take_sightings WHERE slot = 60"), 0); + const auto after = history::inferLoudness(store, card, { sight(volume, 60) }).found; + CHECK_EQ(after.size(), 1u); + CHECK(!after.empty() && quietReading && same(after.front().reading, quietReading->reading)); + CHECK(!after.empty() && !same(after.front().reading, read60.reading)); + } + + // --- a read filed after an operation that ran during it (review S8) --- + // The player's pass reads on its own thread; its filing is a background + // job that a foreground operation jumps ahead of. Facts equal across the + // operation (FAT's two-second step) are simulated by restamping. + { + const fs::path src = tmp.path / "s70.wav"; + writeSine(src, 88200, -20.0); + CHECK_EQ(run(*rec, "op-push-70", "push", volume, [&] { + commands::push(volume, src, 70, { .write = options(rec, "op-push-70") }); + }), + std::string()); + const fs::path take70 = volume::wavDir(volume, 70) / "s70.wav"; + const history::SlotSighting read = sight(volume, 70); // the pass's facts + const std::string readHash = HistoryStore::contentHash(commands::readFileBytes(take70)); + const std::optional began = rec->newestOp(); // where the pass's read began + CHECK(began.has_value()); + // the foreground operation: another take under the same name and size + const fs::path srcQuiet = tmp.path / "q70" / "s70.wav"; + fs::create_directories(srcQuiet.parent_path()); + writeSine(srcQuiet, 88200, -28.0); + CHECK_EQ(run(*rec, "op-push-70b", "push", volume, [&] { + commands::push(volume, srcQuiet, 70, { .force = true, .write = options(rec, "op-push-70b") }); + }), + std::string()); + setStamp(take70, read.take.modifiedMs); // the write landed in the same two-second step + sqlite::Statement same70(store.db(), "UPDATE slot_audio SET modified = ?1 WHERE slot = 70 AND side = 'after' " + "AND op = (SELECT max(op) FROM slot_audio WHERE slot = 70)"); + same70.bind(1, read.take.modifiedMs).run(); + const history::SlotSighting now70 = sight(volume, 70); + CHECK(now70.take.name == read.take.name && now70.take.size == read.take.size + && now70.take.modifiedMs == read.take.modifiedMs); + // the pass's filing job runs now, after the operation + CHECK(began && rec->sighted(volume, 70, read.take.name, read.take.size, read.take.modifiedMs, + readHash, *began)); + const auto found = history::inferLoudness(store, card, { sight(volume, 70) }).found; + CHECK_EQ(found.size(), 1u); + CHECK(!found.empty() && quietReading && same(found.front().reading, quietReading->reading)); + CHECK(!found.empty() && !same(found.front().reading, measured.reading)); // never the take that is gone + } + + // --- the wall clock set back between a read and a later clear (review S10) --- + { + const fs::path src = tmp.path / "s80.wav"; + writeSine(src, 88200, -20.0); + CHECK_EQ(run(*rec, "op-push-80", "push", volume, [&] { + commands::push(volume, src, 80, { .write = options(rec, "op-push-80") }); + }), + std::string()); + const history::SlotSighting seen = sight(volume, 80); + CHECK(history::readSlotLoudness(volume, 80, *rec).kept); // a read of the loud loop + CHECK_EQ(history::inferLoudness(store, card, { seen }).found.size(), 1u); + clockAt -= 3'600'000; // the computer's clock goes back an hour + CHECK_EQ(run(*rec, "op-clear-80", "clear", volume, [&] { + commands::clear(volume, { 80 }, { .write = options(rec, "op-clear-80") }); + }), + std::string()); + // the pedal records a new loop into slot 80 under the old facts + fs::create_directories(volume::wavDir(volume, 80)); + const fs::path take80 = volume::wavDir(volume, 80) / "s80.wav"; + commands::writeFileBytes(take80, quietOnCard); + setStamp(take80, seen.take.modifiedMs); + CHECK(history::inferLoudness(store, card, { sight(volume, 80) }).found.empty()); + CHECK(history::inferLoudness(store, card, { seen }).found.empty()); + } + return testkit::summary("inferred_loudness_tests"); } From e6042681ae76bd8cc0fda0fcccdf9247a49b7bca Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 12:13:58 +0200 Subject: [PATCH 11/14] Lift a failed read's mark on a good read, and keep the gate and mixes out of it Three small ones from the re-review of #141. A slot remembered as unreadable stayed so for the connection even after a read of it succeeded: any successful read of the slot now lifts the mark. A Check the lifecycle gate turned away - the pedal leaving, a ghost mount - never reached the file, yet marked it unreadable: the check job now marks only a failure that came after the gate let it through to the card. And the player's slot was read in the background even when it held a multi-track memory playing as a mix, which the player never measures: track 1's number is not the mix's, and nobody asked for the banner a failure raises. A mix is now any other slot to the column. --- app/MainComponent.cpp | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/app/MainComponent.cpp b/app/MainComponent.cpp index ea244a6..ad99288 100644 --- a/app/MainComponent.cpp +++ b/app/MainComponent.cpp @@ -630,7 +630,6 @@ MainComponent::MainComponent(std::string explicitVolume, juce::File dataOverride if (description.startsWith("Check slot") && slot > 0) { player.clearLoudness(slot); // a failed read must not stay "measuring…" table.clearPendingLoudness(slot); // …nor its cell "…" - readFailed(slot); // …nor the history refill it (#141) } if (inBatch) { ++batchFailed; @@ -2447,6 +2446,20 @@ void MainComponent::enqueueLoudnessRead(int slot, double target, int batch) player.setLoudnessPending(slot); // ignored unless that slot is loaded auto note = std::make_shared(); juce::Component::SafePointer safe(this); + // A read that failed on the file itself is remembered (readFailed, #141); + // one the lifecycle gate turned away — the pedal leaving, a ghost mount — + // never reached the file and says nothing about it. `before` runs only + // once the gate has let the job through to the card. + auto reached = std::make_shared(false); + auto markReached = [reached](const volume::fs::path&) { *reached = true; }; + auto rememberFailure = [safe, slot, reached, alive = uiAlive](const std::string& error) { + if (error.empty() || !*reached) + return; + juce::MessageManager::callAsync([safe, slot, alive] { + if (*alive && safe != nullptr) + safe->readFailed(slot); + }); + }; worker.enqueue( { "Check slot " + juce::String(slot) + " loudness", slot, @@ -2467,7 +2480,8 @@ void MainComponent::enqueueLoudnessRead(int slot, double target, int batch) safe->applyLoudnessReport(slot, report, batch); }); }, - note, batch, /*background=*/true }); + note, batch, /*background=*/true, /*quiet=*/false, /*needsVolume=*/true, + std::move(markReached), std::move(rememberFailure) }); } // The player's pass metered the loaded loop on its own thread; the history @@ -2528,6 +2542,8 @@ void MainComponent::readFailed(int slot) // measure at once, and a guess there would sit beside a player that does not // re-read a file it already has (an Undo on another slot clears every cell, // this one's included). If nothing is reading it, the background read does. +// A multi-track memory playing as a mix is not measured by the player, and +// is asked of the history like any other slot. // Not while the LUFS column is hidden: nobody would see it. Not for a file a // read already failed on in this connection (readFailed). // @@ -2550,11 +2566,18 @@ void MainComponent::inferLoudnessFromHistory() && !(unreadableTakes.contains(row.info.slot) && unreadableTakes.at(row.info.slot) == *row.take); }; const int loaded = player.currentSlot(); + // The player measures the one file it loads; a multi-track memory plays + // as a mix and is measured by nobody, so it is any other slot here — no + // read on its behalf (track 1's number is not the mix's, and a banner + // for a read nobody asked for helps nobody). + const auto playerMeasures = [loaded](const SlotRow& row) { + return row.info.slot == loaded && row.info.tracks.size() <= 1; + }; std::vector sightings; for (const SlotRow& row : snapshot.slots) { if (!blank(row)) continue; - if (row.info.slot == loaded) { + if (playerMeasures(row)) { if (!player.loudnessPending()) enqueueLoudnessRead(loaded, currentTargetLufs(), 0); continue; @@ -2608,7 +2631,7 @@ void MainComponent::inferLoudnessFromHistory() || row->take->size != seen.size || row->take->modifiedMs != seen.modifiedMs || row->info.frames != seen.frames || safe->table.loudnessFor(f.sighted.slot) != nullptr - || f.sighted.slot == safe->player.currentSlot()) + || (f.sighted.slot == safe->player.currentSlot() && row->info.tracks.size() <= 1)) continue; safe->table.setLoudness( f.sighted.slot, @@ -2628,6 +2651,7 @@ void MainComponent::inferLoudnessFromHistory() void MainComponent::applyLoudnessReport(int slot, const LoudnessReport& report, int batch) { + unreadableTakes.erase(slot); // a real read of the slot succeeded: its file is readable (#141) table.setLoudness(slot, { report.cellText, report.attention, false, report.rowText, report.damaged, report.tooltipText }); player.setLoudness(slot, report.rowText, report.attention, report.damaged, From 472f970b3c18b5ccdab4a3e5ed1b63ff725a2670 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 12:33:50 +0200 Subject: [PATCH 12/14] File what a normalize read, so one that finds its slot at target corrects the column A normalize measures the slot's bytes before it decides, and its hook filed the reading and told the column - but not what it had read. One that found the slot at target writes only a subject, so the next connect still trusted the row that named the file under the same frozen-clock facts and showed the old take's number (round 3 review of #141, S12: measured -27.997, nothing applied, next connect -19.997). The hook now files a sighting too: the take's facts from one stat, under the hash of the bytes measured, placed just before the operation it is part of. A normalize that then writes records the archived and the landed take with its own, higher sequence, which makes its read history; one that changes nothing leaves the read standing for the next connect. --- app/history/HistoryRecorder.cpp | 26 ++++++++++++++-- app/history/HistoryRecorder.h | 7 +++++ app/history/HistoryStore.h | 3 +- tests/inferred_loudness_tests.cpp | 49 +++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/app/history/HistoryRecorder.cpp b/app/history/HistoryRecorder.cpp index a9f4172..5c382bc 100644 --- a/app/history/HistoryRecorder.cpp +++ b/app/history/HistoryRecorder.cpp @@ -258,6 +258,23 @@ bool HistoryRecorder::sighted(const std::filesystem::path& volume, int slot, con return true; } +void HistoryRecorder::sightedInOperation(const std::string& opId, int slot, const std::string& fileName, + std::int64_t size, const std::string& hash) +{ + const Operation& op = operation(opId); + const std::optional seen = statFile(volume::wavDir(op.volume, slot) / fileName); + if (!seen || seen->size != size) + return; // the entry does not describe the bytes read: nothing to say about it + const std::optional card = store().cardFor(sessionMarker_); + if (!card) + throw Error("the open session's card " + sessionMarker_ + " is not in the history"); + // Placed just before the operation it is part of: whatever the operation + // then records in the slot — the archived take, the landed one — has a + // higher sequence and makes the read history; an operation that changes + // nothing leaves it standing. + store().recordSighting(*card, slot, fileName, seen->size, seen->modifiedMs, hash, op.row - 1, clock_()); +} + void HistoryRecorder::recordWhatSlotsHold(const Operation& op) { const std::vector slots = store().touchedSlots(op.row); @@ -342,12 +359,17 @@ commands::WriteOptions withHistory(const std::shared_ptr& recor // 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&, + options.journal.loudnessMeasured = [recorder, opId](int slot, const std::string& fileName, std::string_view bytes, const wav::LoudnessReading& reading) { - if (!recorder->reading(HistoryStore::contentHash(bytes), reading)) + const std::string hash = HistoryStore::contentHash(bytes); + if (!recorder->reading(hash, reading)) throw Error("operation " + opId + " measured slot " + std::to_string(slot) + " with no card session open to file the reading in"); + // A measurement is a real read: what it saw goes in too, so a + // normalize that finds its slot at target tells the next connect + // which bytes the slot holds (#141, review S12). + recorder->sightedInOperation(opId, slot, fileName, static_cast(bytes.size()), hash); if (recorder->onMeasured) recorder->onMeasured(slot, reading); }; diff --git a/app/history/HistoryRecorder.h b/app/history/HistoryRecorder.h index f57eeab..e8300a9 100644 --- a/app/history/HistoryRecorder.h +++ b/app/history/HistoryRecorder.h @@ -86,6 +86,13 @@ class HistoryRecorder bool sighted(const std::filesystem::path& volume, int slot, const std::string& name, std::int64_t size, std::int64_t modifiedMs, const std::string& hash, std::int64_t readBegan); + // What a command read inside an operation it is part of (normalize's + // measurement): the take's facts as one stat now gives them, under the + // hash of the bytes read, placed just before the operation's own rows + // so that anything it then records in the slot makes the read history. + // Nothing when the entry's size is not the bytes'. + void sightedInOperation(const std::string& opId, int slot, const std::string& fileName, + std::int64_t size, const std::string& hash); // The newest operation the history has begun, by sequence, for a read // about to begin to note as its place (#141): absent until the store has // opened. Any thread — a read begins on the player's thread too. It diff --git a/app/history/HistoryStore.h b/app/history/HistoryStore.h index 3d64c79..f99a840 100644 --- a/app/history/HistoryStore.h +++ b/app/history/HistoryStore.h @@ -230,7 +230,8 @@ class HistoryStore // What a real read of a take saw (#141): the file's name, size and stamp // on this card's slot, and the hash of the bytes read under them — the - // check job's read and the player's pass both have all of it. + // check job's read, the player's pass and a normalize's measurement all + // have all of it. // `readBegan` is newestOp() as it stood when the read began; the read is // filed after the slot's newest operation at or below it, and every // operation that records a body or a take in the slot with a higher diff --git a/tests/inferred_loudness_tests.cpp b/tests/inferred_loudness_tests.cpp index 0008e9a..b164420 100644 --- a/tests/inferred_loudness_tests.cpp +++ b/tests/inferred_loudness_tests.cpp @@ -41,6 +41,10 @@ // player read filed after an operation that ran during it does not // outrank that operation (S8); a clock set back between a read and a // clear does not let the read outlive the clear (S10) +// - a normalize that finds its slot at target read the bytes, and says +// so: the next connect answers what it measured, not the take a row +// named under the same facts (S12); one that writes makes its own read +// history by the rows it records // - a migrated history (rows with no stamp) never infers through a row it // vouched for by name and size: a file overwritten outside the app with // one of the same size, then renamed, is not read as the old take @@ -638,6 +642,51 @@ int main() CHECK(!found.empty() && !same(found.front().reading, measured.reading)); // never the take that is gone } + // --- a normalize that finds the slot at target is a read too (review S12) --- + { + CHECK_EQ(run(*rec, "op-push-90", "push", volume, [&] { + commands::push(volume, loud, 90, { .write = options(rec, "op-push-90") }); + }), + std::string()); + const history::SlotSighting pushed = sight(volume, 90); + // the pedal records the slot again: the quiet loop, the same frozen facts + const fs::path take90 = volume::wavDir(volume, 90) / pushed.take.name; + commands::writeFileBytes(take90, quietOnCard); + setStamp(take90, pushed.take.modifiedMs); + const history::SlotSighting frozen = sight(volume, 90); + const auto guessed = history::inferLoudness(store, card, { frozen }).found; + CHECK(guessed.size() == 1u && same(guessed.front().reading, measured.reading)); // fooled, as ever + // normalize to the quiet loop's own loudness: it measures, finds the + // slot at target, and writes nothing + CHECK(quietReading && quietReading->reading.integratedLufs); + commands::NormalizeResult atTarget; + CHECK_EQ(run(*rec, "op-normalize-90", "normalize", volume, [&] { + atTarget = commands::normalize(volume, 90, + { .targetLufs = *quietReading->reading.integratedLufs, + .write = options(rec, "op-normalize-90") }); + }), + std::string()); + CHECK(!atTarget.applied); + CHECK_EQ(count(store.db(), "SELECT count(*) FROM take_sightings WHERE slot = 90"), 1); + // the next connect: what the normalize measured, not the loud take + const auto next = history::inferLoudness(store, card, { sight(volume, 90) }).found; + CHECK_EQ(next.size(), 1u); + CHECK(!next.empty() && same(next.front().reading, quietReading->reading)); + CHECK(!next.empty() && !same(next.front().reading, measured.reading)); + // a normalize that writes: its own rows make its read history — the + // facts it read under answer nothing, the new take is known by its row + commands::NormalizeResult written; + CHECK_EQ(run(*rec, "op-normalize-90b", "normalize", volume, [&] { + written = commands::normalize(volume, 90, + { .targetLufs = *quietReading->reading.integratedLufs + 6.0, + .write = options(rec, "op-normalize-90b") }); + }), + std::string()); + CHECK(written.applied); + CHECK(history::inferLoudness(store, card, { frozen }).found.empty()); + CHECK(history::inferLoudness(store, card, { sight(volume, 90) }).found.empty()); // nobody measured the new bytes + } + // --- the wall clock set back between a read and a later clear (review S10) --- { const fs::path src = tmp.path / "s80.wav"; From 67b5a959fcc71e0aef3ffdcff4d53ac5259a9f60 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 12:35:50 +0200 Subject: [PATCH 13/14] Never issue an operation sequence twice beginOp took max(seq) + 1, so forgetting a slot's history that dropped the newest operations whole handed their numbers out again, and a read placed after "operation N" could then stand after an operation that began later (round 3 review of #141, S11b). Not reachable through the app today - the forget's own maintenance operation holds the maximum - but the order of reads must not rest on that. Version 10, which has not shipped, adds op_sequence: one row, the highest sequence ever issued, started from what the store holds and moved by a trigger on every insert into ops. beginOp issues the next value past it (and past the Undo floor, as before), and newestOp reads it, so neither goes back when operations are forgotten - across a reopen too. --- app/history/HistoryStore.cpp | 11 ++++++---- app/history/HistoryStore.h | 5 +++-- app/history/Schema.h | 20 ++++++++++++++++++ tests/history_schema_tests.cpp | 14 ++++++++++++- tests/history_store_tests.cpp | 38 ++++++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/app/history/HistoryStore.cpp b/app/history/HistoryStore.cpp index f7b395b..11c0195 100644 --- a/app/history/HistoryStore.cpp +++ b/app/history/HistoryStore.cpp @@ -198,9 +198,12 @@ void HistoryStore::closeSession(std::int64_t session, std::int64_t nowMs) std::int64_t HistoryStore::beginOp(std::int64_t session, const std::string& opId, const std::string& kind, std::int64_t atMs) { + // The next sequence past every one ever issued (op_sequence, which the + // insert itself moves): never a number a forgotten operation had, and + // past the Undo floor as before. sqlite::Statement add(db_, "INSERT INTO ops(seq, id, session, kind, actor, status, at) " - "VALUES (max(coalesce((SELECT max(seq) FROM ops), 0), " - "coalesce((SELECT max(undo_floor) FROM cards), 0)) + 1, ?1, ?2, ?3, 'app', 'pending', ?4)"); + "VALUES ((SELECT max(last, coalesce((SELECT max(undo_floor) FROM cards), 0)) + 1 " + "FROM op_sequence WHERE one = 1), ?1, ?2, ?3, 'app', 'pending', ?4)"); add.bindText(1, opId).bind(2, session).bindText(3, kind).bind(4, atMs).run(); return db_.lastInsertRowid(); } @@ -480,9 +483,9 @@ std::optional HistoryStore::cardFor(const std::string& markerId) std::int64_t HistoryStore::newestOp() { - sqlite::Statement read(db_, "SELECT coalesce(max(seq), 0) FROM ops"); + sqlite::Statement read(db_, "SELECT last FROM op_sequence WHERE one = 1"); if (!read.step()) - throw Error("the history could not say its newest operation"); + throw Error("the history has no operation sequence"); return read.integer(0); } diff --git a/app/history/HistoryStore.h b/app/history/HistoryStore.h index f99a840..155969a 100644 --- a/app/history/HistoryStore.h +++ b/app/history/HistoryStore.h @@ -224,8 +224,9 @@ class HistoryStore std::int64_t modifiedMs = 0; std::int64_t frames = 0; }; - // The newest operation's sequence, 0 before the first: what a read that - // begins now comes after. + // The highest operation sequence ever issued, 0 before the first: what a + // read that begins now comes after. It never goes down, and beginOp never + // issues a sequence at or below it — forgotten operations included. std::int64_t newestOp(); // What a real read of a take saw (#141): the file's name, size and stamp diff --git a/app/history/Schema.h b/app/history/Schema.h index 4e774e0..19ed506 100644 --- a/app/history/Schema.h +++ b/app/history/Schema.h @@ -72,6 +72,12 @@ // higher sequence recorded a body or a take in the slot. By // card and slot; forgetting a slot's history, or a first // sighting photographing it, takes its sightings with it. +// op_sequence the highest operation sequence ever issued, one row, moved +// by every insert into ops (a trigger). Sequences are what +// orders a read among operations (take_sightings), so none +// is ever issued twice: forgetting a slot's history can drop +// the newest operations whole, and a sequence taken from +// max(seq) would hand their numbers out again (#141). // // 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 @@ -250,6 +256,20 @@ CREATE TABLE take_sightings( at INTEGER NOT NULL, PRIMARY KEY (card, slot, name, size, modified) ) STRICT; +-- The highest operation sequence ever issued (#141): one row, never lower, +-- so a sequence is never handed out twice even when the newest operations +-- are forgotten whole. Started from what the store holds; every insert +-- into ops moves it. +CREATE TABLE op_sequence( + one INTEGER PRIMARY KEY CHECK (one = 1), + last INTEGER NOT NULL CHECK (last >= 0) +) STRICT; +INSERT INTO op_sequence(one, last) + SELECT 1, max(coalesce((SELECT max(seq) FROM ops), 0), coalesce((SELECT max(undo_floor) FROM cards), 0)); +CREATE TRIGGER op_sequence_moves AFTER INSERT ON ops +BEGIN + UPDATE op_sequence SET last = max(last, NEW.seq) WHERE one = 1; +END; )sql", }; diff --git a/tests/history_schema_tests.cpp b/tests/history_schema_tests.cpp index 1f8440d..d41c1f4 100644 --- a/tests/history_schema_tests.cpp +++ b/tests/history_schema_tests.cpp @@ -187,7 +187,7 @@ int main() 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"), 10); - CHECK_EQ(count(db, "SELECT count(*) FROM sqlite_master WHERE type = 'table'"), 12); + CHECK_EQ(count(db, "SELECT count(*) FROM sqlite_master WHERE type = 'table'"), 13); 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); @@ -407,6 +407,9 @@ int main() CHECK_EQ(schema::pragmaInteger(db, "user_version"), 10); CHECK_EQ(count(db, "SELECT count(*) FROM sqlite_master WHERE name = 'take_sightings'"), 1); CHECK_EQ(count(db, "SELECT count(*) FROM take_sightings"), 0); + // the sequence starts where the store stood: one operation, number 1 + CHECK_EQ(count(db, "SELECT last FROM op_sequence"), 1); + CHECK_EQ(migrated.newestOp(), 1); CHECK_EQ(count(db, "SELECT modified FROM slot_audio WHERE slot = 4"), 1'700'000'000'000); CHECK_EQ(count(db, "SELECT count(*) FROM slot_audio WHERE slot = 5 AND modified IS NULL"), 1); // the new table takes a read the way the store files one, and holds its rules @@ -429,6 +432,15 @@ int main() CHECK_THROWS(db.exec("INSERT INTO take_sightings VALUES (1, 4, 'x', 1, 1, x'" + std::string(64, 'a') + "', 0, 1)"), "CHECK"); // no operation has sequence 0: none is NULL + // one row, never a second; every insert into ops moves it, a direct + // one too, and the next operation the store begins is past it + CHECK_THROWS(db.exec("INSERT INTO op_sequence VALUES (2, 5)"), "CHECK"); + CHECK_THROWS(db.exec("INSERT INTO op_sequence VALUES (1, 5)"), "UNIQUE"); + db.exec("INSERT INTO ops(seq, id, session, kind, actor, status, at) VALUES (40, 'v10-direct', 1, 'push', 'app', 'done', 5)"); + CHECK_EQ(migrated.newestOp(), 40); + db.exec("DELETE FROM ops WHERE seq = 40"); + CHECK_EQ(migrated.newestOp(), 40); + CHECK_EQ(migrated.beginOp(1, "v10-next", "push", 6), 41); CHECK_EQ(count(db, "SELECT count(*) FROM pragma_foreign_key_check"), 0); } { diff --git a/tests/history_store_tests.cpp b/tests/history_store_tests.cpp index 3948be0..3615def 100644 --- a/tests/history_store_tests.cpp +++ b/tests/history_store_tests.cpp @@ -1101,5 +1101,43 @@ int main() CHECK_EQ(count(r.store.db(), "SELECT count(*) FROM loudness_readings"), 0); } + // --- the operations' sequence never goes back (#141, review S11b) --- + // + // Theory: a sequence orders a read among operations, so none is ever + // issued twice. Forgetting a slot's history drops operations whole when + // the slot held their only state — the newest ones included — and the + // next operation must still take a sequence above every one ever + // issued, across a reopen too. newestOp says the highest ever issued. + { + TempDir tmp; + std::int64_t newest = 0; + std::int64_t after = 0; + { + Ready r(tmp.path); + const std::int64_t card = *r.store.selectedCard(); + CHECK_EQ(r.store.newestOp(), 0); + for (const char* id : { "op-a", "op-b", "op-c" }) { + const auto op = r.store.beginOp(r.session, id, "rename", 2000); + r.store.recordBodies(op, { { 40, testkit::syntheticSlotBody("Before"), + testkit::syntheticSlotBody("After") } }); + r.store.finishOp(op, OpStatus::done, ""); + CHECK(op > newest); + newest = op; + } + CHECK_EQ(r.store.newestOp(), newest); + r.store.forgetSlot(card, 40, 3000, true); + CHECK_EQ(count(r.store.db(), "SELECT count(*) FROM ops"), 0); // forgotten whole, the newest too + CHECK_EQ(count(r.store.db(), "SELECT coalesce(max(seq), 0) FROM ops"), 0); + CHECK_EQ(r.store.newestOp(), newest); // the high-water mark stays + after = r.store.beginOp(r.session, "op-after", "rename", 4000); + CHECK(after > newest); + CHECK_EQ(r.store.newestOp(), after); + } + HistoryStore reopened(tmp.path); // the first store is closed by now + CHECK_EQ(reopened.newestOp(), after); + const auto session = reopened.openSession(reopened.card("test-RC-5", "RC-5", "BOSS RC-5", 5000), 5000); + CHECK(reopened.beginOp(session, "op-reopened", "rename", 5000) > after); + } + return testkit::summary("history_store_tests"); } From 55d50778727eddc2a683e2da85b326363e26cc24 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Mon, 5 Oct 2026 13:34:08 +0200 Subject: [PATCH 14/14] Read only a regular file as bytes, and refuse anything else in our own words take_facts_tests aborted on the Linux lanes: the connect scan's doctor pass reads takes through readFileBytes, and a folder under a take's name opens as a stream on Linux, after which libstdc++ throws std::ios_base::failure from the first read ('Is a directory'). Nothing expects that exception; on a card it would have ended the worker's scan. macOS's library fails the stream quietly, so the suite passed there. readFileBytes now refuses a path that is not a regular file with an Error before a stream exists. --- core/include/loopercat/Commands.hpp | 8 ++++++++ tests/commands_tests.cpp | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/core/include/loopercat/Commands.hpp b/core/include/loopercat/Commands.hpp index 3084e8c..8fc8b49 100644 --- a/core/include/loopercat/Commands.hpp +++ b/core/include/loopercat/Commands.hpp @@ -47,6 +47,14 @@ namespace fs = std::filesystem; inline std::string readFileBytes(const fs::path& path, const std::function& progress = {}) { + // Only a regular file is bytes. A folder under a take's name opens as a + // stream on Linux, and libstdc++ then throws std::ios_base::failure from + // the first read ("Is a directory") instead of failing the stream: an + // exception none of our callers expects, which ended the connect scan's + // doctor pass. Refused here in our own words, before a stream exists. + std::error_code ec; + if (!fs::is_regular_file(path, ec)) + throw Error("cannot read " + path.string() + ": not a file"); std::ifstream in(path, std::ios::binary); if (!in) throw Error("cannot read " + path.string()); diff --git a/tests/commands_tests.cpp b/tests/commands_tests.cpp index 23f3cfc..6b303b6 100644 --- a/tests/commands_tests.cpp +++ b/tests/commands_tests.cpp @@ -383,6 +383,20 @@ std::string slotChangesOnCard(const std::string& before, const std::string& afte int main() { + // Only a regular file is read as bytes: a folder or a missing path is + // refused with our own error, never a stream exception from the library + // (libstdc++ opens a folder and throws from its first read). + { + TempDir tmp; + const fs::path folder = tmp.path / "002_1.WAV"; + fs::create_directories(folder); + CHECK_THROWS(commands::readFileBytes(folder), "not a file"); + CHECK_THROWS(commands::readFileBytes(folder, [](double) {}), "not a file"); + CHECK_THROWS(commands::readFileBytes(tmp.path / "nowhere.wav"), "not a file"); + commands::writeFileBytes(tmp.path / "take.wav", "RIFF"); + CHECK_EQ(commands::readFileBytes(tmp.path / "take.wav"), std::string("RIFF")); + } + // A missing journal refuses before any byte on the card changes. { TempDir tmp;