Skip to content

Normalize a slot: measure before asking, and ask only when there is something to do - #158

Closed
AliceLafox wants to merge 15 commits into
feat/loudness-in-historyfrom
feat/normalize-measure-first
Closed

AliceLafox wants to merge 15 commits into
feat/loudness-in-historyfrom
feat/normalize-measure-first

Conversation

@AliceLafox

Copy link
Copy Markdown
Member

Closes #142. Stacked on #157 (feat/loudness-in-history, the readings store); the base moves on as the stack below it is merged.

Normalize on a single slot opened a warning window, ran the job, and then said "already at -14.0 LUFS, file untouched" — and left a history row explaining why it wrote nothing.

  • A read-only first step (app/NormalizeStep.h, app/history/SlotLoudness.h): on the worker, as a background job — no history row, no archive, no journal. It reads and hashes the take, asks the history for a reading of exactly those bytes (Loudness readings belong to the bytes: keep them in the history, keyed by content hash #140), measures only when there is none or the stored one is implausible, and files the fresh one. The store is never opened just to ask.
  • The decision (app/NormalizePlan.h): a pure unit over the reading and the target that agrees with commands::normalize on every input (outcome and words; a 400-take random differential shows no disagreement). Nothing to do: a toast in the words the LUFS column already uses, and nothing else. A refusal (damaged or unmeasurable take): the banner, "Normalize slot N: …". Something to do: the window opens with the numbers — "Measured -22.7 LUFS, this adds +4.7 dB", or "+2.1 dB possible: the -1 dBTP ceiling stops the rest", titled "toward" the target when capped.
  • Write only what was measured: the job checks the take's hash before its history operation opens; a take replaced since the step is not written ("slot N changed since it was measured — Normalize again"). One step per slot; the menu item is greyed while the slot's reading is pending.
  • No inaudible writes: a gain smaller than the "already at target" step (0.2 dB) is written by nobody — the command, the plan and the column's verdict share loudness::kSmallestGainDb. Before, a take capped at the ceiling offered "+0.0 dB" and rewrote identical bytes on every click. This changes the command, so the bulk apply now counts such slots as untouched (the guide says so).
  • Targets that are not numbers are refused: NaN and ±inf in the command, in Settings, in normalizeGainDb (the one place every path levels audio); a stored target that is not usable is reported once and readings are shown without a verdict until it is set again.
  • The bulk apply is otherwise untouched; the authority stays with the job, which re-measures before writing.

Tests: normalize_plan_tests (tolerance and ceiling boundaries, the capped take re-run three times, NaN/±inf), normalize_step_tests (new: target parse, the step, the hash guard as the app builds the job, the double request, verdict-less readings), history_recorder_tests (recall vs measure, planted implausible rows, a failed ask), history_store_tests, commands_tests, loudness_tests. Full ctest 67/67 locally, the six GUI harnesses included.

… the job can speak its words

The single-slot Normalize is to decide before it asks (#142), and what it
decides must be what commands::normalize would do with the same take. The
target window, the damaged and the unmeasurable refusals, and the 1e-9 dB
under which a capped boost counts as none were written inline in the
command; they are now requireNormalizeTarget, normalizeDamagedRefusal,
normalizeUnmeasurableRefusal and loudness::kNoGainDb, and the command
calls them. Its behaviour and its sentences are unchanged.
…hing, or refuse in its words

normalizeplan::decide (app/NormalizePlan.h) is the command's rule over a
wav::LoudnessReading and a target, pure and JUCE-free: garbage first, then
silence, then the kAlreadyAtTargetLu tolerance, then the gain rule under
the -1 dBTP ceiling. "apply" carries the window's first line ("Measured
-22.7 LUFS, this adds +4.7 dB", or "+2.1 dB possible: the -1 dBTP ceiling
stops the rest" when capped), "nothingToDo" carries the cap as its reason,
"refuse" carries the command's own sentence.

normalize_plan_tests holds it to the tolerance from both sides, the cap,
the refusals and the target window, then runs plan and command side by
side over eight synthetic takes - at target, peaky, faint, short, damaged,
quiet, capped, loud - where every write, every "nothing" and every refusal
must match, sentence for sentence.
recallOrReadSlotLoudness, for Normalize's first step (#142), reads the
slot's take, hashes it and asks the store for a reading filed under that
hash (#140); only bytes it has never measured are measured, and that
reading is filed as readSlotLoudness files it. The key is the content
hash, so a reading inferred from a take's name, size and date (#141)
never answers here. With no session open for the card the store is not
opened for the asking; a store that cannot answer is measured around,
with the trouble in `failure`.

The recorder suite plants a reading no meter would take off the take and
gets it back, not dated anew; swaps another sound in under the same name,
size and date as the snapshot's row and gets it measured; and shows the
step leaves the card's timeline as it was for a take with nothing to do,
while the normalize that follows a take with something to do adds exactly
one row.
… is something to do

Normalize on one slot opened its window first and measured inside the
job, so a take already at the target was approved, left alone, and still
left a row in the history saying why (#142). The action now runs a
read-only background job first - no operation, no archive, no journal -
that recalls or measures the take and asks normalizeplan::decide.

Nothing to do: a toast in describeReading's words, and nothing else. A
take the command would refuse: the error banner with the command's
sentence, where the job's failure used to land - the fix for a damaged
take is a re-push, and its reason has to outlive the click. Something to
do: the window opens with the numbers in hand. The job still measures the
card's bytes before it writes, whatever the first step believed. The
right-click item and the player's Normalize button both come through
here; the bulk apply is untouched.
kNoGainDb was 1e-9 dB, far below anything a float32 sample carries. A take
Normalize had capped lands a few hundredths of a dB under the ceiling once
its samples are float32 again, so the next Normalize offered "+0.0 dB
possible", rewrote the same bytes and added a history row - every time; a
take with 0.049 dB of headroom opened the window too (review of #142, P0).

The smallest gain worth writing is now loudness::kSmallestGainDb, the same
audible step as kAlreadyAtTargetLu. commands::normalize and
normalizeplan::decide write nothing under it, and the LUFS column's
attention follows the same line, so the column, the plan and the command
never disagree. The cap test is exact: normalizeGainDb hands the wanted
gain back untouched when the ceiling does not bite. The bulk apply now
counts such slots as untouched; the guide says so.

normalize_plan_tests covers the 0.049 dB case and both sides of the step,
and runs the plan and the command three more times on the take the
command has just capped: nothing to do, nothing applied, nothing archived,
the bytes as they were.
…ings

requireNormalizeTarget and the Settings field both asked whether a target
was outside the window as two comparisons, and NaN fails every comparison:
a target of NaN passed both, the plan offered "this cuts nan dB" and the
command filled the take with NaN samples. JUCE reads "nan" and "inf" out
of typed text, so the field could store one (review of #142, P1).

Both now ask whether the target is inside, in the positive. The field's
rule moves out of the dialog into TargetLufs.h, JUCE-core only, with its
window, so it can be tested without a window. commands_tests,
normalize_plan_tests and the new normalize_step_tests run NaN and both
infinities through the command, the plan and the typed text; the take is
untouched by every one of them.
… ask a stuck store once

Three gaps in the recall the first step makes (review of #142, P2):

- A stored reading was trusted as it stood. Recall now takes only what
  the meter can say (plausibleReading): a finite, non-negative sample
  peak, and a loudness, when there is one, finite, above the -70 LUFS
  gate and beside a finite true peak. A reading the caller cannot use -
  `serves`, for Normalize whether a plan can be made of it - is also
  measured over, once, and the fresh reading replaces the row. The store
  now refuses an infinite loudness and a loudness beside a -inf true peak
  on the way in.
- A store that could not be asked was asked again to file, so a locked
  one held the worker through a second busy timeout. The filing is
  skipped when the ask failed.
- `failure` kept the ask's error while the filing then succeeded, so the
  log said "not kept" about a kept reading, and lost it when both failed.
  It now says why the history does not hold the reading, and only then.

The recorder suite plants each implausible row past the store's checks
and gets the take measured and the row replaced; puts a plausible row to
a caller that refuses it and sees one question and a fresh reading; and,
with an SQLite authorizer that refuses the ask but lets a filing through,
shows no filing is tried. The store suite covers the new refusals.
…refuse in one voice

The gap between the step and the click was open (review of #142, P2):
the menu checked only pedalBusy, which background jobs do not set,
nothing stopped a second request for the same slot, and recorded()
opened the operation before the job knew anything - a take replaced
between the window and the click was normalized, and left a row.

normalizestep (app/NormalizeStep.h) now holds the protocol:

- run is the first step - target first, then the take's reading, the
  history's for exactly these bytes or a fresh one, and its plan.
- guardedBefore puts a check ahead of the job's history step: the slot
  must still hold the bytes the step hashed. Otherwise the job fails with
  "slot N changed since it was measured - Normalize again", before the
  operation opens: no row, no archive copy, no write.
- StepsInFlight allows one first step per slot. A second request is
  dropped, and the menu item is greyed while the slot's reading is
  pending, as the player's button already was.

The first step is named "Normalize slot N" and turns whatever it cannot
make a plan of - no take, bytes that are not the pedal's, a card that
will not be read - into the same banner as the plan's refusals, so every
refusal of the action reads "Normalize slot N: ...". A capped boost's
window is titled "toward" the target, since it stops short of it.

normalize_step_tests runs the step on synthetic takes with no operation
opened, the guard through a job built as the app builds it (unchanged
bytes write and add one row; another sound under the same name, size and
date, or an emptied slot, are refused with no row and no archive copy),
and the one-step-per-slot rule.
Stopping the check put every slot of the selection back to the dash,
including one a Normalize first step was still reading, whose "..."
then vanished while its answer was on the way (review of #142, P2). The
check now keeps the slots whose read has not landed and clears only
those on a stop, and never a cell a Normalize step is reading.
…s it

The meter reports a take with a +inf sample as a +inf sample peak beside
a count of impossible samples, and the store keeps that reading; recall
rejected every non-finite sample peak, so such a take was measured and
filed again on every click (re-review of #142: 12 of 340 meter readings).
plausibleReading now takes an infinite sample peak, or an infinite true
peak beside a loudness, when the impossible-sample count is above zero,
and still rejects either without it; NaN is rejected everywhere.

The recorder suite covers both sides on their own, recalls a planted
damaged row, and runs a real take with a +inf sample through: measured
and filed once, recalled after.
…levels audio

normalizeGainDb took a NaN or infinite measured loudness, target or
ceiling and returned a gain that is not one. Normalize-on-upload passes
the stored target straight in, and a settings file from a build whose
field let "nan" through still reads it back, so an upload would bake a
NaN gain into the take (re-review of #142). The gain rule now refuses
such figures with a typed error; it is the one place the on-card
command, its plan and the upload all pass through. Which targets are
sensible stays the command's and the Settings field's business.

loudness_tests runs NaN and both infinities as target, measured value
and ceiling, for a boost and a cut, and pins ordinary calls as they were.
The comment on kSmallestGainDb now says what was measured: the gain left
to a capped take was 2.9e-07 dB, under a microdecibel, not hundredths.
…nothing without one

Every read of the target took the settings file's number as it stood. A
file written by a build whose field let "nan" through, or edited by hand
to "inf", handed that on, and an infinite target now reaches
normalizeGainDb's refusal on the message thread, from the player's own
reading (#142 review).

currentTargetLufs, and every other read of the target outside the upload
path, now reads the stored text through targetlufs::parse. A text that is
not a target is no target - none is put in its place - and it is
reported once per launch: "The normalize target in Settings is not a
number LooperCat can use (<value>): set it again in Settings -> Import".
While it stays so, readings carry no verdict (the number and the peak,
no attention colour), Normalize on a slot or a selection is refused with
that sentence before anything is measured, its menu items lose the
target from their names, and the Settings field starts empty with no
ReplayGain equivalent until a target is typed; an empty field writes
nothing over the stored text. With nothing stored yet the target is the
one out of the box, as before.

describeReading moves out of the window into LoudnessReport.h (JUCE-core
only) and takes an optional target. normalize_step_tests covers the
stored texts, the sentence, and readings with no target against the same
readings with one.
MSVC's app lane failed on normalize_step_tests: the suite reaches
windows.h through JUCE, and windows.h still defines the 16-bit pointer
qualifier near as an empty macro, so 'bool near(double, ...)' reached the
compiler as 'bool (double, ...)'. The plan suite carries the same helper
and escaped only because nothing there includes windows.h yet.
@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