Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 43 additions & 11 deletions app/MainComponent.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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<std::string> 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<const unsigned char*>(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)
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions app/MainComponent.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>& slots);
Expand Down
72 changes: 50 additions & 22 deletions app/PlayerPane.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

#include "PlayerPane.h"

#include "ReaderLoudness.h"
#include "history/FileHash.h"

#include <loopercat/Params.hpp>
#include <loopercat/Wav.hpp>

Expand All @@ -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
Expand Down Expand Up @@ -329,31 +332,61 @@ void PlayerPane::ReadPass::start(std::vector<juce::File> 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<PlayerPane> owner(&owner_);
const auto finish = [owner, slot](std::optional<wav::LoudnessReading> reading) {
juce::MessageManager::callAsync([owner, slot, reading] {
const auto finish = [owner, slot](std::optional<wav::LoudnessReading> 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<wav::LoudnessReading> 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<int>(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<juce::AudioFormatReader> 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<int>(reader->numChannels);
Expand All @@ -364,33 +397,25 @@ 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<loudness::Meter> meter;
std::optional<readerloudness::ReaderMeter> meter;
if (measure) {
try {
meter.emplace(static_cast<int>(reader->sampleRate));
meter.emplace(reader->sampleRate);
} catch (const Error&) {
}
}
bool metered = meter.has_value();

juce::AudioBuffer<float> buffer(channels, kReadBlock);
std::vector<float> interleaved(2 * static_cast<std::size_t>(kReadBlock));
juce::int64 position = 0;
while (position < total && !threadShouldExit()) {
const int n = static_cast<int>(std::min<juce::int64>(kReadBlock, total - position));
if (!reader->read(&buffer, 0, n, position, true, true))
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<std::size_t>(i)] = left[i];
interleaved[2 * static_cast<std::size_t>(i) + 1] = right[i];
}
try {
meter->process(interleaved.data(), static_cast<std::size_t>(n));
meter->feed(buffer, n);
} catch (const Error&) {
metered = false; // past the meter's capacity: the waveform still draws
}
Expand All @@ -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<wav::LoudnessReading> reading)
void PlayerPane::passFinished(int slot, std::optional<wav::LoudnessReading> reading,
std::string contentHash)
{
if (slot != slot_)
return; // the pass for a slot no longer loaded
Expand All @@ -424,7 +452,7 @@ void PlayerPane::passFinished(int slot, std::optional<wav::LoudnessReading> 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
Expand Down
31 changes: 25 additions & 6 deletions app/PlayerPane.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

#include <array>
#include <optional>
#include <string>
#include <vector>

//==============================================================================
Expand Down Expand Up @@ -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<void(int, const wav::LoudnessReading&)> onLoudnessRead;
std::function<void(int, const wav::LoudnessReading&, const std::string& contentHash)>
onLoudnessRead;
void setLoudness(int slot, const juce::String& text, bool attention, bool damaged,
const juce::String& tooltip);
void setLoudnessPending(int slot);
Expand Down Expand Up @@ -147,12 +155,20 @@ class PlayerPane final : public juce::Component,
void updateTransportRow();
void updateLoudnessButtons();
void layoutReadout();
void passFinished(int slot, std::optional<wav::LoudnessReading> reading); // message thread
void passFinished(int slot, std::optional<wav::LoudnessReading> 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:
Expand All @@ -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<juce::File> files_;
int slot_ = 0;
Expand Down
Loading
Loading