Repository navigation
0.9.6: merge #150-#159 in stack order, with the integration fixes - #160
Merged
Merged
Conversation
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).
…eat/lufs-from-history
…eat/normalize-measure-first
…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
…ad speaks JobOutcome
…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.
This was referenced Oct 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
824ae1f): additive only —CMakeLists.txtkeeps both new suites (import_prefs_tests,reader_loudness_tests),tests/wav_tests.cppkeeps both new blocks,tests/player_pane_harness.cppkeeps Loudness readings belong to the bytes: keep them in the history, keyed by content hash #157's read reset with Which slot am I looking at: move SLOT nn into the tab strip, where it is true for every tab #145's "the name alone" slot title.f16468c), against Uploads converted for the pedal: mark them on purpose, and say what changed #153, not textual: both reworked the import preferences.ImportPrefs(Uploads converted for the pedal: mark them on purpose, and say what changed #153) stays the one reader and writer; its target becomesstd::optional<double>read throughtargetlufs::parse(Normalize a slot: measure before asking, and ask only when there is something to do #158): stored text that is not a target reads as none, an absent key is still the -18 default, andwriteleaves an unusable stored target alone until the player types one.currentTargetLufs()reads throughimportprefs::read, so the Normalize commands and the upload share the rule.getDoubleValue), so "nan" went straight into the gain.import_prefs_tests: target cases added (nan, inf, empty, out of window, both window edges, write-without-target keeps the stored text, absent key = default).cc30947), against Normalize a slot: measure before asking, and ask only when there is something to do #158 and Name or write a rhythm PATTERN only at 4/4, the one beat whose list is charted #152:app/history/SlotLoudness.h: Fill the LUFS column on connect from what the history already knows #159's sighting (name, size, stamp around the read,newestOp) moved into Normalize a slot: measure before asking, and ask only when there is something to do #158's read steps (readTake/measureAndFile). A reading recalled for the same bytes files the sighting too, since the bytes were read either way. A sighting the store refuses goes to a newsightingFailure, beside a kept reading, so Normalize a slot: measure before asking, and ask only when there is something to do #158's rule "failureis empty wheneverkept" still holds. Both callers log it in their own words.MainComponent.h: Normalize a slot: measure before asking, and ask only when there is something to do #158'scheckUnread/normalizeStepstogether with Fill the LUFS column on connect from what the history already knows #159's members; Fill the LUFS column on connect from what the history already knows #159's leftovercheckSlotsis dropped (no user left).tests/slot_table_harness.cpp: Do not name or write a rhythm pattern for a memory whose time signature we have not charted #149's 6/4 rows with Fill the LUFS column on connect from what the history already knows #141's new row field.Integration fix (
d6a0c88), conflicts git did not seeSlotTable::getCellTooltipwas added twice: by Name or write a rhythm PATTERN only at 4/4, the one beat whose list is charted #152 (Count-In refusal) and by Fill the LUFS column on connect from what the history already knows #159 (LUFS reading). Now it is one function that answers per column.after(const std::string&)callback. They now take An interrupted first snapshot is normal: say so in the player's words, not as a fault #154'sJobOutcome:!error.empty()becomes!outcome.ok()for the step andoutcome.failed()for "the read failed on the file itself".describeReading, which Normalize a slot: measure before asking, and ask only when there is something to do #158 had renamed toloudnessreport::describe.Checks (macOS, local, pinned deps)
100% tests passed out of 41100% tests passed out of 74(the 6*_harnesssuites passed)ImportPrefsrules: 3 of 3 caught (11, 1 and 2 failed checks), back to 46/46 once restoredmain(DeviceWatcherMac.cppkIOMainPortDefault,WavImport.cppcreateWriterFor,wav_import_tests.cpp:78)Review fixes (
adc5cd5, testsb5817fe)An adversarial review of the glue above, asked to prove it broken:
endNormalizeStepnow says why inJobWords' sentence and logs the refusal.targetlufs::parseread a number off the front of anything: a hand-edited-18,5became -18 and-18 LUFSpassed for -18. Now the whole text must be the number. An empty stored target is said as "empty", not "()".sightingFailureset,failureempty); a v7 file whose v10 step fails stays v7 as a whole, then migrates once.HistoryStoreopen bumps two SQLite header bytes (PRAGMA auto_vacuum = INCREMENTALon an already-incremental file).Checks after the fixes: core
100% tests passed out of 41; app with harnesses100% tests passed out of 74. A mutant that disables the strict parse was caught (3 failed checks inimport_prefs_tests, 5 innormalize_step_tests); both suites are clean once it is restored. The new refusal sentence inendNormalizeStephas 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).