Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
41 changes: 41 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,25 @@ target_link_libraries(reader_loudness_tests PRIVATE
juce::juce_recommended_warning_flags)
add_test(NAME reader_loudness_tests COMMAND reader_loudness_tests)

# A take's directory entry as the connect scan reads it (#141): name, size
# and the platform's own stamp off the file system, no facts for an entry it
# will not describe — and the real scan carrying them slot by slot without
# reading audio. JUCE for the stamp and the worker's thread; no window.
juce_add_console_app(take_facts_tests PRODUCT_NAME "take_facts_tests")
target_sources(take_facts_tests PRIVATE tests/take_facts_tests.cpp)
target_compile_definitions(take_facts_tests PRIVATE
JUCE_WEB_BROWSER=0
JUCE_USE_CURL=0
LOOPERCAT_GOLDEN_JSON="${CMAKE_CURRENT_SOURCE_DIR}/fixtures/golden.json")
target_link_libraries(take_facts_tests PRIVATE
loopercat::core
nlohmann_json::nlohmann_json
juce::juce_core
juce::juce_events
juce::juce_recommended_config_flags
juce::juce_recommended_warning_flags)
add_test(NAME take_facts_tests COMMAND take_facts_tests)

# ----------------------------------------------------------------------------
# The history store (issue #72): theory tests against real SQLite files — the
# storage properties read back, atomicity of a take and its row, dedup,
Expand Down Expand Up @@ -779,6 +798,28 @@ target_link_libraries(history_recorder_tests PRIVATE
juce::juce_recommended_warning_flags)
add_test(NAME history_recorder_tests COMMAND history_recorder_tests)

# What the history says about a take it meets again without reading it (#141):
# real commands recorded on a synthetic pedal, readings filed as Check
# loudness files them, the takes sighted by their directory entries — and
# asked of the store, which must write nothing for answering.
juce_add_console_app(inferred_loudness_tests PRODUCT_NAME "inferred_loudness_tests")
target_sources(inferred_loudness_tests PRIVATE tests/inferred_loudness_tests.cpp
app/history/HistoryRecorder.cpp
app/history/HistoryStore.cpp)
target_compile_definitions(inferred_loudness_tests PRIVATE
JUCE_WEB_BROWSER=0
JUCE_USE_CURL=0
LOOPERCAT_GOLDEN_JSON="${CMAKE_CURRENT_SOURCE_DIR}/fixtures/golden.json")
target_link_libraries(inferred_loudness_tests PRIVATE
loopercat::core
loopercat_sqlite3
nlohmann_json::nlohmann_json
juce::juce_core
juce::juce_cryptography
juce::juce_recommended_config_flags
juce::juce_recommended_warning_flags)
add_test(NAME inferred_loudness_tests COMMAND inferred_loudness_tests)

# First sighting, interruption and foreground writes on synthetic cards.
juce_add_console_app(first_seen_tests PRODUCT_NAME "first_seen_tests")
target_sources(first_seen_tests PRIVATE tests/first_seen_tests.cpp
Expand Down
220 changes: 207 additions & 13 deletions app/MainComponent.cpp

Large diffs are not rendered by default.

21 changes: 20 additions & 1 deletion app/MainComponent.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

#include <loopercat/CardMarker.hpp>

#include <map>
#include <set>
#include <loopercat/Connect.hpp>
#include <loopercat/StorageRegister.hpp>
Expand All @@ -30,6 +31,7 @@
#include "history/SlotRows.h"
#include "history/TakeAudition.h"
#include "history/HistoryRecorder.h"
#include "history/InferredLoudness.h"
#include "history/FirstSeenJob.h"
#include "history/UndoRun.h"
#include "history/CardRestore.h"
Expand Down Expand Up @@ -305,8 +307,15 @@ class MainComponent final : public juce::Component,
void enqueueLoudnessRead(int slot, double target, int batch);
// A reading the player's own pass took, into the history under the hash
// of the bytes it metered (#140) — on the worker, the store's thread.
void keepReading(std::string hash, wav::LoudnessReading reading);
void keepReading(std::string hash, wav::LoudnessReading reading, int slot,
std::optional<TakeFacts> seen, std::optional<std::int64_t> readBegan);
void applyLoudnessReport(int slot, const LoudnessReport& report, int batch);
// The column from what the history already knows (#141): the slots with
// a take and an empty cell, put to the store by the facts the scan read
// off their directory entries — no audio read, no card needed. The
// player's slot is read instead; a read that failed is remembered.
void inferLoudnessFromHistory();
void readFailed(int slot);
void measureSlotLoudness(int slot);
void startLoudnessCheck(const std::vector<int>& slots);
void stopLoudnessCheck();
Expand Down Expand Up @@ -358,6 +367,16 @@ class MainComponent final : public juce::Component,
int checkTotal = 0, checkDone = 0, checkFailed = 0, checkAttention = 0, checkDamaged = 0;
bool checkStopping = false;
std::vector<int> checkSlots; // to un-pend the cells of a dropped tail
// What the LUFS column from the history (#141) remembers for one
// connection: the files a real read failed on, by slot (message thread;
// readFailed), and the slots whose record could not be read and were
// logged already (touched by the worker's jobs only; a new set per card).
std::map<int, TakeFacts> unreadableTakes;
// Where the player's current read pass began among the history's
// operations (HistoryRecorder::newestOp), noted when it is started on
// the card's take; absent for a take played back out of the history.
std::optional<std::int64_t> playerReadBegan;
std::shared_ptr<std::set<int>> inferenceProblemsLogged = std::make_shared<std::set<int>>();
PlayerPane player { engine };
juce::String deviceError;
int selectedSlot = 0; // what the Properties tab is showing (0 = nothing)
Expand Down
15 changes: 14 additions & 1 deletion app/PedalWorker.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

#pragma once

#include "TakeFacts.h"

#include <loopercat/Catalog.hpp>
#include <loopercat/Commands.hpp>
#include <loopercat/DeviceProfile.hpp>
Expand Down Expand Up @@ -47,6 +49,11 @@ struct SlotRow
std::string wavPath; // absolute path of track 1's first wav — what playback opens
std::vector<std::string> trackPaths; // one per track the model has, in track order:
// the track's first wav, or empty for no take
// What wavPath's directory entry says for free (#141): name, size, stamp
// — the take the LUFS column is about, as the history can recognise it
// without reading it. Absent for an empty slot, and for an entry the
// file system would not describe whole (TakeFacts.h).
std::optional<TakeFacts> take;

bool operator==(const SlotRow&) const = default;
};
Expand Down Expand Up @@ -247,8 +254,14 @@ class PedalWorker final : private juce::Thread
firstPath = trackPath;
trackPaths.push_back(std::move(trackPath));
}
// The first take's directory entry, read while the scan is
// already in its folder: no audio, and a take the entry will
// not describe simply carries no facts.
std::optional<TakeFacts> take;
if (!firstPath.empty())
take = takeFacts(volume::fs::path(firstPath));
snapshot.slots.push_back({ std::move(info), std::move(files), std::move(firstPath),
std::move(trackPaths) });
std::move(trackPaths), std::move(take) });
}
} catch (const Error& e) {
snapshot.error = e.what();
Expand Down
58 changes: 39 additions & 19 deletions app/PlayerPane.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -332,26 +332,38 @@ void PlayerPane::ReadPass::start(std::vector<juce::File> files, int slot)
startThread();
}

namespace {
std::filesystem::path pathOf(const juce::File& file)
{
const juce::String full = file.getFullPathName();
const char* utf8 = full.toRawUTF8();
return std::filesystem::path(std::u8string(reinterpret_cast<const char8_t*>(utf8)));
}
} // namespace

// The key a metered take is filed under (#140), or nothing. Nothing when the
// core would not measure these bytes (JUCE decodes files the core refuses,
// and pads a truncated one to its header's length — its number is then not
// the core's), when JUCE did not decode the frames the core would measure
// (a RIFF size field that ends early leaves it a reader of 0 frames over a
// file the core reads whole), when the file changed under the pass (size or
// stamp differ from before the decode: the number and the hash would be of
// two files), and when the hash read stopped short. The hash is streamed, a
// second pass over the file; a take of any length stays out of memory.
// file the core reads whole), when the file changed under the pass (its one
// stat — size and stamp — differs from before the decode, or there was none:
// the number and the hash would be of two files), and when the hash read
// stopped short. The hash is streamed, a second pass over the file; a take
// of any length stays out of memory.
std::string PlayerPane::ReadPass::fileKey(const juce::File& file,
const juce::AudioFormatReader& reader,
juce::int64 sizeBefore, juce::Time modifiedBefore)
const std::optional<history::FileStat>& before)
{
if (!before)
return {};
const auto info = readerloudness::measurableInfo(file);
if (!info || !readerloudness::decodesAsCore(reader, *info))
return {};
const auto hash = history::fileContentHash(file, [this] { return threadShouldExit(); });
if (!hash)
return {};
if (file.getSize() != sizeBefore || file.getLastModificationTime() != modifiedBefore)
if (history::statFile(pathOf(file)) != before)
return {};
return *hash;
}
Expand All @@ -361,32 +373,34 @@ void PlayerPane::ReadPass::run()
const int slot = slot_;
juce::Component::SafePointer<PlayerPane> owner(&owner_);
const auto finish = [owner, slot](std::optional<wav::LoudnessReading> reading,
std::string hash) {
juce::MessageManager::callAsync([owner, slot, reading, key = std::move(hash)] {
std::string hash, std::optional<TakeFacts> seen) {
juce::MessageManager::callAsync([owner, slot, reading, key = std::move(hash),
facts = std::move(seen)] {
if (owner != nullptr)
owner->passFinished(slot, reading, key);
owner->passFinished(slot, reading, key, facts);
});
};
// The meter measures one loop; a multi-track memory draws its lanes and
// 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)
std::optional<TakeFacts> seen; // the file the hash is of, as its stat read it (#141)
for (std::size_t lane = 0; lane < files_.size() && lane < 2; ++lane) {
juce::AudioThumbnail& thumbnail = owner_.thumbnailFor(static_cast<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();
// What the file was before the decode began, from one stat: a
// replace under the pass shows as a changed size or stamp afterwards,
// and then nothing is filed.
const std::optional<history::FileStat> before = history::statFile(pathOf(files_[lane]));
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, {}, std::nullopt);
return;
}
const int channels = static_cast<int>(reader->numChannels);
Expand Down Expand Up @@ -426,21 +440,25 @@ void PlayerPane::ReadPass::run()
return; // stopped for a newer file: no verdict, and no message
if (measure) {
if (position < total || !metered) {
finish(std::nullopt, {}); // a short read is not a reading
finish(std::nullopt, {}, std::nullopt); // a short read is not a reading
return;
}
reading = meter->reading();
hash = fileKey(files_[lane], *reader, sizeBefore, modifiedBefore);
hash = fileKey(files_[lane], *reader, before);
if (threadShouldExit())
return; // the hash read was cut short for a newer file
if (!hash.empty()) // fileKey proved `before` is the file the hash is of; the
// name spelled as the scan spells it (TakeFacts.h)
seen = TakeFacts { pathOf(files_[lane]).filename().string(), before->size,
before->modifiedMs };
}
}
if (measure)
finish(reading, hash);
finish(reading, hash, seen);
}

void PlayerPane::passFinished(int slot, std::optional<wav::LoudnessReading> reading,
std::string contentHash)
std::string contentHash, std::optional<TakeFacts> seen)
{
if (slot != slot_)
return; // the pass for a slot no longer loaded
Expand All @@ -449,10 +467,12 @@ void PlayerPane::passFinished(int slot, std::optional<wav::LoudnessReading> read
"Could not measure this file.");
loudnessPending_ = false;
updateLoudnessButtons();
if (onLoudnessFailed)
onLoudnessFailed(slot);
return;
}
if (onLoudnessRead)
onLoudnessRead(slot, *reading, contentHash); // the owner answers with setLoudness
onLoudnessRead(slot, *reading, contentHash, seen); // the owner answers with setLoudness
}

// The readout sits right after the name and takes what is left of the row
Expand Down
19 changes: 16 additions & 3 deletions app/PlayerPane.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

#include "AudioEngine.h"
#include "CardPermissions.h"
#include "TakeFacts.h"

#include <loopercat/Normalize.hpp>

Expand Down Expand Up @@ -107,12 +108,24 @@ class PlayerPane final : public juce::Component,
// too. The owner turns it into words (target, damage) and feeds them
// back through setLoudness; a read-only check running elsewhere lands
// the same way.
// With the hash go the file's name, size and stamp as one stat read
// them before the decode and again after the hash (#141): what the read
// saw, for the history to know the file by its bytes next time. Present
// exactly when the hash is.
// A pass that cannot measure the file says so on the row and tells the
// owner through onLoudnessFailed: a number the column showed for the
// slot without reading it must not outlive a read that failed.
// The readout and Normalize… share the row with Trim and step aside
// while a trim selection is active: the selection owns the row then,
// and Normalize is whole-loop work that must not read as "the
// selection". All setters ignore a slot that is not the loaded one.
std::function<void(int, const wav::LoudnessReading&, const std::string& contentHash)>
std::function<void(int, const wav::LoudnessReading&, const std::string& contentHash,
const std::optional<TakeFacts>& seen)>
onLoudnessRead;
std::function<void(int)> onLoudnessFailed; // the loaded slot's file could not be measured
// A reading for the loaded slot is on its way: the pane's own pass, or a
// check the owner announced with setLoudnessPending.
bool loudnessPending() const { return loudnessPending_; }
void setLoudness(int slot, const juce::String& text, bool attention, bool damaged,
const juce::String& tooltip);
void setLoudnessPending(int slot);
Expand Down Expand Up @@ -156,7 +169,7 @@ class PlayerPane final : public juce::Component,
void updateLoudnessButtons();
void layoutReadout();
void passFinished(int slot, std::optional<wav::LoudnessReading> reading,
std::string contentHash); // message thread
std::string contentHash, std::optional<TakeFacts> seen); // message thread

// The one read pass over a loaded file: waveform blocks into the
// thumbnail, samples into the meter, then the reading up to the pane. A
Expand All @@ -183,7 +196,7 @@ class PlayerPane final : public juce::Component,

private:
std::string fileKey(const juce::File& file, const juce::AudioFormatReader& reader,
juce::int64 sizeBefore, juce::Time modifiedBefore);
const std::optional<history::FileStat>& before);

PlayerPane& owner_;
std::vector<juce::File> files_;
Expand Down
45 changes: 44 additions & 1 deletion app/SlotTable.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ int SlotTable::slotOfRow(int rowIndex) const
: 0;
}

SlotTable::LoudnessCell SlotTable::LoudnessCell::fromHistory(LoudnessCell measured,
juce::Time measuredAt)
{
// The provenance leads: a player reading the hint learns first that
// this number was not read off the card today, then the one gesture
// that reads it on every card and every slot — the double-click on this
// very cell — then what the number says.
measured.tooltip = "From the history: measured " + measuredAt.formatted("%d %b %Y %H:%M")
+ ", not re-measured. Double-click to measure it now."
+ (measured.tooltip.isEmpty() ? juce::String() : " " + measured.tooltip);
// No attention: "Normalize would change this" is a verdict, and a guess
// passes none. The number alone, in light ink.
measured.attention = false;
measured.inferred = true;
return measured;
}

void SlotTable::setLoudness(int slot, LoudnessCell cell)
{
loudness_[slot] = std::move(cell);
Expand Down Expand Up @@ -138,6 +155,11 @@ void SlotTable::setOptionalColumns(bool oneShot, bool countIn, bool loudness)
header.setColumnVisible(kLufs, loudness);
}

bool SlotTable::loudnessColumnVisible() const
{
return table_.getHeader().isColumnVisible(kLufs);
}

void SlotTable::selectSlot(int slot)
{
const int row = rowOfSlot(slot);
Expand Down Expand Up @@ -247,6 +269,21 @@ void SlotTable::selectedRowsChanged(int lastRowSelected)
onSlotSelected(slotOfRow(lastRowSelected));
}

// The LUFS cell carries its reading's explanation as a hover hint — where
// the number came from first of all, when it came out of the history rather
// than a read (#141). A read in flight explains nothing yet, and a row whose
// cell is not drawn (no loop in the slot) has nothing to explain.
juce::String SlotTable::getCellTooltip(int row, int columnId)
{
const int slot = slotOfRow(row);
if (columnId != kLufs || slot <= 0 || !rows_[static_cast<std::size_t>(row)].info.hasAudio)
return {};
const auto found = loudness_.find(slot);
if (found == loudness_.end() || found->second.pending)
return {};
return found->second.tooltip;
}

void SlotTable::cellDoubleClicked(int row, int columnId, const juce::MouseEvent&)
{
// Double-click = edit in place, where the card may be written to; on a
Expand Down Expand Up @@ -573,7 +610,13 @@ void SlotTable::paintCell(juce::Graphics& g, int row, int columnId, int width, i
}
return;
}
g.setColour(!known ? kDim.withAlpha(0.55f)
// A number out of the history rather than a read (#141) wears the
// text ink, lighter: the column's own vocabulary for "less present"
// (the dash, the empty rows) rather than a mark, which in a 68 px
// number column would read as part of the number. It never wears
// the attention colour (LoudnessCell::fromHistory). The hint says why.
g.setColour(!known ? kDim.withAlpha(0.55f)
: found->second.inferred ? kText.withAlpha(0.6f)
: found->second.attention ? felitronics::appkit::brand::orange
: kText);
g.drawText(known ? found->second.text
Expand Down
Loading
Loading