Skip to content

0.9.6: merge #150-#159 in stack order, with the integration fixes - #160

Merged
AliceLafox merged 103 commits into
mainfrom
chore/integration-0.9.6
Oct 17, 2026
Merged

AliceLafox merged 103 commits into
mainfrom
chore/integration-0.9.6

Conversation

@AliceLafox

@AliceLafox AliceLafox commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

The ten fix PRs for 0.9.6, merged in stack order on one branch, so CI judges the tree that ships once instead of once per retargeted PR. Each PR keeps its own merge commit (Merge pull request #N ...), second parent = the PR's head, so its history is intact.

Order: #150, #152, #151, #153, #154, #155, #156, #157, #158, #159.

Conflicts resolved in the merge commits

Integration fix (d6a0c88), conflicts git did not see

Checks (macOS, local, pinned deps)

  • core-only tree: 100% tests passed out of 41
  • app tree, harnesses included: 100% tests passed out of 74 (the 6 *_harness suites passed)
  • mutants on the new ImportPrefs rules: 3 of 3 caught (11, 1 and 2 failed checks), back to 46/46 once restored
  • warnings printed by the incremental build: 6, all on lines unchanged from main (DeviceWatcherMac.cpp kIOMainPortDefault, WavImport.cpp createWriterFor, wav_import_tests.cpp:78)

Review fixes (adc5cd5, tests b5817fe)

An adversarial review of the glue above, asked to prove it broken:

  • An interrupted first snapshot is normal: say so in the player's words, not as a fault #154 x Normalize a slot: measure before asking, and ask only when there is something to do #158: since An interrupted first snapshot is normal: say so in the player's words, not as a fault #154 a quiet job's refusal at the worker's gate is not told, so the single-slot Normalize step (quiet, written against the old worker) ended in silence when the pedal was leaving. endNormalizeStep now says why in JobWords' sentence and logs the refusal.
  • targetlufs::parse read a number off the front of anything: a hand-edited -18,5 became -18 and -18 LUFS passed for -18. Now the whole text must be the number. An empty stored target is said as "empty", not "()".
  • Review tests: the sighting on both loudness read paths, including a store that keeps the reading and refuses the sighting (sightingFailure set, failure empty); a v7 file whose v10 step fails stays v7 as a whole, then migrates once.
  • Migration checked on a copy of a real v7 history (3 cards, 30 operations, 266 slot changes, 68 audio rows). v7 to v10 took 9 ms, every row count is unchanged, every card's timeline reads, integrity_check and foreign_key_check are clean, and the second open is a no-op. A crash before COMMIT rolls back and migrates on the next open. A brand-new history is created at v10.
  • Not changed here (pre-existing, to file separately): every HistoryStore open bumps two SQLite header bytes (PRAGMA auto_vacuum = INCREMENTAL on an already-incremental file).

Checks after the fixes: core 100% tests passed out of 41; app with harnesses 100% tests passed out of 74. A mutant that disables the strict parse was caught (3 failed checks in import_prefs_tests, 5 in normalize_step_tests); both suites are clean once it is restored. The new refusal sentence in endNormalizeStep has no automated test: it needs the GUI and a pedal leaving mid-request.

Merge with a merge commit, never squash.

Issues this stack closes

Closes #148, closes #149, closes #143, closes #139, closes #146 (named by #150, #152, #151, #153, #154), and closes #145, closes #144, closes #140, closes #142, closes #141 (#155, #156, #157, #158, #159 named no issue).

The atlas said the pedal's settings files were zero bytes and uncharted.
On a working card both carry a <sys> document with SETUP, MIDI and CTL,
and the same pair-and-counter mechanism as MEMORY1/2 — which the core
has read by since SystemFile.hpp. Replace the paragraph with one section
in the atlas's own shape: the pair and which file wins, a table per
section with the observed values, and the confidence each fact earned.
MemoryNumber is the pedal's selected memory, zero-based, and the pedal
rewrites the pair when that changes; the CTL names follow the manual's
lists as Controls.hpp decodes them; the SETUP and MIDI meanings stay
unknown until read off the screen.
…overed

Testers keep asking whether the pedal can save a memory by itself. The
settings file is now charted, and nothing in its three sections names a
save, a write-on-change or a confirmation. Write that down next to the
tables; whether the pedal behaves that way with no stored setting behind
it is still a question for the manual or the hardware.
Two readings of a working RC-5 (#147, 2026-09-30 and 2026-10-01): Rock2
chosen on the pedal is stored as 12 in a 4/4 memory and as 3 in a 6/4 one,
and a 19 written by this app at 6/4 shows a blank name on the screen. So
the number in <Pattern> indexes the pattern list of the memory's CURRENT
BEAT, and the manual's printed list - kPatterns - is the 4/4 list. Every
other beat's list is uncharted: its length, its order, where Blank sits.

The range cannot catch this. A short list's numbers all fall inside the
57-entry one, so patternName answered 3 with SimpleBeat4 and no error, and
the editor wrote 4/4 indices into 6/4 memories. The guard keys off the
beat (#149):

- patternListCharted(beat) says which beats have a list we can name from,
  only kBeatFourFour, and Values carries the answer as patternCharted, so
  read() returns the raw number at every beat and names nothing.
- apply() checks a pattern edit against the beat the edit leaves the
  memory at: a pattern together with a move to 4/4 passes; a pattern alone
  at 6/4, or together with a move away from 4/4, is refused before any
  byte moves, naming the beat as the screen prints it. Changing BEAT alone
  stays allowed and reproduces the pattern bytes exactly.
- The switch's two paths that write a pattern number - Blank to the
  default on the way on, the groove to Blank on the way off with a
  count-in to keep - are refused at an uncharted beat (switchRefusal,
  shared with the tab so it can say so before the click). State alone is
  still ours, and "already there" still moves nothing.
- describe() takes the memory's beat and refuses the same pattern edit, so
  the history never gets a 4/4 name for a 6/4 number.

Tests from the hardware pairing and the rule, not from the code: 6/4
bodies read but not named, refusals that leave the body untouched and say
"6/4" rather than 4, BEAT moving with the pattern bytes reproduced, the
pattern checked against the new beat, each switch case at 6/4 refused or
passed, and 4/4 unchanged. 266 checks; with the guard switched off 54 of
them fail, with apply()'s pattern check removed 12 do.
Where the core will not name the pattern (#149), the box shows the number
the card holds - as text, with no item behind it - and is a lamp, in the
shape BEAT already takes under a take: the caption reads "PATTERN (list at
6/4 not charted)". At 4/4 nothing changes. Going grey and coming back
re-applies the lock the way it does BEAT's, so a dropdown moved past the
greyed face snaps back to the number.

The switch follows the core: where a click would need a pattern number -
on over Blank, off with the count-in on - the switch is a lamp and the
line beside it carries the core's own sentence (switchRefusal), so the tab
and a banner can never disagree, and no click ends in an error for
something the tab could have said up front. The "forgets <name>" line is
reached only where the list is charted.

The owner hands describe() the memory's beat from the row it is editing:
the words are made before the job is enqueued and before the history
opens its row, so a pattern at an uncharted beat is refused with no row
written. The card itself is checked again on the worker, against the beat
it holds then.

rhythm_pane_harness: four 6/4 rows - the pedal's own Rock2 at 6/4 (3)
with drums on, the same with a count-in in front, Blank off, and the 19
the pedal could not name - checking the number on the box, the caption,
which controls stay the player's, which switch clicks are offered, the
lock surviving busy, and 4/4 as before.
The RHYTHM table's Pattern row now carries the two hardware readings of
#147 (Rock2 = 12 at 4/4 and 3 at 6/4, 2026-10-01) and says what is known:
the 4/4 list, and nothing of any other beat's.
The History window painted a badge for every slot an operation touched,
but the layout only made room for three. The card's first sighting
touches all 99, so 96 badges ran over the action, the detail and the
audio column (#143). The hit test walked the same unbounded list, and a
click anywhere on that row silently filtered to a slot.

Which badges a row wears is now one answer, history::badges::shown,
read by both the painting and the hit test: up to three slots, then a
"+N" chip in the last position counting the rest, and none at all for a
snapshot, whose sentence already says "99 slots". The chip is a number,
not a filter; the counted slots still answer the slot filter, and a
restore out of a snapshot still goes by the filtered slot.

A click in a row is now a mouse-free action, clickAt, so the tests can
press the chip and see nothing happen.
Every job that did not succeed reached the banner as `description + ": " +
error`, with whatever the core said. For a job the lifecycle gate refused that
read "Push loop.wav to slot 3: pedal is ejecting — refusing to touch the
volume": right for a log, wrong for a window, and it hid the one fact that
mattered — nothing had run (issue #146).

JobOutcome is the ending as a value: success, a failure of the job's own work,
or the gate's refusal with the state it refused in, the core's sentence kept as
the log text. JobWords turns it into sentences: a refusal is "<job> did not
run: <why>", one wording per state the gate refuses in; a failure keeps the
shape it always had; the first snapshot's interruption has its own quiet
sentence for the toast and an engineering line for the log. Asking for a
banner line for a success, or the interruption's words for a failure, is a
caller bug and throws. Both headers are JUCE-free, and job_words_tests proves
the table from the issue's spec.
…the banner

The worker threw the gate's refusal as an Error, and the same string travelled
through `after` and onJobResult as if the job had failed. It had not: neither
`before` nor `work` ran and the card was not touched. The gate now produces a
JobOutcome::refused with the machine's state, a throw from `work` a
JobOutcome::failure, and both hooks take the outcome instead of a sentence. A
throw from `after` is still appended to the error, and turns a refusal into a
failure to tell: a history problem must reach the banner whatever the job did.

A quiet job reports only failures, and a refusal is none, so the first
snapshot's refused step and the card-name read the pedal left behind no longer
raise a banner at all; their `after` still learns the refusal, which is what
the first snapshot resumes from, and the card-name read traces the core's
sentence instead. The player's own jobs are told every ending, as before.

pedal_monitor_harness checks the ghost refusal as a typed ending, that neither
hook ran, and that a quiet job's refusal is not told; first_seen_tests drives
the first snapshot's two endings — refused and failed — and the words for
each.
…d job as one that did not run

A player who connected and disconnected again within the first snapshot's run
saw an orange banner: "Record the card's first snapshot: pedal is ejecting —
refusing to touch the volume". Nothing was wrong and nothing was lost — the
snapshot resumes on the next connect from the slot it reached — and the
sentence answered none of a player's questions (issue #146).

The refused step is now told once, as information: a toast saying the snapshot
stopped when the pedal was disconnected and will finish next time, because the
toast is the app's channel for news and the status line is the disconnect's at
that moment. The operations log gets the engineering line, "first snapshot
interrupted at slot N: pedal is ejecting — refusing to touch the volume", so
the Connect/Disconnect trace still explains itself. A step that fails on its
own — the card changed underneath, a read error — is still a failure and still
reaches the banner as before.

Every other job the gate refused — a push, a trim, a rename asked while the
pedal was leaving — keeps its banner, since the player's own job did not run,
but reads "<job> did not run: the pedal was being disconnected", with the
core's sentence in the log.
A detail that did not fit was cut at its end with an ellipsis, which is
exactly the part that matters: a normalize that says "already peaking at
the -1 dBTP ceiling (measured -19.2 LUFS), nothing to..." lost the
"nothing to do" (#143).

elideMiddle fits a sentence into a width by replacing as little of its
middle as has to go with one ellipsis, the tail never shorter than the
head. It is pure: the width of a string comes from a function the
painting site provides, so the fit is tested through a stand-in face,
and every candidate is measured, so what comes back has been seen to
fit — or is the ellipsis alone when not even that does.
push named the file on the card after whatever path it was handed, so the
working name of a conversion's temp file travelled to the card by accident.
PushOptions now carries an optional landed name; the core checks it before a
byte moves — a bare file name, something in front of .wav, no separator in
either direction — and refuses otherwise with the slot and the archive
untouched. Absent, the handed file's own name, as before.
…eferences

The suffix on a rebuilt upload becomes a decision: uploadmark::landedName
gives <source stem><suffix>.wav for a conversion and leaves an untouched
file's name alone, and a suffix with a character a FAT name cannot hold is
refused by name rather than stripped. The default stays -pedal, the spelling
existing cards already carry.

ImportPrefs gathers the three upload preferences — the normalize switch, its
target and the mark — with one reader and one writer over the PropertiesFile,
so the dialog, the push job and a test see the same keys and defaults. A mark
cleared to nothing stays nothing: the key is present, its value empty.
wavimport::prepare wrote <stem>-pedal.wav into import-tmp, renamed around a
clash, and left the caller to delete the file by hand on both exits of the
push. The conversion now goes to a directory of its own under import-tmp,
owned by the Prepared through a RAII handle: gone when the job returns and
when it throws alike, and two uploads with the same stem in one queue never
meet.

Prepared also reports the source's sample rate, sample format and channel
count. A compressed source names its codec, because the float32 our MP3
reader hands out is the decoder's, not the file's. describeConversion says
only what differs from the pedal's 44100 Hz, 32-bit float, stereo — so a
loudness-only rewrite of a pedal-ready file has no sentence at all.
…nged

The push job reads the upload preferences when the player acts, names the
file on the card through the mark rule, and hands that name to push instead
of inheriting the temp file's. The note the toast and the history row carry
opens with the conversion's facts, joined to the normalization sentence when
both apply, and the operations log line says the landed name when it differs
from the source's.

Settings -> Import gains the mark field under the normalize switch. A
character a FAT name cannot hold snaps the field back to the stored mark and
says why beneath it; nothing is stripped on the way in.
Why a rebuilt upload lands as <name>-pedal.wav, what the push report says
about it, and where the suffix lives.
With the detail cut to the row's width, the rest of a sentence could be
read nowhere: the window had no hints at all (#143). Each row now
answers getTooltipForRow with its whole line — when, action, detail,
state, audio — joined by a dash, the parts that are empty left out.

A juce::TooltipWindow shows tips only for components sharing its own
peer, so the main window's never reached a view living in its own
desktop window. HistoryWindowHost, the frame the view lives in, owns one
now, with the main window's delay so the two feel alike.
describeConversion joined its two sides with an ASCII "->" while
describeNormalize, which shares the same note, draws U+2192. A push that
was both rebuilt and levelled read with two different arrows in one
sentence. The conversion now uses the same escape, the tests pin the
sentences with it, and the guide's example matches what the toast shows.
The refused step ends while the lifecycle is still ejecting, so its toast went
up a second before "Pedal disconnected — back on the looper screen" replaced
it, and the player never read what happens next (issue #146).

The interruption now waits in a FirstSnapshotNotice until the departure is
told. The app-driven Disconnect says both at once — "Pedal disconnected — back
on the looper screen. The card's first snapshot stopped; it will finish next
time you connect." — and the sentence no longer repeats that the pedal left.
Where the departure had no toast of its own (a yank cleaned up, a Finder
eject, a volume ejected while the pedal stayed in STORAGE), the lifecycle line
and the status told it, and the sentence stands alone on the first scan
without the pedal. Said once: a second disconnect without a new interruption
says only its own sentence, and a new connection resumes the snapshot and
forgets. The log line at refusal time is unchanged.

Two sentences do not fit the strip the toast always drew, and drawText would
end them in an ellipsis: the toast now owns its width, widening around the
anchor its owner gives it as far as the window allows, and one sentence's
width again for the next short one.
…old take moves

push archives and removes the old take before it writes the new one, so a
name the card refuses at the write leaves the slot folder empty under a
config that says it holds audio. The up-front check had no length rule, and
a converted upload's <stem><mark>.wav could pass it at 256 UTF-16 units and
fail on the card (review of #139: a 252-character stem with force, a
247-character stem through the whole job, a 260-character mark).

FatName.hpp now owns what a file name on the card may be — FAT's reserved
characters and control range, the 255-unit limit counted in UTF-16 units
from the UTF-8, and the device names Windows will not open, judged before the
first dot as Windows judges them — so the core and the app check the same
rules. assertLandedName refuses all of it before a byte moves, and with it a
name the card's own sweep would delete ("._take.wav" was reported as pushed
and then swept) and a name starting with a dot. The reproductions leave the
card byte-identical and the old take in place.
A float DAW export wearing a WAVE_FORMAT_EXTENSIBLE header fails the pedal's
strict gate and goes through the converter, so it landed as <stem>-pedal.wav
with an empty sentence although no sample moved. "Rebuilt" is now a fact
Prepared reports: the rate, sample format or channel count differed from the
pedal's, or a gain went in. The mark keys on it; a header-only rewrite lands
as <stem>.wav — the extension still changed on purpose — with no mark and no
sentence, and its samples equal the source's bit for bit. `converted` keeps
meaning what it did: the file lives in the job's directory.

landedName takes what happened to the audio (untouched, repackaged, rebuilt)
and judges the mark only where it is used, so a mark hand-edited into the
settings file no longer stops an untouched upload. A name it builds over the
FAT limit is refused with the name it would have been, never trimmed, and
assertSuffix carries a named cap so the Settings field refuses a mark no name
could carry. The field trims: a mark of only spaces is the empty mark the
hint promises.

FLAC keeps its samples' width and JUCE's reader reports it, so a FLAC source
reads "24-bit" like PCM; only MP3 and Ogg Vorbis name their codec. A job
directory that will not delete leaves a line in the operations log — the
only trace a leftover has until something sweeps import-tmp. The ImportPrefs
comment now says what the code does: the preferences are read as the job is
queued and travel with it.
The guide says the push toast and the history row spell out what changed,
but the push row drew only the take's name, length and tempo; the job's note
was stored and never shown. The row now draws the note's sentences after the
numbers — the rebuild's facts, the normalization's — leaving the note-length
fact to the place it already had at the end.
Review of the beat lock found the hole it left open: Play Count-In has two
paths of its own that write <Pattern> - the borrow on the way on (State on,
Pattern 57) and the hand-back on the way off (State 0, Pattern 0) - and
both numbers are 4/4 numbers. Through the table's column or the card,
"count-in on" over the pedal's own Rock2 at 6/4 (stored as 3) wrote 57,
and "off" then wrote 0. The header said no edit writes <Pattern> at an
uncharted beat; now it is true of both features.

BEAT moves to Beat.hpp, where both can see it: the manual's list, the one
charted beat, patternListCharted, the shared fact sentence, and two ways to
name a number - beatName, which throws for an action on BEAT itself, and
label, which never throws and tells the truth about a number the list
lacks ("BEAT 17, not in the manual's list"). kBeats is inferred past its
one anchor, so a memory can hold such a number, and reading it must not
end the app: switchRefusal now decides its two branches before any name is
looked up, so a State-only switch at beat 17 passes; the beat is named
only inside the refusal, through label. The rhythm's names for the beat
stay, as using-declarations.

countin::refusal(body, on) is the count-in's typed answer, pure over the
body: on is refused where the section is off (the borrow), off where the
section was borrowed for the count (the hand-back), and a PlayCount-only
switch - on over a playing rhythm, off where nothing was borrowed - passes
at every beat. apply() asks it before any byte moves; refusalText carries
the fact for the banner. patternAtRisk answers nothing at an uncharted
beat: the click that would replace the pattern is refused there, so the
refusal is what the card says, and deciding from 0 or 57 would read 4/4
names into another list's numbers. SlotInfo carries the refusal of the
next click (countInRefused) for the table and the card.

Both switch refusals are typed now (SwitchRefusal, Refusal) with the
sentence built apart, so the UI can say the same fact in fewer words.

Tests from the rule, not the code: every beat but 4/4 plus -1, 17 and 99,
both toggles, all eight section states - refused exactly where a pattern
would be written, the body untouched, PlayCount alone elsewhere; the
sentence naming 6/4 and never 4; the P0 end to end through
commands::setCountIn on a scratch volume, both memory files byte-identical
after each refusal and a 4/4 slot ahead in the batch not landing first;
the read model's refusal per slot; a beat the list lacks read, labelled,
switched State-only and refused a pattern. usecase_count_in_tests 1501
checks, usecase_rhythm_tests 324, catalog 944, commands 1584; with the
guard removed from apply() 200 and 8 of them fail, with patternAtRisk's
removed 4 and 1 do.
… switch

One place for the lines a lamp carries (RefusalWords.h): the rhythm switch,
the Play Count-In card and the table's Count-In cell each ask the core's
typed refusal and say it in one short line - no date, no field name - with
the beat as the screen prints it, or the truth about a number the manual's
list does not have. The core keeps the full fact in its Error text for the
banner and the log.

The Play Count-In card is a lamp where the count-in's click would write a
pattern number at an uncharted beat, its orange line saying why; the
table's dot is the same lamp, and its hover says the same line, since a
seven-pixel dot cannot. The Rhythm tab's PATTERN caption and cost line no
longer look a beat's name up on every snapshot: a memory holding a beat the
list lacks is shown ("PATTERN (BEAT 17 is not in the manual's list)"), not
thrown on.

editRhythm catches what describe() may throw rather than dropping the edit:
the tab locks PATTERN where the words would be refused, so reaching the
catch means the tab and the core disagree, and a silent drop would hide
exactly that. The banner shows the core's sentence; no job and no history
row follow.

rhythm_pane_harness: two rows at Beat 17 - painted without a throw, the
caption, a State-only switch offered, the lamp and its line over 57.
slot_table_harness: a 6/4 row whose Count-In click calls nobody and whose
hover says why, a 6/4 row with the rhythm playing that still toggles, One
Shot untouched by any of it.
One sentence in the Play Count-In bullet: where the click would have to
write a rhythm pattern number, the switch waits and the card says what to
do instead, and the Rhythm tab shows such a pattern as a number for the
same reason.
…red slot in sight

The snapshot row wore no badges, but nothing said its slots in words
either: rows::forCard words the row as "Card first seen" and "<N>
takes, <bytes>", and the window's test had typed "99 slots" into the
detail by hand (#143, review). Now a row counted in words paints
"<N> slots" across the badge strip in the chip's quiet ink, and its hint
carries the same words. The test feeds a snapshot row worded by
rows::forCard from a 99-slot entry, so the words are proven to be the
window's own.

Filtering to a slot behind the chip left the row with no violet badge,
so nothing said why it was listed. The filtered slot now takes the last
badge position in place of the row's slot there — the chip keeps its
count, one slot out of hiding as one went in — and the hit test reads
the same answer.

The hint also says what the row paints and the strip only counts:
"slots 3, 7, 12, 40, 99" for a row with a chip, and "pinned" for a
pinned row. badgeStripText reads the strip in words — the badges' numbers,
the chip, or the words — from the same list the painting draws.
… in the strip from playing

show() took any row: slot 0, slot 100, a slot twice, slots out of
order. The badge for such a slot then threw from inside JUCE's mouse
dispatch when clicked, far from the mistake (#143, review). The store
hands slots ascending, each once, and the pedal has 1..99 of them; a row
that says otherwise is now refused by its op before the window changes.

A double-click returned early only where badgeAt answered, so a
double-click on the chip, or on the "99 slots" words, played the row.
The guard is now the whole badge strip — badge, chip or words, the first
click has answered — and a double-click past it still plays. doubleClickAt
is the mouse-free version, like clickAt, so the tests can press it.
elideMiddle cut by character, so a cut could land between a letter and
its combining accent, inside a family joined by zero-width joiners, or
between the two letters of a flag: valid UTF-8 that renders wrong, and
take names from macOS often come decomposed (#143, review).

Each side of the cut now pulls back to the cluster it would have cut
into — the head's end down, the tail's start up — over the clusters
UAX #29 has for what names and sentences carry: combining marks,
variation selectors, skin-tone modifiers, the joiner and what follows
it, and regional-indicator pairs. The fit is still measured, candidate
by candidate.

"One ellipsis" means one put in: a sentence carrying its own keeps it,
and the comment now says so.
The History window's frame had its own 600 next to the main window's
600 (#143, review). kHintDelayMs lives in HintDelay.h now, and both
windows read it.
Re-review of the beat lock found the one BEAT move that strands a memory:
State on, Blank, count on is the count-in's own shape, and moved away from
4/4 it is stuck in the app - the count-in's hand-back and the rhythm's
switch would both need a pattern number there, both are lamps, and BEAT
itself is the only way back. So rhythm::apply refuses that one move with a
typed Error ("BEAT cannot leave 4/4 while the count-in borrows the rhythm
section: switch the count-in off first"), Values carries the fact as
beatHeldByCountIn, and the tab makes BEAT a lamp with the caption in the
take's shape ("BEAT (held by the count-in)") and the way out on the line
beside the switch. Every other BEAT move keeps the pattern bytes as the
issue asks - 4/4 to 6/4 with a count over Rock2 still passes and keeps 12 -
and a move to 4/4 is never refused; a memory already stuck at 6/4 is not
"held", since the hold is a 4/4 fact.

Two sentences reworded from the same review: the count-in's off refusal
now says what to do ("switch the count-in off on the pedal instead",
core and card alike), and a beat the manual's list lacks is labelled
"BEAT 99 (not in the manual's list)", so every sentence that names it still
reads as one at either label.

One line in Rhythm.hpp's header: a restore (history, undo, redo) writes a
recorded body faithfully, not through apply(), so a row recorded before
this guard can bring a 4/4 number back to a memory at another beat; the
tab then shows it as a number. No code change for restore.

Tests: the stuck case refused with the body untouched, with a kit in the
same edit, and under a take (the manual's refusal first); the same beat
not a move; the count-in's own switch as the way out; a count over a
groove, a rhythm on with Blank and no count, and the factory slot not
held; the stuck memory's way home never refused; the reworded sentences.
rhythm_pane_harness: a 4/4 row in the count-in's shape - BEAT a lamp with
its caption and line, PATTERN and the switch still the player's, a pick
past the greyed face heard by nobody. usecase_rhythm_tests 345 checks;
with the hold removed from apply() 6 of them fail.
…al whole

The review of the first-snapshot change found the interruption lost on every
path where a scan sees the pedal gone before the refused step completes: the
scan cancels the run, the step's completion returns on `cancelled`, and since
a quiet refusal is no longer told by the worker, nothing said or logged it —
always for a Finder eject, a volume ejected while the pedal stayed in STORAGE
or a yank the probe saw first, and under a busy message thread for the app's
own Disconnect. FirstSnapshotNotice now has two ways in: the step's own
completion, and `departed`, called from the scan before the run is cancelled
with the state it saw and the slot reached. Whichever comes first logs the
line; the other is not news, so it is said once either way.

JobOutcome is a class built by its factories only — an open aggregate let a
success be refused at once. A job that did not run carries its reason: the
gate's four states, and now the volume that was there at the last scan and is
not when the job runs, which went down the failure path although nothing had
run either. The history's own failure, a throw from `after`, travels beside
the ending instead of folding a refusal into a plain failure: the banner says
"<job> did not run: <reason>; the history could not record it: <e>", the
trace keeps the refusal, and a quiet job is told when that happens.

A `std::exception` whose `what()` is empty made JobOutcome::failure throw
inside the handler, and a throw out of run() ends the worker thread with no
`after`, no result, no busy(false) and no job or scan ever again. The worker
names such a throw by its type instead, with an assertion, and the harness
proves the job behind it still runs.

The History row of an interrupted snapshot read the core's sentence after the
take's name; it reads "stopped when the pedal was disconnected" now, while the
core's sentence stays in the operations log. A departure sentence that already
ends in a period gets no second one, and a refused Undo or Redo settles with
the banner's words rather than the core's.
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.
…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.
…sked for

The stamp test set a modification time with milliseconds and expected the
same integer back. JUCE on Linux sets and reads whole seconds, so the CI
lanes read 1600000000000 where the test asked for 1600000000123 and the
rows, which faithfully carry what the directory entry says, failed against
the asked-for value. The invariant is that a row says what the file system
says: compare against the stamp read back, and check only that the setter
took (rounded down to a second at most).
…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.
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.
…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.
# Conflicts:
#	CMakeLists.txt
#	tests/player_pane_harness.cpp
#	tests/wav_tests.cpp
# Conflicts:
#	app/MainComponent.cpp
#	app/SettingsDialog.h
# Conflicts:
#	CMakeLists.txt
#	app/MainComponent.cpp
#	app/MainComponent.h
#	app/history/SlotLoudness.h
#	tests/slot_table_harness.cpp
…s the whole text or none

A quiet job's refusal at the worker's gate is not told since #154, so the single-slot Normalize step, written against the old worker, ended in silence when the pedal was leaving. endNormalizeStep now says it in the words every refused job uses.

targetlufs::parse read a number off the front of anything: a hand-edited "-18,5" became -18 and "-18 LUFS" passed for -18. The whole text must now be the number. An empty stored target is said as empty rather than as "()".
…ails its last step

Review tests for the integration: a refused sighting is reported apart from a kept reading on the read and the recall path; a v7 file whose v10 step fails stays v7 whole, then migrates once, with the operation sequence starting past the Undo floor.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment