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