diff --git a/CMakeLists.txt b/CMakeLists.txt index cdeb6e6..9fc6354 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, @@ -779,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/MainComponent.cpp b/app/MainComponent.cpp index 075e47a..ad99288 100644 --- a/app/MainComponent.cpp +++ b/app/MainComponent.cpp @@ -366,10 +366,26 @@ 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); + // 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); + 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) + // 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)) @@ -593,6 +609,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, @@ -649,8 +671,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, @@ -1319,8 +1343,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 }); @@ -1395,6 +1421,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() @@ -1710,8 +1737,11 @@ 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 + unreadableTakes.clear(); // ...and what failed to read was another card's (#141) + inferenceProblemsLogged = std::make_shared>(); + } snapshot = latest; if (anotherVolume) refreshUndoOffer(); // what Undo offers is read for the card in front of it @@ -1806,6 +1836,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 — @@ -1862,21 +1893,27 @@ 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 // 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); } @@ -2409,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, @@ -2429,21 +2480,30 @@ 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 // 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 — +// 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 readBegan) { 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), + began = readBegan, mounted = volume::fs::path(snapshot.volume), logDir = settings.dataDir()](const volume::fs::path&) { try { - rec->reading(key, reading); + 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())); @@ -2456,8 +2516,142 @@ 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 — +// 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. +// +// 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. +// 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). +// +// 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() + || !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(); + // 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 (playerMeasures(row)) { + 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); + worker.enqueue( + { "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 : inference.found) + 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, + // 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 + || (f.sighted.slot == safe->player.currentSlot() && row->info.tracks.size() <= 1)) + 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) { + 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, diff --git a/app/MainComponent.h b/app/MainComponent.h index 3e4c03c..6b94f5e 100644 --- a/app/MainComponent.h +++ b/app/MainComponent.h @@ -5,6 +5,7 @@ #include +#include #include #include #include @@ -30,6 +31,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" @@ -305,8 +307,15 @@ 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, 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 + // 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(); @@ -358,6 +367,16 @@ 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 + // 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; + // 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; int selectedSlot = 0; // what the Properties tab is showing (0 = nothing) 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/PlayerPane.cpp b/app/PlayerPane.cpp index 29e5877..eee4655 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 @@ -449,10 +467,12 @@ void PlayerPane::passFinished(int slot, std::optional read "Could not measure this file."); loudnessPending_ = false; updateLoudnessButtons(); + if (onLoudnessFailed) + onLoudnessFailed(slot); 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..80fb337 100644 --- a/app/PlayerPane.h +++ b/app/PlayerPane.h @@ -5,6 +5,7 @@ #include "AudioEngine.h" #include "CardPermissions.h" +#include "TakeFacts.h" #include @@ -107,12 +108,24 @@ 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. + // 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 // selection". All setters ignore a slot that is not the loaded one. - std::function + 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); @@ -156,7 +169,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 +196,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/SlotTable.cpp b/app/SlotTable.cpp index 7b62090..f3d9cee 100644 --- a/app/SlotTable.cpp +++ b/app/SlotTable.cpp @@ -89,6 +89,23 @@ 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 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. 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; +} + void SlotTable::setLoudness(int slot, LoudnessCell cell) { loudness_[slot] = std::move(cell); @@ -138,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); @@ -247,6 +269,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,7 +610,13 @@ void SlotTable::paintCell(juce::Graphics& g, int row, int columnId, int width, i } return; } - g.setColour(!known ? kDim.withAlpha(0.55f) + // A number out of the history rather than a read (#141) wears the + // 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. 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 diff --git a/app/SlotTable.h b/app/SlotTable.h index 92cb9f6..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 @@ -68,7 +69,20 @@ 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 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, 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); void clearLoudness(int slot); @@ -105,6 +119,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/app/TakeFacts.h b/app/TakeFacts.h new file mode 100644 index 0000000..6ff5020 --- /dev/null +++ b/app/TakeFacts.h @@ -0,0 +1,51 @@ +// 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 + +//============================================================================== +// 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 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, 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 +{ + +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) +{ + const std::optional stat = history::statFile(file); + if (!stat) + return std::nullopt; + 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/app/history/HistoryRecorder.cpp b/app/history/HistoryRecorder.cpp index c835822..5c382bc 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) @@ -224,6 +245,36 @@ 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, + 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, readBegan, clock_()); + 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); @@ -236,16 +287,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); } } } @@ -307,12 +359,19 @@ 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); }; // 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 d7f533c..e8300a9 100644 --- a/app/history/HistoryRecorder.h +++ b/app/history/HistoryRecorder.h @@ -6,6 +6,7 @@ #include "HistoryStore.h" #include +#include #include #include #include @@ -75,6 +76,35 @@ 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. `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 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 + // 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; + // 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 @@ -141,9 +171,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); @@ -160,6 +191,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 844664c..11c0195 100644 --- a/app/history/HistoryStore.cpp +++ b/app/history/HistoryStore.cpp @@ -7,6 +7,7 @@ #include "Schema.h" #include "Undo.h" +#include #include #include @@ -156,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); @@ -191,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(); } @@ -276,7 +286,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)"); @@ -285,7 +295,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(); } @@ -401,13 +414,27 @@ 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 // 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 " @@ -416,22 +443,161 @@ 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) +{ + 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::int64_t HistoryStore::newestOp() +{ + sqlite::Statement read(db_, "SELECT last FROM op_sequence WHERE one = 1"); + if (!read.step()) + throw Error("the history has no operation sequence"); + 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 readBegan, + std::int64_t nowMs) +{ + if (hash.size() != 32) + throw Error("a content hash is 32 bytes, not " + std::to_string(hash.size())); + 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, 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, readBegan).bind(8, 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. A subject alone + // (#144: an operation about the slot that changed nothing) is not one. + std::optional last; + { + 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)"); + read.bind(1, card).bind(2, slot); + if (read.step() && !read.isNull(0)) + last = read.integer(0); + } + // 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, 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()) { + 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 + // 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, 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"); + 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. + 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 + // 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 + 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: + // 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); + // 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) @@ -797,6 +963,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 8d86b7a..155969a 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,18 +182,91 @@ 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 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 // 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); + // --- 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 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 + // on this card's slot, and the hash of the bytes read under them — the + // 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 + // 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 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) 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 + // 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, + 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..884e93e --- /dev/null +++ b/app/history/InferredLoudness.h @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE. +// SPDX-License-Identifier: AGPL-3.0-or-later + +#pragma once + +#include "HistoryStore.h" + +#include + +#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. +// +// 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 +// 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 +}; + +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) +{ + Inference out; + for (const SlotSighting& sighted : sightings) { + 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; +} + +} // namespace loopercat::history diff --git a/app/history/Schema.h b/app/history/Schema.h index b04a747..19ed506 100644 --- a/app/history/Schema.h +++ b/app/history/Schema.h @@ -53,8 +53,31 @@ // 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 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. +// 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 @@ -78,7 +101,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 +239,38 @@ 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, 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), + name TEXT NOT NULL, + 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; +-- 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", }; static_assert(sizeof(kSteps) / sizeof(kSteps[0]) == kVersion - kBaseVersion + 1, diff --git a/app/history/SlotLoudness.h b/app/history/SlotLoudness.h index 6bb03b6..2086d50 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,14 @@ // 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 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 // refuses the row is reported in `failure` with the reading still returned — @@ -44,13 +55,22 @@ 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 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); + 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 && 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/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; diff --git a/tests/history_recorder_tests.cpp b/tests/history_recorder_tests.cpp index 5935bf1..07a33d8 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..d41c1f4 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'"), 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); @@ -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,72 @@ 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); + // 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 + // ...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_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') + "', NULL, 1)"), + "CHECK"); + CHECK_THROWS(db.exec("INSERT INTO take_sightings VALUES (9, 4, 'x', 1, 1, x'" + + 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') + "', 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 + // 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); + } { TempDir freshDir, migratedDir; { diff --git a/tests/history_store_tests.cpp b/tests/history_store_tests.cpp index 42b9507..3615def 100644 --- a/tests/history_store_tests.cpp +++ b/tests/history_store_tests.cpp @@ -21,12 +21,31 @@ // 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; 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 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" #include "../app/history/HistoryStore.h" #include "../app/history/Schema.h" +#include #include #include #include @@ -769,5 +788,356 @@ 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, ""); + 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 + // 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); + // 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 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); + 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, + // 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 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, 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, + // which came after no operation of its own + CHECK(r.store.hashOfSighting(card, 21, Sighting { "021_1.WAV", 3000, kStamp + 2000, 132300 }) == hashC); + 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); + // 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, 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, + // 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, 12400); + 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) --- + { + // 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(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(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"); + 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 { + 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 <= 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"); + CHECK(everything() == before); + 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"); } diff --git a/tests/inferred_loudness_tests.cpp b/tests/inferred_loudness_tests.cpp new file mode 100644 index 0000000..b164420 --- /dev/null +++ b/tests/inferred_loudness_tests.cpp @@ -0,0 +1,716 @@ +// 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 — 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 — 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 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 +// (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" + +#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 +#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) }).found; + 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) }).found.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) }).found; + 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 }).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 }).found.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) }).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 }).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 }).found.empty()); + asBefore.slot = 5; + 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()); + } + + // --- 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 }).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 }).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 }).found.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 }).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, [&] { + 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 }).found.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 }).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 + // 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; + // 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, + { .targetLufs = guessed, + .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 + 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)); + } + + // --- 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)); + // 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"), store.newestOp(), 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); + } + + // 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 + } + + // --- 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"; + 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"); +} diff --git a/tests/player_pane_harness.cpp b/tests/player_pane_harness.cpp index c76c909..b11648b 100644 --- a/tests/player_pane_harness.cpp +++ b/tests/player_pane_harness.cpp @@ -16,12 +16,17 @@ // - 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; a file the meter cannot +// measure is said to the owner through onLoudnessFailed, and no reading +// comes up for it #include "support.hpp" #include "../app/CardPermissions.h" #include "../app/PlayerPane.h" #include "../app/history/ContentHash.h" +#include "../app/history/FileTime.h" #include @@ -127,11 +132,16 @@ 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; + int failures = 0; + 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, ""); }; + pane.onLoudnessFailed = [&](int) { ++failures; }; const auto load = [&] { readCame = false; @@ -143,6 +153,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,9 +242,40 @@ 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); + } + 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"); } 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..b5ca699 100644 --- a/tests/slot_table_harness.cpp +++ b/tests/slot_table_harness.cpp @@ -14,6 +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, 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" @@ -86,7 +92,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); @@ -165,5 +171,79 @@ 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, 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->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); + // 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); + // 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"); } diff --git a/tests/take_facts_tests.cpp b/tests/take_facts_tests.cpp new file mode 100644 index 0000000..8493ec9 --- /dev/null +++ b/tests/take_facts_tests.cpp @@ -0,0 +1,192 @@ +// 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 +// - 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 — +// 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", and its getter answers zero for a file +// it cannot stat. +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) })); + } + // 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); + } + + // --- 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 + 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 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); + 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)); + 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(); + 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"); +}