Skip to content

Fill the LUFS column on connect from what the history already knows - #159

Closed
AliceLafox wants to merge 15 commits into
feat/loudness-in-historyfrom
feat/lufs-from-history
Closed

AliceLafox wants to merge 15 commits into
feat/loudness-in-historyfrom
feat/lufs-from-history

Conversation

@AliceLafox

Copy link
Copy Markdown
Member

Closes #141. Stacked on #157 (feat/loudness-in-history, schema v9); the base moves on as the stack below it is merged. Parallel to #158 (#142) on the same base; the two meet in app/history/SlotLoudness.h and the normalize hook in HistoryRecorder.cpp and are merged by hand when both land.

The LUFS column started empty on every connect. A connect scan reads no audio, and proving identity by hash would mean reading every take. So the free facts decide: the take's name, size and modification time from its directory entry, plus WavLen from the config.

  • The scan reads each take's size and stamp with one stat (app/TakeFacts.h, statFile); no audio is read.
  • The match (HistoryStore::hashOfSighting, app/history/InferredLoudness.h): the slot's newest after-row for that file name, from a finished operation, with an exactly equal size and stamp (a migrated row without a stamp never matches) and the same WavLen, with nothing recorded about the slot after it — or a real read of these exact facts that no later operation on the slot has voided. The reading under that hash (Loudness readings belong to the bytes: keep them in the history, keyed by content hash #140) is shown.
  • Every real read records what it saw (schema v10 take_sightings, placed among operations by sequence, not by clock): the check job, the player's filed pass and a normalize's measurement. So a take re-recorded on the pedal with the same name, size and stamp (a pedal clock that does not tick) is corrected by the first real read and stays corrected across later operations and connects. A resumed first sighting voids the slot's earlier reads; operation sequence numbers are never reused (op_sequence).
  • The rule that keeps it honest: an inferred number informs the eye and decides nothing. It is drawn in lighter ink with no attention colour; its hint says it came from the history and was not re-measured ("Double-click to measure it now"); the slot loaded in the player is never inferred (a real read is queued instead); Normalize, the background check and the batch apply never read it.
  • Inference is a quiet background job (needsVolume = false), skipped while the LUFS column is hidden; one unreadable recorded body skips its slot only.

Tests: take_facts_tests (new), inferred_loudness_tests (new: the frozen-clock re-record, migrated rows, swaps of pushed and pedal-named takes, a failed write, a resumed baseline, a late-filed read, a clock set back, a normalize at target), history_store_tests (sighting order by sequence, the done rule, forget), history_schema_tests (v9→v10). Full ctest 67/67 locally, the six GUI harnesses included.

Known and accepted: FAT stamps are local time, so after a DST change the exact match fails and cells stay a dash (the safe direction); a hardware spot-check of whether the RC-5's clock ticks would settle how often the first case occurs.

…connect scan

The connect scan reads the two memory files and lists directories, and
nothing it carried could tell one file in a slot from another without
reading it (#141). SlotRow now carries TakeFacts for track 1's first take:
its name, its size and the platform's own modification time, read the way
the history stores it (FileTime.h), so the two compare as equal integers.
No audio is read for them.

An entry the file system will not describe whole - a folder under a take's
name, a name with nothing behind it, a link to nothing, a stamp that reads
as the epoch, which is JUCE's answer for a file it cannot stat - carries no
facts: the slot keeps its name on screen and nothing is inferred about it,
rather than the scan failing on one take. The facts are part of the
snapshot, so a rewritten take is a changed snapshot.

take_facts_tests runs the real scan over a scratch volume; the two
harnesses that build rows by hand name the new field.
… what it measured

hashOfSighting answers with the hash of the take a card's slot holds when
the slot's newest row for that file on track 1 carries a hash, its size
and its stamp are the entry's to the millisecond, and the body recorded
with it says the same WavLen - and only while nothing else about the slot
was recorded after that row. A clear, a swap or a failed write after it
makes the row history: the pedal stamps what it records with a clock that
does not keep the date, so a loop of the same length recorded again in the
slot can carry the very same name, size and stamp. A row without a stamp,
older than version 9, gets no allowance here as it does in hashHeldBefore:
nothing is at stake but a number on screen, and it must not be a guess.
cardFor finds a card by its marker without touching last_seen.

inferLoudness (InferredLoudness.h) joins that to the readings of #140: a
slot the history cannot vouch for, or whose bytes it never measured, is not
in the answer, and each answer names the sighting it answers. Neither
writes anything.

The store suite takes each fact off by one, the newest row, the rows after
it that end its word and the ones that do not (an operation only about the
slot, another slot's write), track 2, another card, and checks that asking
wrote nothing. inferred_loudness_tests records real commands through
withHistory: a rename keeps the reading, a swap carries a take under its
own name and loses one under the pedal's name, a trim and a clear end it.
And a different take under the very same four facts fools it - while the
store knows nothing of the bytes really there, and normalize, which reads
them, raises the loop the guess says is already at target (#142).
…came from in its hint

A LoudnessCell now says whether its number was read off the card or came
out of the history for the bytes the slot appears to hold (#141).
fromHistory wears a measured cell's words and verdict with the provenance
first in the hint: when it was measured, and that it was not re-measured
now. Any reading set over it replaces it; a mutation's clearing takes it
away like any cell; a dropped read clears only a pending cell, never this.

It draws in the same ink at 60%: the column's own way of saying "less
present", as the dash and the empty rows do, rather than a mark that in a
68 px number column would read as part of the number. The LUFS cell now
shows its reading's hint on hover; a read in flight and a slot with no
loop have none.

slot_table_harness checks the flag, the hint, the replacement and the
clearing; harnesses need a window server and are not run here.
The column started empty on every connect and filled only where something
read a take (#141). Now, when a snapshot lands and when a job's busy state
drops - the job that reads the card's name included - every slot with a
take and an empty cell is put to the store by its scan facts and WavLen, in
a quiet background job that needs no card. A reading the history has for
those bytes lands as an inferred cell, and only for a slot that still shows
the very facts sighted, on the card its marker named, into a cell still
empty: a read that came first, or a mutation's clearing, outranks it.

It holds off while a foreground job runs, as restoreListening does: the
job's rescan lands before its result clears the cells it moved, so the busy
drop is the hook after a job and the snapshot hook covers mounts and idle
rescans. The player row leaves an inferred cell alone, since its own pass
reads the loaded bytes and replaces it.

An inferred cell informs the eye and decides nothing: no write path reads
the column, and normalize measures the card's bytes before it writes.
The connect scan asked three stats of every take on every 1.5 s poll over
a USB mount: is it a file, how big, and when written - each free to see a
different file (review of #141). history::statFile asks once and answers
both or nothing, and modifiedMs is that same answer's time, so the scan,
the history rows and the player's pass all read one number.

The number is the one JUCE's getLastModificationTime gives on each
platform - the milliseconds on macOS, whole seconds on Linux, the
FILETIME's on Windows - so stamps stored before compare equal. A direct
stat also tells a stamp of zero from no answer at all, which JUCE could
not: an epoch stamp is now described as it is, and only an entry that
gives no answer has no facts.
… and size a guess

Three ways the history vouched for a file it had not seen (review of
#141):

- A migrated store's rows have no stamp and vouch by name and size, so
  that a 0.9.5 history stays restorable. recordWhatSlotsHold then wrote
  that hash beside the file's fresh stamp, and the next connect trusted
  the row as naming the very file: a take overwritten outside the app
  with one of the same size, then renamed, showed the old loop's number
  (S1). heldBefore now hands such a hash on with no stamp - restorable
  through the allowance, never inferred - and the stamp is the file's in
  every other case. The comment that said the staleness stays confined
  said what this makes true.

- The pedal's clock does not keep the date, and in the app's own layout
  the size follows the length, so a loop of the same length recorded
  again keeps name, size, stamp and WavLen. Check loudness measured the
  new bytes and the next connect showed the old reading anyway (S3).
  Store version 10 adds take_sightings: what a real read saw, the file's
  facts with the hash of the bytes read under them, by card and slot.
  readSlotLoudness files one when the file stood still under the read,
  and the player's pass hands its facts up with its hash (one stat before
  the decode, one after) for keepReading to file. hashOfSighting answers
  a read of these facts made after the slot's last operation first, and
  falls back to the row; forgetting a slot takes its sightings along.

- A write that failed after its take landed left a row that still
  answered. Only a finished operation's row does now, and a first
  sighting's, which commits slot by slot.

And one unreadable recorded body no longer fails the whole job (and a
banner on every snapshot): inferLoudness reports that slot and asks the
rest, and the app logs it once a connection.
An inferred cell kept the reading's attention colour, which says
"Normalize would change this" - a verdict, painted on a number nobody
read today (review of #141). It now carries no attention: the number
alone, in light ink.

Its hint promised "Selecting the slot or Check loudness reads it again",
and twice that was not true: the slot already in the player is not read
again on selection, and a two-track memory on a card this app only reads
has no Check loudness. It now names the one gesture that measures on
every card and every slot: "Double-click to measure it now."

The table also says whether its LUFS column is shown, for the app to
leave a hidden column alone.
…ead's slot a dash

The slot loaded in the player was refilled as inferred after an Undo,
Redo, Swap or Restore on another slot cleared every cell, and clicking
its row did nothing: the player does not read again a file it already
has (review of #141). The inference now never answers for the player's
slot. It is the one slot the app can measure at once, so when nothing is
reading it, the existing background read does.

A guess no longer outlives a read that could not confirm it. A player
pass that cannot measure the file tells the owner, and the inferred cell
goes; that file, and the file of a failed Check, is not asked of the
history again in this connection. A normalize meters the slot's bytes on
its way, the bulk run included: the recorder hands the reading on, and
it replaces the cell as a measurement - a write that follows clears it,
as every rewrite does.

Nothing is inferred while the LUFS column is hidden; showing it asks
the history then.
The column takes a normalize's measurement as a measured cell through
HistoryRecorder::onMeasured; the fooled-take case now checks the hook is
called once, with the slot and the very number normalize decided by.
…its hash on

Two holes left in how the history believed a read (re-review of #141):

- An operation after a read brought the wrong number back. heldBefore
  read only slot_audio: after Check loudness had read a frozen-clock
  re-recording, a rename carried the old row's hash under the very same
  facts into a row newer than the read, and the next connect trusted it
  (S3d). heldBefore now takes the newest word for the file: a read of
  these facts that no earlier operation on the slot came after hands on
  the hash of the bytes it read, with the file's stamp.

- Reads were ordered against operations by wall clock. A first sighting
  resumed on a later connect keeps the time it began (S7), a player read
  is filed by a background job a foreground operation can jump ahead of
  (S8), and a clock can be set back (S10). A sighting now keeps since_op:
  the slot's newest operation at or below the newest one begun when the
  read started. It is history once an operation with a higher sequence
  records a body or a take in the slot; a subject alone does not count,
  nor another slot's operation. The check job notes where its read began
  at its start, the player when it starts a pass on the card's take
  (HistoryRecorder::newestOp, published as operations begin), and the
  late filing carries it. A first sighting photographing a slot removes
  that slot's earlier reads: its sequence is old, its photograph is not.

take_sightings gains since_op in version 10, which has not shipped.
… out of it

Three small ones from the re-review of #141. A slot remembered as
unreadable stayed so for the connection even after a read of it
succeeded: any successful read of the slot now lifts the mark. A Check
the lifecycle gate turned away - the pedal leaving, a ghost mount - never
reached the file, yet marked it unreadable: the check job now marks only
a failure that came after the gate let it through to the card. And the
player's slot was read in the background even when it held a multi-track
memory playing as a mix, which the player never measures: track 1's
number is not the mix's, and nobody asked for the banner a failure
raises. A mix is now any other slot to the column.
…ects the column

A normalize measures the slot's bytes before it decides, and its hook
filed the reading and told the column - but not what it had read. One
that found the slot at target writes only a subject, so the next connect
still trusted the row that named the file under the same frozen-clock
facts and showed the old take's number (round 3 review of #141, S12:
measured -27.997, nothing applied, next connect -19.997).

The hook now files a sighting too: the take's facts from one stat, under
the hash of the bytes measured, placed just before the operation it is
part of. A normalize that then writes records the archived and the
landed take with its own, higher sequence, which makes its read history;
one that changes nothing leaves the read standing for the next connect.
beginOp took max(seq) + 1, so forgetting a slot's history that dropped
the newest operations whole handed their numbers out again, and a read
placed after "operation N" could then stand after an operation that
began later (round 3 review of #141, S11b). Not reachable through the
app today - the forget's own maintenance operation holds the maximum -
but the order of reads must not rest on that.

Version 10, which has not shipped, adds op_sequence: one row, the
highest sequence ever issued, started from what the store holds and
moved by a trigger on every insert into ops. beginOp issues the next
value past it (and past the Undo floor, as before), and newestOp reads
it, so neither goes back when operations are forgotten - across a
reopen too.
…n words

take_facts_tests aborted on the Linux lanes: the connect scan's doctor pass
reads takes through readFileBytes, and a folder under a take's name opens as
a stream on Linux, after which libstdc++ throws std::ios_base::failure from
the first read ('Is a directory'). Nothing expects that exception; on a card
it would have ended the worker's scan. macOS's library fails the stream
quietly, so the suite passed there. readFileBytes now refuses a path that is
not a regular file with an Error before a stream exists.
AliceLafox added a commit that referenced this pull request Oct 17, 2026
0.9.6: merge #150-#159 in stack order, with the integration fixes
@AliceLafox

Copy link
Copy Markdown
Member Author

Landed in main through #160 (merge commit d7cccff), which carries this PR's own merge commit; GitHub refuses to retarget a head that main already contains, so closing by hand.

@AliceLafox AliceLafox closed this Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant