From 218cae93451360de0d398966f35bbe5625f066f3 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Sun, 4 Oct 2026 03:03:55 +0200 Subject: [PATCH 1/7] Name a rhythm pattern only at 4/4, the one beat whose list is charted 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 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. --- core/include/loopercat/usecases/Rhythm.hpp | 177 +++++++++++--- tests/usecase_rhythm_tests.cpp | 271 ++++++++++++++++++++- 2 files changed, 403 insertions(+), 45 deletions(-) diff --git a/core/include/loopercat/usecases/Rhythm.hpp b/core/include/loopercat/usecases/Rhythm.hpp index 18d4474..2dc651c 100644 --- a/core/include/loopercat/usecases/Rhythm.hpp +++ b/core/include/loopercat/usecases/Rhythm.hpp @@ -23,14 +23,26 @@ // groove becomes Blank. The pattern is forgotten, and the UI says so // before the click (patternLostOnOff). No hidden state anywhere. // -// The lists are the manual's, in its order, counted from zero. Three points +// The lists are the manual's, in its order, counted from zero. Four points // are measured on the pedal: Pattern 57 = Blank (kRhythmPatternBlank), Beat 2 -// = 4/4, PlayCount 1 = 1MEAS. The rest — every other name, the interior of -// the BEAT range the manual prints as "2/4–4/4–7/4, 5/8–15/8", the +10 the -// file adds to TONE LOW/HIGH (screen −10..+10, factory body 10) — is per the -// manual and the factory body, not yet read off the screen. A number outside -// a list is a typed error carrying the number: a control that shows "?" for -// it would hide exactly the fact that says the rule is wrong. +// = 4/4, PlayCount 1 = 1MEAS, Pattern 12 = Rock2 at 4/4. The rest — every +// other name, the interior of the BEAT range the manual prints as +// "2/4–4/4–7/4, 5/8–15/8", the +10 the file adds to TONE LOW/HIGH (screen +// −10..+10, factory body 10) — is per the manual and the factory body, not +// yet read off the screen. A number outside a list is a typed error carrying +// the number: a control that shows "?" for it would hide exactly the fact +// that says the rule is wrong. +// +// One list is not one list. The number in indexes the pattern +// list of the memory's CURRENT BEAT (hardware, 2026-09-30 and 2026-10-01, +// #147): Rock2 chosen on the pedal is stored as 12 at 4/4 and as 3 at 6/4, +// and a 19 written at 6/4 shows a blank name on the screen. kPatterns is the +// 4/4 list, the one the manual prints; every other beat's list is uncharted +// — its length, its order, and where Blank sits in it. The range cannot tell: +// a short list's numbers all fall inside this one, so patternName would +// answer with a wrong name and no error. The guard keys off the beat (#149): +// at an uncharted beat the number is read and shown, never named, and no +// edit writes . // // One rule the manual states outright (p. 10): BEAT cannot be changed after // a track is recorded. A slot with an indexed take refuses a Beat edit; the @@ -58,9 +70,11 @@ namespace loopercat::usecases::rhythm { // (Choice.hpp, shared with every list-valued use case). using usecases::Choice; -// PATTERN: 57 grooves in the manual's fourteen groups, then Blank. Anchors: -// 0 is the factory value and the printed default (SimpleBeat1); 57 is Blank -// on the screen (hardware). Interior boundaries follow from the group sizes. +// PATTERN at 4/4: 57 grooves in the manual's fourteen groups, then Blank. +// Anchors: 0 is the factory value and the printed default (SimpleBeat1); 57 +// is Blank on the screen (hardware); 12 is Rock2 (hardware, 2026-10-01, a +// 4/4 memory written on the pedal). Interior boundaries follow from the +// group sizes. This is the 4/4 list only — see patternListCharted. inline constexpr std::array kPatterns { { { 0, "SimpleBeat1" }, { 1, "SimpleBeat2" }, { 2, "SimpleBeat3" }, { 3, "SimpleBeat4" }, { 4, "GrooveBeat1" }, { 5, "GrooveBeat2" }, { 6, "GrooveBeat3" }, { 7, "GrooveBeat4" }, @@ -102,6 +116,16 @@ inline constexpr std::array kBeats { { { 12, "11/8" }, { 13, "12/8" }, { 14, "13/8" }, { 15, "14/8" }, { 16, "15/8" }, } }; +// The one beat whose pattern list is charted: 4/4, the manual's printed list +// (hardware: Beat 2 reads 4/4 on the screen, and Rock2 written there is +// stored as 12, where kPatterns has it — 2026-10-01, #147). +inline constexpr long long kBeatFourFour = 2; + +// Does this beat have a pattern list we can name numbers from? Only at 4/4. +// A beat outside kBeats has no charted list either — the answer is no, not +// an error, so a memory can always be read; naming it is what fails. +inline constexpr bool patternListCharted(long long beat) { return beat == kBeatFourFour; } + // VARIATION: A or B. Anchor: 0 is the factory value and the printed default. inline constexpr std::array kVariations { { { 0, "A" }, { 1, "B" } } }; @@ -195,7 +219,9 @@ inline std::optional patternLostOnOff(std::string_view slotBody) // Everything the card shows, read in one go. Tone values are the screen's. struct Values { bool on; - long long pattern; // an index into kPatterns — Blank included, it is what the file says + long long pattern; // what the file says, at any beat — Blank included + bool patternCharted; // the beat's list is charted, so `pattern` has a name: + // 4/4 only (#149). Elsewhere it is a number to show. long long kit; long long beat; long long variation; @@ -210,10 +236,12 @@ struct Values { inline Values read(std::string_view slotBody) { + const long long beat = detail::rhythm(slotBody, "Beat"); return { isOn(slotBody), detail::rhythm(slotBody, "Pattern"), + patternListCharted(beat), detail::rhythm(slotBody, "Kit"), - detail::rhythm(slotBody, "Beat"), + beat, detail::rhythm(slotBody, "Variation"), detail::rhythm(slotBody, "Level"), detail::rhythm(slotBody, "Reverb"), @@ -222,25 +250,81 @@ inline Values read(std::string_view slotBody) beatLocked(slotBody) }; } +// --- the beat's list, or none --- + +namespace detail { + + // The fact behind every refusal below. Each sentence names the beat as + // the screen prints it ("6/4"): the number alone would send the player + // to count the manual's list. + inline constexpr std::string_view kOnlyFourFourCharted + = "only the 4/4 list is charted (hardware, 2026-10-01)"; + + // A PATTERN number is chosen from the current beat's list, so it can be + // chosen only where that list is known. + inline void requirePatternCharted(long long beat) + { + if (!patternListCharted(beat)) + throw Error("PATTERN cannot be chosen at " + std::string(beatName(beat)) + ": " + + std::string(kOnlyFourFourCharted)); + } + +} // namespace detail + // --- the switch --- +// Why switching the drums to `on` is refused at this memory's beat, or +// nothing. Two of the switch's paths write a PATTERN number: Blank becomes +// the default on the way on, the groove becomes Blank on the way off with a +// count-in to keep. Neither number is known outside the 4/4 list, so at an +// uncharted beat those two clicks are refused; State alone is still ours. +// Pure over what the card shows, so the tab makes the switch a lamp before +// the click and apply() refuses after it with the same sentence. +inline std::optional switchRefusal(const Values& values, bool countInOn, bool on) +{ + if (patternListCharted(values.beat) || on == values.on) + return std::nullopt; + const std::string beat(beatName(values.beat)); + const std::string fact(detail::kOnlyFourFourCharted); + if (on && values.pattern == rc0::kRhythmPatternBlank) + return "Switching on at " + beat + " needs a groove in place of Blank, and " + fact + + ": choose a pattern on the pedal first."; + if (!on && countInOn) + return "Switching off at " + beat + " with the count-in on needs Blank's number, and " + + fact + ": switch the count-in off first."; + return std::nullopt; +} + +namespace detail { + + // The switch's bytes, trusted: the caller has asked switchRefusal first. + inline std::string switchBytes(std::string body, bool on) + { + if (on == isOn(body)) + return body; + if (on) { + if (rhythm(body, "Pattern") == rc0::kRhythmPatternBlank) + body = setRhythm(body, "Pattern", kPatternDefault); + return setRhythm(body, "State", rc0::kRhythmStateOn); + } + if (countin::isOn(body)) + return setRhythm(body, "Pattern", rc0::kRhythmPatternBlank); + return setRhythm(body, "State", 0); + } + +} // namespace detail + // Switch the drums for one slot body. On: State on, and a groove where there // was Blank — the printed default, since the silence was never a choice of // groove. Off: State off, unless the count-in needs it, in which case the -// groove becomes Blank. Already there is already there: nothing moves. +// groove becomes Blank. Already there is already there: nothing moves. At an +// uncharted beat the two paths that would write a pattern number are refused +// before any byte moves (switchRefusal). inline std::string applySwitch(std::string_view slotBody, bool on) { - std::string body(slotBody); - if (on == isOn(body)) - return body; - if (on) { - if (detail::rhythm(body, "Pattern") == rc0::kRhythmPatternBlank) - body = detail::setRhythm(body, "Pattern", kPatternDefault); - return detail::setRhythm(body, "State", rc0::kRhythmStateOn); - } - if (countin::isOn(body)) - return detail::setRhythm(body, "Pattern", rc0::kRhythmPatternBlank); - return detail::setRhythm(body, "State", 0); + if (const auto why = switchRefusal(read(slotBody), countin::isOn(slotBody), on)) + throw Error(*why); + return detail::switchBytes(std::string(slotBody), on); } // --- edits --- @@ -266,27 +350,40 @@ struct Edits { }; // Apply an edit to a slot body. Every present field is checked before the -// first byte moves — against the manual's lists and ranges, and against the -// slot itself for BEAT — so a refused edit leaves the body untouched. An -// empty edit is a caller bug, not a no-op. +// first byte moves — against the manual's lists and ranges, against the +// slot itself for BEAT, and against the beat the edit leaves the memory at +// for PATTERN and the switch — so a refused edit leaves the body untouched. +// An empty edit is a caller bug, not a no-op. inline std::string apply(std::string_view slotBody, const Edits& edits) { if (edits.empty()) throw Error("no rhythm setting to change"); + if (edits.beat) { + beatName(*edits.beat); + if (beatLocked(slotBody)) + throw Error("BEAT cannot be changed once a track is recorded (RC-5 reference " + "manual p. 10)"); + } + // PATTERN is chosen from the list of the beat the memory will be at once + // this edit lands — its own, or the one the edit carries — and so is the + // switch's own pattern write. A pattern together with a move to 4/4 is + // fine; a pattern together with a move away from it is not. + const long long beat = edits.beat ? *edits.beat : detail::rhythm(slotBody, "Beat"); if (edits.pattern) { + detail::requirePatternCharted(beat); patternName(*edits.pattern); if (*edits.pattern == rc0::kRhythmPatternBlank) throw Error("Blank is the count-in's silence, not a groove: switch the rhythm off " "instead"); } + if (edits.on) { + Values after = read(slotBody); + after.beat = beat; + if (const auto why = switchRefusal(after, countin::isOn(slotBody), *edits.on)) + throw Error(*why); + } if (edits.kit) kitName(*edits.kit); - if (edits.beat) { - beatName(*edits.beat); - if (beatLocked(slotBody)) - throw Error("BEAT cannot be changed once a track is recorded (RC-5 reference " - "manual p. 10)"); - } if (edits.variation) variationName(*edits.variation); if (edits.level) @@ -300,7 +397,7 @@ inline std::string apply(std::string_view slotBody, const Edits& edits) std::string body(slotBody); if (edits.on) - body = applySwitch(body, *edits.on); + body = detail::switchBytes(std::move(body), *edits.on); if (edits.pattern) body = detail::setRhythm(body, "Pattern", *edits.pattern); if (edits.kit) @@ -322,9 +419,17 @@ inline std::string apply(std::string_view slotBody, const Edits& edits) // The edit in the pedal's own words, for the history and the banner: // "switched on", "pattern Rock1, kit Jazz", "tone low -3". Names are looked -// up, so a number outside a list fails here as loudly as in apply(). -inline std::string describe(const Edits& edits) +// up, so a number outside a list fails here as loudly as in apply(). `beat` +// is the memory's own: a pattern is named from the list of the beat the +// edit leaves the memory at, and at an uncharted beat it is refused here +// too — the words are made before the job runs, and a history row must not +// carry a 4/4 name for a 6/4 number. +inline std::string describe(const Edits& edits, long long beat) { + if (edits.beat) + beat = *edits.beat; + if (edits.pattern) + detail::requirePatternCharted(beat); std::string out; const auto add = [&out](std::string_view text) { if (!out.empty()) diff --git a/tests/usecase_rhythm_tests.cpp b/tests/usecase_rhythm_tests.cpp index 5173ae6..43d682b 100644 --- a/tests/usecase_rhythm_tests.cpp +++ b/tests/usecase_rhythm_tests.cpp @@ -17,6 +17,11 @@ // range is refused before any byte moves. // 5. BEAT cannot be changed once a track is recorded (p. 10, stated). // 6. The RHYTHM section is the address: the same tag elsewhere is nobody's. +// 7. PATTERN indexes the list of the memory's CURRENT BEAT, and only the +// 4/4 list is charted (hardware, 2026-10-01: Rock2 is 12 at 4/4 and 3 +// at 6/4). At any other beat the number is read and never named, no +// edit writes , and a refusal names the beat as the screen +// prints it — "6/4", not 4. #include "support.hpp" @@ -33,6 +38,12 @@ namespace { constexpr long long kSomeGroove = 11; // Rock1 in the manual's order: neither Blank nor factory 0 + // The hardware pairing of #147: one groove, two numbers, one per beat. + constexpr long long kFourFour = 2; // Beat 2 reads 4/4 on the screen + constexpr long long kSixFour = 4; // Beat 4 reads 6/4 on the screen (2026-10-01) + constexpr long long kRock2At44 = 12; // Rock2 chosen on the pedal at 4/4 is stored as 12 + constexpr long long kRock2At64 = 3; // the same Rock2 at 6/4 is stored as 3 + std::string bodyWith(long long state, long long playCount, long long pattern) { std::string body = testkit::syntheticSlotBody(); @@ -63,11 +74,21 @@ namespace { return rc0::sectionField(body, rc0::kSectionRhythm, tag); } + + // The same body at 6/4, holding `pattern` — a number from the 6/4 list, + // which we cannot name. + std::string atSixFour(std::string body, long long pattern) + { + body = rc0::setSectionField(body, rc0::kSectionRhythm, "Beat", kSixFour); + return rc0::setSectionField(body, rc0::kSectionRhythm, "Pattern", pattern); + } } int main() { const std::string factory = testkit::syntheticSlotBody(); + // Memory 3 as the pedal wrote it (#147): 6/4, Rock2, which the card stores as 3. + const std::string sixFourRock2 = atSixFour(factory, kRock2At64); // --- the lists are the manual's, counted from zero --- @@ -423,16 +444,248 @@ int main() CHECK_THROWS(rhythm::apply(noRhythm, { .on = true }), "missing "); } - // --- the words the history gets --- + // --- the words the history gets (at 4/4, the memory's own beat) --- + + CHECK_EQ(rhythm::describe({ .on = true }, kFourFour), "switched on"); + CHECK_EQ(rhythm::describe({ .on = false }, kFourFour), "switched off"); + CHECK_EQ(rhythm::describe({ .pattern = kSomeGroove, .kit = 2 }, kFourFour), + "pattern Rock1, kit Jazz"); + CHECK_EQ(rhythm::describe({ .beat = 6, .variation = 1 }, kFourFour), "beat 5/8, variation B"); + CHECK_EQ(rhythm::describe({ .level = 120, .reverb = 0 }, kFourFour), "level 120, reverb 0"); + CHECK_EQ(rhythm::describe({ .toneLow = -3, .toneHigh = 2 }, kFourFour), + "tone low -3, tone high +2"); + CHECK_EQ(rhythm::describe({ .toneHigh = 0 }, kFourFour), "tone high 0"); + CHECK_THROWS(rhythm::describe({ .pattern = 99 }, kFourFour), "PATTERN 99"); + + // --- PATTERN is an index into the current BEAT's list; only 4/4 is charted --- + + // The hardware pairing (#147): Rock2 is stored as 12 at 4/4 and as 3 at + // 6/4. 3 is inside the 4/4 list, where it reads SimpleBeat4, so the + // range says nothing — only the beat does. + CHECK_EQ(rhythm::beatName(kSixFour), "6/4"); + CHECK_EQ(rhythm::patternName(kRock2At44), "Rock2"); + CHECK_EQ(rhythm::kBeatFourFour, kFourFour); + CHECK(rhythm::patternListCharted(kFourFour)); + for (const rhythm::Choice& b : rhythm::kBeats) + CHECK_EQ(rhythm::patternListCharted(b.number), b.number == kFourFour); + // A beat outside the list is not charted either — and not an error here: + // the memory must still be readable. Naming is what fails (below). + CHECK(!rhythm::patternListCharted(17)); + CHECK(!rhythm::patternListCharted(-1)); + + // Reading a 6/4 memory: the number as the card holds it, and the word + // that it cannot be named. No throw, whatever the number. + { + const rhythm::Values v = rhythm::read(sixFourRock2); + CHECK_EQ(v.pattern, kRock2At64); + CHECK_EQ(v.beat, kSixFour); + CHECK(!v.patternCharted); + CHECK(rhythm::read(factory).patternCharted); + // The 19 the pedal could not name, and a number outside even the + // 4/4 list: read, not named. + CHECK_EQ(rhythm::read(atSixFour(factory, 19)).pattern, 19); + CHECK(!rhythm::read(atSixFour(factory, 19)).patternCharted); + CHECK_EQ(rhythm::read(atSixFour(factory, 99)).pattern, 99); + CHECK(!rhythm::read(atSixFour(factory, 99)).patternCharted); + // A 4/4 memory at the same numbers is charted as before. + CHECK(rhythm::read(rc0::setSectionField(factory, rc0::kSectionRhythm, "Pattern", 3)) + .patternCharted); + } + // The table's read model carries the same word, for the tab to act on. + { + std::string text = testkit::syntheticMemoryText(); + text = rc0::replaceSlotBody(text, 3, sixFourRock2); + CHECK(!catalog::readSlot(text, 3).rhythm.patternCharted); + CHECK_EQ(catalog::readSlot(text, 3).rhythm.pattern, kRock2At64); + CHECK(catalog::readSlot(text, 4).rhythm.patternCharted); + } + + // Writing a pattern at 6/4 is refused before any byte moves, naming the + // beat as the screen prints it — even for the number the pedal itself + // stored there, since it is OUR list the number would be checked against. + { + std::string after; + CHECK_THROWS(after = rhythm::apply(sixFourRock2, { .pattern = kRock2At44 }), + "PATTERN cannot be chosen at 6/4"); + CHECK(after.empty()); + CHECK_THROWS(after = rhythm::apply(sixFourRock2, { .pattern = kRock2At64 }), "6/4"); + CHECK(after.empty()); + CHECK_THROWS(after = rhythm::apply(sixFourRock2, { .pattern = 0 }), "4/4 list"); + CHECK(after.empty()); + try { + rhythm::apply(sixFourRock2, { .pattern = kRock2At44 }); + CHECK(false); + } catch (const Error& e) { + const std::string what = e.what(); + CHECK(what.find("6/4") != std::string::npos); + CHECK(what.find("at 4") == std::string::npos); // the number is not the word + } + // One bad field spoils the whole edit here too: the good one does not land. + CHECK_THROWS(after = rhythm::apply(sixFourRock2, { .pattern = kRock2At44, .kit = 3 }), + "6/4"); + CHECK(after.empty()); + } + // Every other field of the card is still the player's at 6/4 (#149: + // nothing suggests kit, level, reverb, tone or variation are + // beat-relative — at 6/4 the pedal took Kit 6 and showed it). The + // pattern bytes are reproduced exactly. + { + const std::string after = rhythm::apply( + sixFourRock2, { .kit = 6, .variation = 1, .level = 90, .reverb = 40, .toneLow = -2, + .toneHigh = 3 }); + CHECK_EQ(rhythmField(after, "Pattern"), kRock2At64); + CHECK_EQ(rhythm::read(after).kit, 6); + CHECK(onlyTheseFieldsMoved(sixFourRock2, after, + { "Kit", "Variation", "Level", "Reverb", "ToneLow", + "ToneHigh" })); + } + + // BEAT moves on its own; the pattern bytes stay exactly as they were. + { + const std::string fourFourRock2 + = rc0::setSectionField(factory, rc0::kSectionRhythm, "Pattern", kRock2At44); + // 4/4 -> 6/4: allowed without a take, and 12 stays 12 — now a 6/4 + // number we cannot name, but the pedal's to resolve, not ours to guess. + const std::string away = rhythm::apply(fourFourRock2, { .beat = kSixFour }); + CHECK_EQ(rhythm::read(away).beat, kSixFour); + CHECK_EQ(rhythmField(away, "Pattern"), kRock2At44); + CHECK(!rhythm::read(away).patternCharted); + CHECK(onlyTheseFieldsMoved(fourFourRock2, away, { "Beat" })); + // 6/4 -> 7/4: uncharted to uncharted, the pattern still untouched. + const std::string onward = rhythm::apply(sixFourRock2, { .beat = 5 }); + CHECK_EQ(rhythmField(onward, "Pattern"), kRock2At64); + CHECK(onlyTheseFieldsMoved(sixFourRock2, onward, { "Beat" })); + // 6/4 -> 4/4: the number is a 4/4 number again, named from the list. + const std::string back = rhythm::apply(sixFourRock2, { .beat = kFourFour }); + CHECK(rhythm::read(back).patternCharted); + CHECK_EQ(rhythmField(back, "Pattern"), kRock2At64); + CHECK(onlyTheseFieldsMoved(sixFourRock2, back, { "Beat" })); + } + // A pattern is checked against the beat the edit LEAVES the memory at. + { + // 6/4 -> 4/4 together with Rock2: allowed, checked against 4/4. + const std::string after + = rhythm::apply(sixFourRock2, { .pattern = kRock2At44, .beat = kFourFour }); + CHECK_EQ(rhythm::read(after).beat, kFourFour); + CHECK_EQ(rhythmField(after, "Pattern"), kRock2At44); + CHECK(rhythm::read(after).patternCharted); + CHECK(onlyTheseFieldsMoved(sixFourRock2, after, { "Beat", "Pattern" })); + // 4/4 -> 6/4 together with a pattern: refused, against the new beat. + std::string refused; + CHECK_THROWS(refused = rhythm::apply(factory, { .pattern = kRock2At44, .beat = kSixFour }), + "PATTERN cannot be chosen at 6/4"); + CHECK(refused.empty()); + // Under a take the move to 4/4 is the manual's refusal, and the + // pattern does not land either way. + CHECK_THROWS(refused = rhythm::apply(withTake(sixFourRock2), + { .pattern = kRock2At44, .beat = kFourFour }), + "BEAT cannot be changed"); + CHECK(refused.empty()); + } + // A beat outside the list is still the existing typed error, at any beat. + CHECK_THROWS(rhythm::apply(sixFourRock2, { .beat = 17 }), "BEAT 17"); + CHECK_THROWS(rhythm::apply(sixFourRock2, { .beat = -1 }), "BEAT -1"); + + // --- the switch at 6/4: State alone is ours, a pattern number is not --- + + // Blank's number and the default groove are 4/4 facts (57 and 0), so the + // two paths that write them are refused; the two that move State alone + // pass. The same through apply({.on}). + { + // Off over a groove with no count-in: State alone — passes. + const std::string on = atSixFour(bodyWith(rc0::kRhythmStateOn, 0, kRock2At64), kRock2At64); + const std::string off = rhythm::applySwitch(on, false); + CHECK(!rhythm::isOn(off)); + CHECK_EQ(rhythmField(off, "Pattern"), kRock2At64); + CHECK(onlyTheseFieldsMoved(on, off, { "State" })); + CHECK(rhythm::apply(on, { .on = false }) == off); + CHECK(!rhythm::switchRefusal(rhythm::read(on), false, false).has_value()); + // On over a chosen-but-silent groove: State alone — passes. + const std::string silent = atSixFour(bodyWith(0, 0, kRock2At64), kRock2At64); + const std::string loud = rhythm::applySwitch(silent, true); + CHECK(rhythm::isOn(loud)); + CHECK_EQ(rhythmField(loud, "Pattern"), kRock2At64); + CHECK(onlyTheseFieldsMoved(silent, loud, { "State" })); + CHECK(rhythm::apply(silent, { .on = true }) == loud); + // The 19 the pedal could not name is still a groove to switch on + // under: State alone, the number untouched. + const std::string nineteen = atSixFour(bodyWith(0, 0, 19), 19); + CHECK_EQ(rhythmField(rhythm::applySwitch(nineteen, true), "Pattern"), 19); + // Already there is already there, at any beat: no throw, nothing moves. + CHECK(rhythm::applySwitch(on, true) == on); + CHECK(rhythm::applySwitch(silent, false) == silent); + } + { + // On over Blank: the default groove would be written — refused, with + // the beat as the screen prints it and what the player should do. + const std::string blankOff = atSixFour(bodyWith(0, 0, rc0::kRhythmPatternBlank), + rc0::kRhythmPatternBlank); + std::string after; + CHECK_THROWS(after = rhythm::applySwitch(blankOff, true), "6/4"); + CHECK(after.empty()); + CHECK_THROWS(after = rhythm::applySwitch(blankOff, true), "4/4 list"); + CHECK_THROWS(after = rhythm::applySwitch(blankOff, true), "on the pedal"); + CHECK_THROWS(after = rhythm::apply(blankOff, { .on = true }), "6/4"); + CHECK(after.empty()); + CHECK(rhythm::switchRefusal(rhythm::read(blankOff), false, true).has_value()); + // The count-in's silence (State on, Blank) is the same refusal on the + // way on, and nothing at all on the way off: it is already off. + const std::string countSilence + = atSixFour(bodyWith(rc0::kRhythmStateOn, rc0::kRhythmPlayCount1Meas, + rc0::kRhythmPatternBlank), + rc0::kRhythmPatternBlank); + CHECK_THROWS(after = rhythm::applySwitch(countSilence, true), "6/4"); + CHECK(after.empty()); + CHECK(rhythm::applySwitch(countSilence, false) == countSilence); + // Off with the count-in on: Blank would be written — refused. + const std::string counted = atSixFour( + bodyWith(rc0::kRhythmStateOn, rc0::kRhythmPlayCount1Meas, kRock2At64), kRock2At64); + CHECK(rhythm::isOn(counted)); + CHECK(usecases::countin::isOn(counted)); + CHECK_THROWS(after = rhythm::applySwitch(counted, false), "6/4"); + CHECK(after.empty()); + CHECK_THROWS(after = rhythm::applySwitch(counted, false), "count-in"); + CHECK_THROWS(after = rhythm::apply(counted, { .on = false }), "6/4"); + CHECK(after.empty()); + CHECK(rhythm::switchRefusal(rhythm::read(counted), true, false).has_value()); + // The same memory with the count-in off is State alone again. + CHECK(!rhythm::switchRefusal(rhythm::read(counted), false, false).has_value()); + // Together with a move to 4/4 the numbers are 4/4 numbers: both pass. + const std::string onAtFourFour = rhythm::apply(blankOff, { .on = true, .beat = kFourFour }); + CHECK(rhythm::isOn(onAtFourFour)); + CHECK_EQ(rhythm::read(onAtFourFour).beat, kFourFour); + CHECK_EQ(rhythmField(onAtFourFour, "Pattern"), rhythm::kPatternDefault); + const std::string offAtFourFour = rhythm::apply(counted, { .on = false, .beat = kFourFour }); + CHECK(!rhythm::isOn(offAtFourFour)); + CHECK(usecases::countin::isOn(offAtFourFour)); + CHECK_EQ(rhythmField(offAtFourFour, "Pattern"), rc0::kRhythmPatternBlank); + // And a move away from 4/4 together with the switch is refused the + // same way, against the new beat. + CHECK_THROWS(after = rhythm::apply(bodyWith(0, 0, rc0::kRhythmPatternBlank), + { .on = true, .beat = kSixFour }), + "6/4"); + CHECK(after.empty()); + } + // At 4/4 the switch never has a refusal to give, whatever it holds. + CHECK(!rhythm::switchRefusal(rhythm::read(factory), false, true).has_value()); + CHECK(!rhythm::switchRefusal(rhythm::read(bodyWith(0, 0, rc0::kRhythmPatternBlank)), false, + true) + .has_value()); + CHECK(!rhythm::switchRefusal(rhythm::read(bodyWith(rc0::kRhythmStateOn, + rc0::kRhythmPlayCount1Meas, kSomeGroove)), + true, false) + .has_value()); + + // --- the words at 6/4: a number is never given a 4/4 name --- - CHECK_EQ(rhythm::describe({ .on = true }), "switched on"); - CHECK_EQ(rhythm::describe({ .on = false }), "switched off"); - CHECK_EQ(rhythm::describe({ .pattern = kSomeGroove, .kit = 2 }), "pattern Rock1, kit Jazz"); - CHECK_EQ(rhythm::describe({ .beat = 6, .variation = 1 }), "beat 5/8, variation B"); - CHECK_EQ(rhythm::describe({ .level = 120, .reverb = 0 }), "level 120, reverb 0"); - CHECK_EQ(rhythm::describe({ .toneLow = -3, .toneHigh = 2 }), "tone low -3, tone high +2"); - CHECK_EQ(rhythm::describe({ .toneHigh = 0 }), "tone high 0"); - CHECK_THROWS(rhythm::describe({ .pattern = 99 }), "PATTERN 99"); + CHECK_THROWS(rhythm::describe({ .pattern = kRock2At44 }, kSixFour), "6/4"); + CHECK_THROWS(rhythm::describe({ .pattern = kRock2At64 }, kSixFour), + "PATTERN cannot be chosen at 6/4"); + CHECK_THROWS(rhythm::describe({ .pattern = kRock2At44, .beat = kSixFour }, kFourFour), "6/4"); + CHECK_EQ(rhythm::describe({ .pattern = kRock2At44, .beat = kFourFour }, kSixFour), + "pattern Rock2, beat 4/4"); + CHECK_EQ(rhythm::describe({ .beat = kSixFour }, kSixFour), "beat 6/4"); + CHECK_EQ(rhythm::describe({ .on = true, .kit = 2 }, kSixFour), "switched on, kit Jazz"); return testkit::summary("usecase_rhythm_tests"); } From 2b37f8a2f59d908bb1e7c65685b6200e88e126bb Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Sun, 4 Oct 2026 03:03:55 +0200 Subject: [PATCH 2/7] Rhythm tab: PATTERN shows its number at a beat whose list is not charted 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 " 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. --- app/MainComponent.cpp | 15 ++-- app/MainComponent.h | 2 +- app/RhythmPane.cpp | 38 ++++++++-- app/RhythmPane.h | 4 +- tests/rhythm_pane_harness.cpp | 139 ++++++++++++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 14 deletions(-) diff --git a/app/MainComponent.cpp b/app/MainComponent.cpp index 7a7b5e3..f110a0e 100644 --- a/app/MainComponent.cpp +++ b/app/MainComponent.cpp @@ -351,8 +351,8 @@ MainComponent::MainComponent(std::string explicitVolume, juce::File dataOverride toggleCountIn(slot, row->info.countIn); }; rhythmPane.onEdit = [this](int slot, usecases::rhythm::Edits edits) { - if (!pedalBusy && slotRowFor(slot) != nullptr) - editRhythm(slot, std::move(edits)); + if (const SlotRow* row = pedalBusy ? nullptr : slotRowFor(slot)) + editRhythm(slot, row->info.rhythm.beat, std::move(edits)); }; inspector.onPlayStopEdited = [this](int slot, usecases::playstop::Edits edits) { if (!pedalBusy && slotRowFor(slot) != nullptr) @@ -2013,10 +2013,16 @@ void MainComponent::toggleCountIn(int slot, bool currentlyOn) // screen's fields. The change rides as the job's note, in the card's own // words (usecases::rhythm::describe): the banner reads "Rhythm on slot 7 — // kit Jazz", and the history row keeps the same words, not "field 5 = 2". -void MainComponent::editRhythm(int slot, usecases::rhythm::Edits edits) +// The words are made here, before the job is enqueued and before the +// history opens its row, from the memory's beat as the table shows it: a +// pattern at a beat whose list is not charted is refused at this line, and +// no row is written for it (#149). The card itself is checked again on the +// worker, against the beat it holds then. +void MainComponent::editRhythm(int slot, long long beat, usecases::rhythm::Edits edits) { if (!CardPermissions::of(snapshot.family).rhythm) return; + auto note = std::make_shared(utf8(usecases::rhythm::describe(edits, beat))); const auto options = makeWriteOptions(); worker.enqueue(recorded("rhythm", options, { "Rhythm on slot " + juce::String(slot), @@ -2024,8 +2030,7 @@ void MainComponent::editRhythm(int slot, usecases::rhythm::Edits edits) [slot, edits, options](const volume::fs::path& volumePath) { commands::setRhythm(volumePath, slot, edits, options); }, - std::make_shared( - utf8(usecases::rhythm::describe(edits))) })); + std::move(note) })); } // One job per change on the Start & Stop card, the twin of editRhythm: the diff --git a/app/MainComponent.h b/app/MainComponent.h index 51cf0a2..dc113c7 100644 --- a/app/MainComponent.h +++ b/app/MainComponent.h @@ -273,7 +273,7 @@ class MainComponent final : public juce::Component, std::vector hashes); void toggleOneShot(int slot, bool currentlyOn); void toggleCountIn(int slot, bool currentlyOn); - void editRhythm(int slot, usecases::rhythm::Edits edits); + void editRhythm(int slot, long long beat, usecases::rhythm::Edits edits); void editPlayStop(int slot, usecases::playstop::Edits edits); void releasePlayerIfHolding(int slotA, int slotB); void choosePushWav(int slot, bool slotOccupied); diff --git a/app/RhythmPane.cpp b/app/RhythmPane.cpp index 6a2ba01..fe1b572 100644 --- a/app/RhythmPane.cpp +++ b/app/RhythmPane.cpp @@ -187,7 +187,21 @@ void RhythmControls::setValues(const rhythm::Values& values) void RhythmControls::refresh() { - pattern_.setSelectedId(static_cast(values_.pattern) + 1, juce::dontSendNotification); + // PATTERN is an index into the current BEAT's list, and only the 4/4 + // list is charted (Rhythm.hpp, #149): elsewhere the box shows the number + // the card holds — as text, with no item behind it — and is a lamp, in + // the shape BEAT takes under a take. A 4/4 name for a 6/4 number is the + // one thing this tab must never show. + if (values_.patternCharted) + pattern_.setSelectedId(static_cast(values_.pattern) + 1, juce::dontSendNotification); + else + pattern_.setText(juce::String(values_.pattern), juce::dontSendNotification); + pattern_.setEnabled(isEnabled() && values_.patternCharted); + patternCaption_.setText(values_.patternCharted + ? juce::String("PATTERN") + : "PATTERN (list at " + name(rhythm::beatName(values_.beat)) + + " not charted)", + juce::dontSendNotification); kit_.setSelectedId(static_cast(values_.kit) + 1, juce::dontSendNotification); beat_.setSelectedId(static_cast(values_.beat) + 1, juce::dontSendNotification); variation_.setSelectedId(static_cast(values_.variation) + 1, juce::dontSendNotification); @@ -214,11 +228,11 @@ void RhythmControls::refresh() // not have until the job ends. void RhythmControls::enablementChanged() { - for (auto* control : std::initializer_list { &pattern_, &kit_, &variation_, - &level_, &reverb_, &toneLow_, + for (auto* control : std::initializer_list { &kit_, &variation_, &level_, + &reverb_, &toneLow_, &toneHigh_ }) control->setEnabled(isEnabled()); - refresh(); // also re-applies BEAT's own lock + refresh(); // also re-applies BEAT's own lock, and PATTERN's at an uncharted beat } void RhythmControls::resized() @@ -319,9 +333,19 @@ void RhythmPane::refresh() const rhythm::Values& v = info_.rhythm; switch_.setState(v.on, v.on ? "Drums play with this memory." : "No drums with this memory."); - // The groove that "off" would forget: only with a count-in in front of - // playing drums does off mean Blank rather than State off (Rhythm.hpp). - cost_.setText(v.on && info_.countIn + // A click the core would refuse is not offered: at an uncharted beat the + // two clicks that write a PATTERN number make the switch a lamp, and the + // line beside it says why — the core's own sentence, so the tab and a + // banner can never disagree (Rhythm.hpp, switchRefusal). + const std::optional refused = rhythm::switchRefusal(v, info_.countIn, !v.on); + if (refused) + switch_.setEnabled(false); + // Otherwise the groove that "off" would forget: only with a count-in in + // front of playing drums does off mean Blank rather than State off + // (Rhythm.hpp). That click is refused at an uncharted beat, so the name + // is looked up only where the list is charted. + cost_.setText(refused ? name(*refused) + : v.on && info_.countIn ? "Switching it off keeps the count-in and forgets " + name(rhythm::patternName(v.pattern)) + "." : juce::String(), diff --git a/app/RhythmPane.h b/app/RhythmPane.h index 049a32c..548d235 100644 --- a/app/RhythmPane.h +++ b/app/RhythmPane.h @@ -58,7 +58,9 @@ class RhythmSwitch final : public juce::Component // The RHYTHM screen's eight fields, one control each, in the screen's order: // the choices above, the numbers below. Every control carries its field's // name as its component id (pattern, kit, beat, variation, level, reverb, -// toneLow, toneHigh). +// toneLow, toneHigh). Two of them lock themselves from the memory's own +// facts, caption saying why: BEAT under a recorded take, PATTERN at a beat +// whose list is not charted (Rhythm.hpp) — there it shows the number. class RhythmControls final : public juce::Component { public: diff --git a/tests/rhythm_pane_harness.cpp b/tests/rhythm_pane_harness.cpp index 2daacac..d24b3d3 100644 --- a/tests/rhythm_pane_harness.cpp +++ b/tests/rhythm_pane_harness.cpp @@ -16,6 +16,11 @@ // - a value typed equal to the slot's own is nothing; so is a re-selected // choice // - a slot with a take fixes BEAT and only BEAT +// - a slot at a beat whose pattern list is not charted (anything but 4/4, +// hardware 2026-10-01) shows PATTERN as the number the card holds, with +// the caption saying why, and only PATTERN is a lamp; the switch is a +// lamp exactly where its click would need a pattern number, and the +// line beside it says why before the click // - a busy tab is a lamp until the job is done; no slot means no controls #include "support.hpp" @@ -53,8 +58,38 @@ juce::MouseEvent clickOn(juce::Component& on) &on, &on, now, { 4.0f, 4.0f }, now, 1, false); } +// A caption somewhere under the pane with exactly this text. Captions sit +// inside the controls, so the search descends — and asks for the whole text, +// since a ComboBox keeps a Label of its own that reads "6/4" or "3". +bool captionReads(juce::Component& root, const juce::String& text) +{ + for (auto* child : root.getChildren()) { + if (auto* label = dynamic_cast(child)) + if (label->getText() == text) + return true; + if (captionReads(*child, text)) + return true; + } + return false; +} + +// The pane's own line (the orange cost, the footer): a direct child only, +// so a control's inner label cannot answer for it. +bool paneSays(juce::Component& pane, const juce::String& fragment) +{ + for (auto* child : pane.getChildren()) + if (auto* label = dynamic_cast(child)) + if (label->getText().contains(fragment)) + return true; + return false; +} + // The pane's rows: slot 7 with drums on (Rock1, Jazz), slot 8 with a take // recorded (BEAT fixed) and a count-in in front of its groove, slot 9 factory. +// Then four at 6/4 (Beat 4), the beat whose list the hardware showed is not +// ours (#147): slot 10 holds 3 — the pedal's own Rock2 at 6/4 — with drums +// on; slot 11 the same with a count-in in front; slot 12 holds 57, off; slot +// 13 holds 19 — the number the pedal could not name — off. std::vector rowsOf() { std::string text = testkit::syntheticMemoryText(); @@ -71,6 +106,15 @@ std::vector rowsOf() set(8, "RHYTHM", "State", rc0::kRhythmStateOn); set(8, "RHYTHM", "Pattern", 45); set(8, "RHYTHM", "PlayCount", rc0::kRhythmPlayCount1Meas); + for (int slot = 10; slot <= 13; ++slot) + set(slot, "RHYTHM", "Beat", 4); + set(10, "RHYTHM", "State", rc0::kRhythmStateOn); + set(10, "RHYTHM", "Pattern", 3); + set(11, "RHYTHM", "State", rc0::kRhythmStateOn); + set(11, "RHYTHM", "Pattern", 3); + set(11, "RHYTHM", "PlayCount", rc0::kRhythmPlayCount1Meas); + set(12, "RHYTHM", "Pattern", rc0::kRhythmPatternBlank); + set(13, "RHYTHM", "Pattern", 19); std::vector rows; for (auto& info : catalog::listSlots(text)) rows.push_back({ std::move(info), "", "", { "" } }); @@ -294,6 +338,101 @@ int main() CHECK_EQ(kit->getText(), "Studio"); CHECK_EQ(level->getText(), "100"); + // --- a 6/4 slot: PATTERN is the card's number and a lamp; the rest is the player's --- + pane.setSlot(&rows[9]); + CHECK(!pattern->isEnabled()); + CHECK_EQ(pattern->getSelectedId(), 0); // no item behind the text: 3 is not SimpleBeat4 here + CHECK_EQ(pattern->getText(), "3"); + CHECK(captionReads(pane, "PATTERN (list at 6/4 not charted)")); + CHECK(!captionReads(pane, "PATTERN")); + CHECK(beat->isEnabled() && kit->isEnabled() && variation->isEnabled() && level->isEnabled() + && reverb->isEnabled() && toneLow->isEnabled() && toneHigh->isEnabled()); + CHECK_EQ(beat->getText(), "6/4"); + CHECK(captionReads(pane, "BEAT")); + // The lamp reports nothing and keeps nothing: a pick made past its greyed + // face snaps back to the card's number. + choose(*pattern, 12); + CHECK_EQ(heard.calls, 10); + CHECK_EQ(pattern->getText(), "3"); + CHECK_EQ(pattern->getSelectedId(), 0); + // Off here is State alone (no count-in to keep): the switch is offered, + // and no name is said for the number. + CHECK(toggle->isEnabled() && toggle->isOn()); + CHECK(!paneSays(pane, "forgets")); + CHECK(!paneSays(pane, "6/4")); + press(); + CHECK_EQ(heard.calls, 11); + CHECK_EQ(heard.slot, 10); + CHECK(heard.last.on.has_value() && !*heard.last.on); + CHECK_EQ(fieldsSet(heard.last), 1); + choose(*kit, 5); // KIT is not beat-relative (#149): still one Edits with one field + CHECK_EQ(heard.calls, 12); + CHECK(heard.last.kit.has_value() && *heard.last.kit == 5); + CHECK_EQ(fieldsSet(heard.last), 1); + choose(*beat, 2); // and the way out: BEAT itself is still the player's + CHECK_EQ(heard.calls, 13); + CHECK(heard.last.beat.has_value() && *heard.last.beat == 2); + + // --- 6/4 with a count-in in front: off would write Blank's number — a lamp, and the line says why --- + pane.setSlot(&rows[10]); + CHECK(toggle->isOn()); + CHECK(!toggle->isEnabled()); + CHECK(paneSays(pane, "6/4")); + CHECK(paneSays(pane, "count-in")); + CHECK(!paneSays(pane, "forgets")); // no 4/4 name for a 6/4 number + CHECK(!paneSays(pane, "SimpleBeat4")); + press(); + CHECK_EQ(heard.calls, 13); + CHECK(kit->isEnabled() && !pattern->isEnabled()); // only the switch and PATTERN are lamps + + // --- 6/4 holding 57, off: on would write the 4/4 default — a lamp --- + pane.setSlot(&rows[11]); + CHECK(!toggle->isOn()); + CHECK(!toggle->isEnabled()); + CHECK(paneSays(pane, "6/4")); + CHECK(paneSays(pane, "on the pedal")); + CHECK_EQ(pattern->getText(), "57"); + CHECK(!pattern->isEnabled()); + press(); + CHECK_EQ(heard.calls, 13); + + // --- 6/4 holding 19, off: on is State alone — offered, the number shown --- + pane.setSlot(&rows[12]); + CHECK(!toggle->isOn()); + CHECK(toggle->isEnabled()); + CHECK(!paneSays(pane, "6/4")); + CHECK_EQ(pattern->getText(), "19"); + press(); + CHECK_EQ(heard.calls, 14); + CHECK_EQ(heard.slot, 13); + CHECK(heard.last.on.has_value() && *heard.last.on); + + // --- busy at 6/4 and back: the PATTERN lock is re-applied, like BEAT's --- + pane.setBusy(true); + CHECK(allLamps()); + choose(*pattern, 12); + CHECK_EQ(heard.calls, 14); + CHECK_EQ(pattern->getText(), "19"); + pane.setBusy(false); + CHECK(toggle->isEnabled() && kit->isEnabled() && beat->isEnabled()); + CHECK(!pattern->isEnabled()); + CHECK_EQ(pattern->getText(), "19"); + CHECK_EQ(pattern->getSelectedId(), 0); + CHECK(captionReads(pane, "PATTERN (list at 6/4 not charted)")); + + // --- back at 4/4: everything as before, the name on the box again --- + pane.setSlot(&rows[6]); + CHECK(pattern->isEnabled()); + CHECK_EQ(pattern->getSelectedId(), 12); + CHECK_EQ(pattern->getText(), "Rock1"); + CHECK(captionReads(pane, "PATTERN")); + CHECK(!captionReads(pane, "PATTERN (list at 6/4 not charted)")); + CHECK(toggle->isEnabled()); + CHECK(!paneSays(pane, "6/4")); + choose(*pattern, 12); + CHECK_EQ(heard.calls, 15); + CHECK(heard.last.pattern.has_value() && *heard.last.pattern == 12); + // --- no slot again: the controls go away --- pane.setSlot(nullptr); CHECK(!toggle->isVisible() && !pattern->isShowing()); From 710f11da063e26423c6511f5c991b55d11942238 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Sun, 4 Oct 2026 03:03:55 +0200 Subject: [PATCH 3/7] Atlas: PATTERN indexes the current BEAT's list, and only 4/4 is charted 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. --- docs/pedal-settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pedal-settings.md b/docs/pedal-settings.md index 80ab990..9907250 100644 --- a/docs/pedal-settings.md +++ b/docs/pedal-settings.md @@ -76,7 +76,7 @@ unplugged the pedal folds its own output to mono, which hardware confirmed. | `State` | Rhythm on/off for this memory | 0/1 | [M] | | `Level` | Drum level | 0–200 | [M] | | `Reverb` | Drum reverb send | 0–100, factory 30 | [M] | -| `Pattern` | Rhythm pattern | enum 0–56 (57 patterns) | [M] map to verify | +| `Pattern` | Rhythm pattern — an index into the list of the memory's **current `Beat`**, not into one global list | at 4/4: enum 0–56 (the manual's 57 patterns) + 57 = Blank; Rock2 is 12 at 4/4 and 3 at 6/4; every other beat's list uncharted (length, order, Blank) | [V] 4/4 only (hardware 2026-10-01) | | `Variation` | Variation A/B | 0/1 | [M] | | `VariationChange` | When A↔B switches | measure / loop end | [M] | | `Kit` | Drum kit | enum (Studio, Live, Rock, Jazz, Brush, Cajon…) | [M] map to verify | From f872678911f4d3da109f77161b0a29995c5bcca3 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Sun, 4 Oct 2026 16:50:01 +0200 Subject: [PATCH 4/7] The count-in writes no pattern number outside 4/4 either Review of the beat lock found the hole it left open: Play Count-In has two paths of its own that write - 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 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. --- core/include/loopercat/Catalog.hpp | 10 +- core/include/loopercat/usecases/Beat.hpp | 77 ++++++++++ core/include/loopercat/usecases/CountIn.hpp | 71 ++++++++- core/include/loopercat/usecases/Rhythm.hpp | 129 +++++++++-------- tests/catalog_tests.cpp | 26 ++++ tests/commands_tests.cpp | 74 ++++++++++ tests/usecase_count_in_tests.cpp | 153 ++++++++++++++++++++ tests/usecase_rhythm_tests.cpp | 63 +++++++- 8 files changed, 538 insertions(+), 65 deletions(-) create mode 100644 core/include/loopercat/usecases/Beat.hpp diff --git a/core/include/loopercat/Catalog.hpp b/core/include/loopercat/Catalog.hpp index 7a962a0..f38a6c1 100644 --- a/core/include/loopercat/Catalog.hpp +++ b/core/include/loopercat/Catalog.hpp @@ -25,6 +25,7 @@ #include "usecases/PlayStop.hpp" #include "usecases/Rhythm.hpp" +#include #include #include #include @@ -66,6 +67,11 @@ struct SlotInfo { // thing the UI has to say out loud beforehand. // A bool, not the pattern: its names are not // hardware-verified yet, so we cannot name it. + std::optional countInRefused; // the next click on Play + // Count-In (the opposite of countIn) would write a + // PATTERN number at a beat whose list is not charted + // (CountIn.hpp, #149): the switch is a lamp, and this + // is why. Nothing when the click is the player's. long long tempoTenths; // Tempo: tenths of BPM the pedal will play at long long measures; // MeasLen: whole bars, as the pedal displays them long long recTempoTenths; // RecTmp: tenths of BPM the take was recorded at — @@ -103,13 +109,15 @@ inline SlotInfo readSlot(std::string_view memoryText, const profile::DeviceProfi for (int track = 1; track <= family.trackCount; ++track) tracks.push_back(readTrack(body, track)); const TrackInfo& first = tracks.front(); + const bool countIn = usecases::countin::isOn(body); return { slot, rc0::decodeName(body), first.hasAudio, first.frames, first.oneShot, - usecases::countin::isOn(body), + countIn, usecases::countin::patternAtRisk(body).has_value(), + usecases::countin::refusal(body, !countIn), rc0::sectionField(body, rc0::kSectionMaster, "Tempo"), first.measures, first.recTempoTenths, diff --git a/core/include/loopercat/usecases/Beat.hpp b/core/include/loopercat/usecases/Beat.hpp new file mode 100644 index 0000000..97cbc47 --- /dev/null +++ b/core/include/loopercat/usecases/Beat.hpp @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE. +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// BEAT, the time signature of a memory's rhythm (RC-5 reference manual +// p. 10), shared by the two features that write into the RHYTHM section: +// the rhythm itself (Rhythm.hpp) and Play Count-In (CountIn.hpp). It lives +// apart from both because of one hardware fact both have to obey (#147, +// 2026-09-30 and 2026-10-01): the number in indexes the pattern +// list of the memory's CURRENT BEAT, and only the 4/4 list is charted — +// Rock2 chosen on the pedal is stored as 12 at 4/4 and as 3 at 6/4, and a +// 19 written at 6/4 shows a blank name on the screen. So a PATTERN number, +// whichever feature writes it, is a 4/4 number, and at every other beat the +// write is refused (#149). The range cannot tell: a short list's numbers all +// fall inside the 4/4 list. Only the beat can. + +#pragma once + +#include "../Error.hpp" +#include "Choice.hpp" + +#include +#include +#include +#include + +namespace loopercat::usecases::beat { + +// BEAT: the manual prints "2/4–4/4–7/4, 5/8–15/8" with 4/4 the default, and +// never lists the steps. Anchor: 2 = 4/4 (hardware), which fits 2/4, 3/4, 4/4. +// Every step inside both ranges, the /8 block after the /4 block: inferred. +inline constexpr std::array kBeats { { + { 0, "2/4" }, { 1, "3/4" }, { 2, "4/4" }, { 3, "5/4" }, { 4, "6/4" }, { 5, "7/4" }, + { 6, "5/8" }, { 7, "6/8" }, { 8, "7/8" }, { 9, "8/8" }, { 10, "9/8" }, { 11, "10/8" }, + { 12, "11/8" }, { 13, "12/8" }, { 14, "13/8" }, { 15, "14/8" }, { 16, "15/8" }, +} }; + +// The one beat whose pattern list is charted: 4/4, the manual's printed list +// (hardware: Beat 2 reads 4/4 on the screen, and Rock2 written there is +// stored as 12, where the printed list has it — 2026-10-01, #147). +inline constexpr long long kBeatFourFour = 2; + +// Does this beat have a pattern list we can name numbers from? Only at 4/4. +// A beat outside kBeats has no charted list either — the answer is no, not +// an error, so a memory can always be read; acting on it is what fails. +inline constexpr bool patternListCharted(long long beat) { return beat == kBeatFourFour; } + +// The fact behind every refusal that follows from it, one sentence for both +// features, so the banner and the log never say it two ways. +inline constexpr std::string_view kOnlyFourFourCharted + = "only the 4/4 list is charted (hardware, 2026-10-01)"; + +// The name at `n` — "6/4" — or the typed error carrying the number: for an +// action on BEAT itself, where a number outside the list must fail loudly. +inline std::string_view beatName(long long n) { return usecases::nameIn(kBeats, "RHYTHM BEAT", n); } + +// The same name, or nothing for a number the list does not have: for a label. +inline std::optional nameIfListed(long long n) +{ + for (const Choice& choice : kBeats) + if (choice.number == n) + return choice.name; + return std::nullopt; +} + +// The beat as a sentence names it: as the screen prints it ("6/4"), or the +// truth about a number the manual's list does not have. kBeats is inferred +// past its one anchor, so a memory can hold a number it lacks, and a sentence +// about that memory must not throw where an action would: a caption is +// painted on every snapshot, a refusal is thrown once. +inline std::string label(long long n) +{ + if (const auto named = nameIfListed(n)) + return std::string(*named); + return "BEAT " + std::to_string(n) + ", not in the manual's list"; +} + +} // namespace loopercat::usecases::beat diff --git a/core/include/loopercat/usecases/CountIn.hpp b/core/include/loopercat/usecases/CountIn.hpp index dc9cabe..fea67e9 100644 --- a/core/include/loopercat/usecases/CountIn.hpp +++ b/core/include/loopercat/usecases/CountIn.hpp @@ -17,10 +17,20 @@ // rhythm is otherwise silent. A groove set on the pedal is never overwritten // and never cancels the count — the two coexist on the hardware, and they // coexist here. +// +// Blank and the factory 0 are 4/4 numbers (Beat.hpp, #147): the PATTERN a +// memory stores indexes the list of its current BEAT, and only the 4/4 list +// is charted. So the two paths of this feature that write Pattern — the +// borrow on the way on, the hand-back on the way off — are refused at any +// other beat before a byte moves (#149; found with the pedal's own Rock2 at +// 6/4, which "count-in on" turned into 57). The count alone, PlayCount over +// a rhythm that is already playing, is still ours at every beat. #pragma once +#include "../Error.hpp" #include "../Rc0.hpp" +#include "Beat.hpp" #include #include @@ -55,13 +65,68 @@ inline bool isOn(std::string_view slotBody) && detail::rhythm(slotBody, "PlayCount") == rc0::kRhythmPlayCount1Meas; } +// The two paths of the switch that write a PATTERN number, both 4/4 numbers. +enum class Refusal { + onBorrowsSection, // on over a silent section would write Blank (57) + offReturnsSection, // off would hand the borrowed section back as the factory 0 +}; + +// Why switching the count to `on` is refused at this memory's beat, or +// nothing. Pure over the body, and typed, so the table and the card make the +// switch a lamp before the click and say so in their own words, while +// apply() refuses after it with the fact (refusalText). The beat is not +// named here: a memory can hold a beat the manual's list lacks, and a +// PlayCount-only switch there must still pass. Already there is already +// there — a count switched on over a playing rhythm, or off where nothing +// was borrowed, moves PlayCount alone and passes at every beat. +inline std::optional refusal(std::string_view slotBody, bool on) +{ + if (beat::patternListCharted(detail::rhythm(slotBody, "Beat"))) + return std::nullopt; + const bool rhythmPlaying = detail::rhythm(slotBody, "State") == rc0::kRhythmStateOn; + if (on) { + if (rhythmPlaying) + return std::nullopt; + return Refusal::onBorrowsSection; + } + const bool borrowed = isOn(slotBody) + && detail::rhythm(slotBody, "Pattern") == rc0::kRhythmPatternBlank; + if (borrowed) + return Refusal::offReturnsSection; + return std::nullopt; +} + +// The refusal as the core states it, for the banner and the log: what the +// click needed, the beat as the screen prints it, and the fact. +inline std::string refusalText(Refusal why, long long atBeat) +{ + const std::string fact(beat::kOnlyFourFourCharted); + switch (why) { + case Refusal::onBorrowsSection: + return "Switching the count-in on at " + beat::label(atBeat) + + " would silence the rhythm with Blank, a 4/4 number, and " + fact + + ": switch the rhythm on first, and the count joins it."; + case Refusal::offReturnsSection: + return "Switching the count-in off at " + beat::label(atBeat) + + " would hand the rhythm back the factory pattern, a 4/4 number, and " + fact + + ": set it on the pedal."; + } + throw Error("unknown count-in refusal " + std::to_string(static_cast(why))); +} + // The groove that switching the count ON would replace, if any. Only a // silent rhythm section puts a pattern at risk: with the rhythm already // playing we leave Pattern alone, and Blank is not a groove anyone chose. // Pattern 0 is the factory value every untouched slot carries // (fixtures/golden.json) — reporting it would cry wolf on a fresh pedal. +// At an uncharted beat nothing is at risk, because nothing will be written: +// the switch that would replace the pattern is refused there (refusal), and +// the refusal is what the UI says instead. Deciding anything from 0 or 57 +// at such a beat would be reading 4/4 names into another list's numbers. inline std::optional patternAtRisk(std::string_view slotBody) { + if (!beat::patternListCharted(detail::rhythm(slotBody, "Beat"))) + return std::nullopt; const long long pattern = detail::rhythm(slotBody, "Pattern"); if (detail::rhythm(slotBody, "State") == rc0::kRhythmStateOn) return std::nullopt; @@ -75,9 +140,13 @@ inline std::optional patternAtRisk(std::string_view slotBody) // writes the count only. Turning it off gives the borrowed fields back — // the factory zeros, not a saved copy of anything: the feature keeps no // hidden state, so a slot that only ever had a count-in round-trips to the -// exact bytes it started with. +// exact bytes it started with. At an uncharted beat the two paths that +// write Pattern are refused before any byte moves (refusal). inline std::string apply(std::string_view slotBody, bool on) { + if (const auto why = refusal(slotBody, on)) + throw Error(refusalText(*why, detail::rhythm(slotBody, "Beat"))); + std::string body(slotBody); const bool rhythmPlaying = detail::rhythm(body, "State") == rc0::kRhythmStateOn; const bool rhythmSilent = detail::rhythm(body, "Pattern") == rc0::kRhythmPatternBlank; diff --git a/core/include/loopercat/usecases/Rhythm.hpp b/core/include/loopercat/usecases/Rhythm.hpp index 2dc651c..0c763a3 100644 --- a/core/include/loopercat/usecases/Rhythm.hpp +++ b/core/include/loopercat/usecases/Rhythm.hpp @@ -35,14 +35,17 @@ // // One list is not one list. The number in indexes the pattern // list of the memory's CURRENT BEAT (hardware, 2026-09-30 and 2026-10-01, -// #147): Rock2 chosen on the pedal is stored as 12 at 4/4 and as 3 at 6/4, -// and a 19 written at 6/4 shows a blank name on the screen. kPatterns is the -// 4/4 list, the one the manual prints; every other beat's list is uncharted -// — its length, its order, and where Blank sits in it. The range cannot tell: -// a short list's numbers all fall inside this one, so patternName would -// answer with a wrong name and no error. The guard keys off the beat (#149): -// at an uncharted beat the number is read and shown, never named, and no -// edit writes . +// #147, stated in Beat.hpp): Rock2 chosen on the pedal is stored as 12 at +// 4/4 and as 3 at 6/4, and a 19 written at 6/4 shows a blank name on the +// screen. kPatterns is the 4/4 list, the one the manual prints; every other +// beat's list is uncharted — its length, its order, and where Blank sits in +// it. The range cannot tell: a short list's numbers all fall inside this +// one, so patternName would answer with a wrong name and no error. The +// guard keys off the beat (#149): at an uncharted beat the number is read +// and shown, never named, and no edit writes — neither this +// feature's nor the count-in's. The next uncharted edge is the reading +// itself: isOn() still takes 57 for Blank at every beat, a 4/4 fact used +// for display only, until the other lists are charted. // // One rule the manual states outright (p. 10): BEAT cannot be changed after // a track is recorded. A slot with an indexed take refuses a Beat edit; the @@ -53,6 +56,7 @@ #include "../Error.hpp" #include "../Rc0.hpp" +#include "Beat.hpp" #include "Choice.hpp" #include "CountIn.hpp" @@ -107,24 +111,13 @@ inline constexpr std::array kKits { { { 5, "R&B" }, { 6, "808+909" }, } }; -// BEAT: the manual prints "2/4–4/4–7/4, 5/8–15/8" with 4/4 the default, and -// never lists the steps. Anchor: 2 = 4/4 (hardware), which fits 2/4, 3/4, 4/4. -// Every step inside both ranges, the /8 block after the /4 block: inferred. -inline constexpr std::array kBeats { { - { 0, "2/4" }, { 1, "3/4" }, { 2, "4/4" }, { 3, "5/4" }, { 4, "6/4" }, { 5, "7/4" }, - { 6, "5/8" }, { 7, "6/8" }, { 8, "7/8" }, { 9, "8/8" }, { 10, "9/8" }, { 11, "10/8" }, - { 12, "11/8" }, { 13, "12/8" }, { 14, "13/8" }, { 15, "14/8" }, { 16, "15/8" }, -} }; - -// The one beat whose pattern list is charted: 4/4, the manual's printed list -// (hardware: Beat 2 reads 4/4 on the screen, and Rock2 written there is -// stored as 12, where kPatterns has it — 2026-10-01, #147). -inline constexpr long long kBeatFourFour = 2; - -// Does this beat have a pattern list we can name numbers from? Only at 4/4. -// A beat outside kBeats has no charted list either — the answer is no, not -// an error, so a memory can always be read; naming it is what fails. -inline constexpr bool patternListCharted(long long beat) { return beat == kBeatFourFour; } +// BEAT is the RHYTHM screen's field, but the count-in has to obey it too, so +// the list and the one charted beat live in Beat.hpp; the card keeps its +// names for them. +using beat::kBeats; +using beat::kBeatFourFour; +using beat::patternListCharted; +using beat::beatName; // VARIATION: A or B. Anchor: 0 is the factory value and the printed default. inline constexpr std::array kVariations { { { 0, "A" }, { 1, "B" } } }; @@ -170,7 +163,6 @@ namespace detail { inline std::string_view patternName(long long n) { return detail::nameIn(kPatterns, "PATTERN", n); } inline std::string_view kitName(long long n) { return detail::nameIn(kKits, "KIT", n); } -inline std::string_view beatName(long long n) { return detail::nameIn(kBeats, "BEAT", n); } inline std::string_view variationName(long long n) { return detail::nameIn(kVariations, "VARIATION", n); @@ -254,47 +246,65 @@ inline Values read(std::string_view slotBody) namespace detail { - // The fact behind every refusal below. Each sentence names the beat as + // A PATTERN number is chosen from the current beat's list, so it can be + // chosen only where that list is known. The sentence names the beat as // the screen prints it ("6/4"): the number alone would send the player // to count the manual's list. - inline constexpr std::string_view kOnlyFourFourCharted - = "only the 4/4 list is charted (hardware, 2026-10-01)"; - - // A PATTERN number is chosen from the current beat's list, so it can be - // chosen only where that list is known. - inline void requirePatternCharted(long long beat) + inline void requirePatternCharted(long long atBeat) { - if (!patternListCharted(beat)) - throw Error("PATTERN cannot be chosen at " + std::string(beatName(beat)) + ": " - + std::string(kOnlyFourFourCharted)); + if (!patternListCharted(atBeat)) + throw Error("PATTERN cannot be chosen at " + beat::label(atBeat) + ": " + + std::string(beat::kOnlyFourFourCharted)); } } // namespace detail // --- the switch --- +// The two paths of the switch that write a PATTERN number: Blank becomes the +// default on the way on, the groove becomes Blank on the way off with a +// count-in to keep. Neither number is known outside the 4/4 list. +enum class SwitchRefusal { + onNeedsGroove, // on over Blank would write kPatternDefault + offNeedsBlank, // off with the count-in on would write kRhythmPatternBlank +}; + // Why switching the drums to `on` is refused at this memory's beat, or -// nothing. Two of the switch's paths write a PATTERN number: Blank becomes -// the default on the way on, the groove becomes Blank on the way off with a -// count-in to keep. Neither number is known outside the 4/4 list, so at an -// uncharted beat those two clicks are refused; State alone is still ours. -// Pure over what the card shows, so the tab makes the switch a lamp before -// the click and apply() refuses after it with the same sentence. -inline std::optional switchRefusal(const Values& values, bool countInOn, bool on) +// nothing. At an uncharted beat the two clicks above are refused; State +// alone is still ours. Pure over what the card shows, and typed, so the tab +// makes the switch a lamp before the click and says so in its own words, +// while apply() refuses after it with the fact (switchRefusalText). The +// beat is not named here: a memory can hold a beat the manual's list lacks, +// and a State-only switch there must still pass. +inline std::optional switchRefusal(const Values& values, bool countInOn, bool on) { if (patternListCharted(values.beat) || on == values.on) return std::nullopt; - const std::string beat(beatName(values.beat)); - const std::string fact(detail::kOnlyFourFourCharted); if (on && values.pattern == rc0::kRhythmPatternBlank) - return "Switching on at " + beat + " needs a groove in place of Blank, and " + fact - + ": choose a pattern on the pedal first."; + return SwitchRefusal::onNeedsGroove; if (!on && countInOn) - return "Switching off at " + beat + " with the count-in on needs Blank's number, and " - + fact + ": switch the count-in off first."; + return SwitchRefusal::offNeedsBlank; return std::nullopt; } +// The refusal as the core states it, for the banner and the log: what the +// click needed, the beat as the screen prints it, and the fact. +inline std::string switchRefusalText(SwitchRefusal why, long long atBeat) +{ + const std::string fact(beat::kOnlyFourFourCharted); + switch (why) { + case SwitchRefusal::onNeedsGroove: + return "Switching the rhythm on at " + beat::label(atBeat) + + " needs a groove in place of Blank, and " + fact + + ": choose a pattern on the pedal first."; + case SwitchRefusal::offNeedsBlank: + return "Switching the rhythm off at " + beat::label(atBeat) + + " with the count-in on needs Blank's number, and " + fact + + ": switch the count-in off first."; + } + throw Error("unknown rhythm switch refusal " + std::to_string(static_cast(why))); +} + namespace detail { // The switch's bytes, trusted: the caller has asked switchRefusal first. @@ -322,8 +332,9 @@ namespace detail { // before any byte moves (switchRefusal). inline std::string applySwitch(std::string_view slotBody, bool on) { - if (const auto why = switchRefusal(read(slotBody), countin::isOn(slotBody), on)) - throw Error(*why); + const Values values = read(slotBody); + if (const auto why = switchRefusal(values, countin::isOn(slotBody), on)) + throw Error(switchRefusalText(*why, values.beat)); return detail::switchBytes(std::string(slotBody), on); } @@ -368,9 +379,9 @@ inline std::string apply(std::string_view slotBody, const Edits& edits) // this edit lands — its own, or the one the edit carries — and so is the // switch's own pattern write. A pattern together with a move to 4/4 is // fine; a pattern together with a move away from it is not. - const long long beat = edits.beat ? *edits.beat : detail::rhythm(slotBody, "Beat"); + const long long atBeat = edits.beat ? *edits.beat : detail::rhythm(slotBody, "Beat"); if (edits.pattern) { - detail::requirePatternCharted(beat); + detail::requirePatternCharted(atBeat); patternName(*edits.pattern); if (*edits.pattern == rc0::kRhythmPatternBlank) throw Error("Blank is the count-in's silence, not a groove: switch the rhythm off " @@ -378,9 +389,9 @@ inline std::string apply(std::string_view slotBody, const Edits& edits) } if (edits.on) { Values after = read(slotBody); - after.beat = beat; + after.beat = atBeat; if (const auto why = switchRefusal(after, countin::isOn(slotBody), *edits.on)) - throw Error(*why); + throw Error(switchRefusalText(*why, atBeat)); } if (edits.kit) kitName(*edits.kit); @@ -424,12 +435,12 @@ inline std::string apply(std::string_view slotBody, const Edits& edits) // edit leaves the memory at, and at an uncharted beat it is refused here // too — the words are made before the job runs, and a history row must not // carry a 4/4 name for a 6/4 number. -inline std::string describe(const Edits& edits, long long beat) +inline std::string describe(const Edits& edits, long long atBeat) { if (edits.beat) - beat = *edits.beat; + atBeat = *edits.beat; if (edits.pattern) - detail::requirePatternCharted(beat); + detail::requirePatternCharted(atBeat); std::string out; const auto add = [&out](std::string_view text) { if (!out.empty()) diff --git a/tests/catalog_tests.cpp b/tests/catalog_tests.cpp index 2eea100..5694bb0 100644 --- a/tests/catalog_tests.cpp +++ b/tests/catalog_tests.cpp @@ -54,6 +54,21 @@ int main() std::string body14 = rc0::slotBody(text, 14); body14 = rc0::setField(body14, "Pattern", 11); text = rc0::replaceSlotBody(text, 14, body14); + + // Slot 15: the same shape at 6/4 (Beat 4), where the number 11 is + // not a name we know and the count's borrow would write a 4/4 number + // (#149). Slot 16: 6/4 with the rhythm playing, where the count + // alone is still the player's. + std::string body15 = rc0::slotBody(text, 15); + body15 = rc0::setField(body15, "Pattern", 11); + body15 = rc0::setField(body15, "Beat", 4); + text = rc0::replaceSlotBody(text, 15, body15); + + std::string body16 = rc0::slotBody(text, 16); + body16 = rc0::setField(body16, "State", rc0::kRhythmStateOn); + body16 = rc0::setField(body16, "Pattern", 11); + body16 = rc0::setField(body16, "Beat", 4); + text = rc0::replaceSlotBody(text, 16, body16); } const auto slots = catalog::listSlots(text); @@ -106,6 +121,17 @@ int main() CHECK(!slots.at(12).countInTakesPattern); CHECK(!slots.at(0).countInTakesPattern); + // At 6/4 the count's borrow is refused rather than warned about: slot 15 + // carries the refusal of its next click (on) and no warning; slot 16's + // next click moves the count alone and carries nothing; a 4/4 slot + // carries nothing either way. + CHECK(slots.at(14).countInRefused == usecases::countin::Refusal::onBorrowsSection); + CHECK(!slots.at(14).countInTakesPattern); + CHECK(!slots.at(14).rhythm.patternCharted); + CHECK(!slots.at(15).countInRefused.has_value()); + CHECK(!slots.at(13).countInRefused.has_value()); + CHECK(!slots.at(0).countInRefused.has_value()); + // readSlot agrees with listSlots. CHECK(catalog::readSlot(text, 7) == slots.at(6)); diff --git a/tests/commands_tests.cpp b/tests/commands_tests.cpp index ffc0d30..53592ee 100644 --- a/tests/commands_tests.cpp +++ b/tests/commands_tests.cpp @@ -1064,6 +1064,80 @@ int main() "out of range"); } + // --- setCountIn at a beat whose pattern list is not charted (#149) --- + // + // The P0 as it was found on the card: the pedal's own Rock2 at 6/4 is + // stored as 3, and "count-in on" over it wrote 57, a 4/4 number. Now the + // transaction is refused and the volume is byte-identical afterwards — + // both memory files, and the whole batch: one refused slot refuses the + // write for every slot in it. The count alone is still written at 6/4. + { + TempDir tmp; + const fs::path volume = makePedal(tmp.path); + { + std::string text = commands::readMemory(volume); + const auto rhythm = [&text](int slot, const char* tag, long long value) { + text = rc0::replaceSlotBody( + text, slot, + rc0::setSectionField(rc0::slotBody(text, slot), rc0::kSectionRhythm, tag, value)); + }; + // Slot 5: silent at 6/4 with the pedal's Rock2. Slot 6: the same, + // playing. Slot 7: the app's own count-in shape, moved to 6/4. + rhythm(5, "Beat", 4); + rhythm(5, "Pattern", 3); + rhythm(6, "Beat", 4); + rhythm(6, "Pattern", 3); + rhythm(6, "State", rc0::kRhythmStateOn); + rhythm(7, "Beat", 4); + rhythm(7, "Pattern", rc0::kRhythmPatternBlank); + rhythm(7, "State", rc0::kRhythmStateOn); + rhythm(7, "PlayCount", rc0::kRhythmPlayCount1Meas); + for (const int fileNo : { 1, 2 }) + commands::writeFileBytes(volume::memoryPath(volume, fileNo), + rc0::setTailMarker(text, fileNo)); + } + const auto pair = [&volume] { + return std::pair { commands::readFileBytes(volume::memoryPath(volume, 1)), + commands::readFileBytes(volume::memoryPath(volume, 2)) }; + }; + const auto before = pair(); + + CHECK_THROWS(commands::setCountIn(volume, { 5 }, true, writeOpts(tmp.path)), "at 6/4"); + CHECK(pair() == before); + CHECK_EQ(rc0::field(rc0::slotBody(commands::readMemory(volume), 5), "Pattern"), 3); + // A 4/4 slot ahead of it in the batch does not land first. + CHECK_THROWS(commands::setCountIn(volume, { 3, 5 }, true, writeOpts(tmp.path)), "at 6/4"); + CHECK(pair() == before); + CHECK(!catalog::readSlot(commands::readMemory(volume), 3).countIn); + // Off over the borrowed section is the other refused path. + CHECK_THROWS(commands::setCountIn(volume, { 7 }, false, writeOpts(tmp.path)), "at 6/4"); + CHECK(pair() == before); + // On where it already is: nothing to write, nothing refused. + commands::setCountIn(volume, { 7 }, true, writeOpts(tmp.path)); + CHECK(rc0::slotBody(commands::readMemory(volume), 7) + == rc0::slotBody(std::string(before.first.begin(), before.first.end()), 7)); + + // The count alone at 6/4: over a playing rhythm, on and then off move + // PlayCount and nothing else — the pedal's 3 stays 3. + commands::setCountIn(volume, { 6 }, true, writeOpts(tmp.path)); + { + const std::string text = commands::readMemory(volume); + const std::string body = rc0::slotBody(text, 6); + CHECK_EQ(rc0::field(body, "PlayCount"), rc0::kRhythmPlayCount1Meas); + CHECK_EQ(rc0::field(body, "Pattern"), 3); + CHECK_EQ(rc0::field(body, "State"), rc0::kRhythmStateOn); + CHECK_EQ(rc0::field(body, "Beat"), 4); + CHECK(catalog::readSlot(text, 6).countIn); + } + commands::setCountIn(volume, { 6 }, false, writeOpts(tmp.path)); + { + const std::string body = rc0::slotBody(commands::readMemory(volume), 6); + CHECK_EQ(rc0::field(body, "PlayCount"), 0); + CHECK_EQ(rc0::field(body, "Pattern"), 3); + CHECK_EQ(rc0::field(body, "State"), rc0::kRhythmStateOn); + } + } + // --- push: validate-then-write, canonical bytes, full config --- { diff --git a/tests/usecase_count_in_tests.cpp b/tests/usecase_count_in_tests.cpp index 11bce6b..edb0948 100644 --- a/tests/usecase_count_in_tests.cpp +++ b/tests/usecase_count_in_tests.cpp @@ -14,18 +14,34 @@ // the count from the table. // 4. The feature keeps no hidden state: a slot that only ever had a // count-in returns to the exact bytes it started with. +// 5. A PATTERN number is a number in the list of the memory's CURRENT +// BEAT, and only the 4/4 list is charted (hardware 2026-10-01, #147): +// at any other beat the feature writes no pattern number — the two +// paths that would (the borrow on the way on, the hand-back on the way +// off) are refused before a byte moves, naming the beat as the screen +// prints it — while the count alone, PlayCount over a playing rhythm, +// is still the player's. #include "support.hpp" #include +#include #include +#include + using namespace loopercat; namespace { constexpr long long kSomeGroove = 11; // any pattern that is neither Blank nor the factory 0 + // The hardware pairing of #147: Beat 2 reads 4/4, Beat 4 reads 6/4, and + // Rock2 chosen on the pedal is stored as 3 at 6/4 (12 at 4/4). + constexpr long long kFourFour = 2; + constexpr long long kSixFour = 4; + constexpr long long kRock2At64 = 3; + std::string bodyWith(long long state, long long playCount, long long pattern) { std::string body = testkit::syntheticSlotBody(); @@ -35,6 +51,11 @@ namespace return body; } + std::string atBeat(std::string body, long long beat) + { + return rc0::setSectionField(body, rc0::kSectionRhythm, "Beat", beat); + } + // Byte proof that nothing outside the named fields moved: restore them to // the values the original carried, and the two bodies must be identical. bool onlyTheseFieldsMoved(const std::string& before, std::string after, @@ -189,5 +210,137 @@ int main() CHECK(usecases::countin::apply(on, false) == body); } + // --- a PATTERN number is a 4/4 number: the count-in writes none elsewhere --- + // + // The P0 of #149 as it was found: the pedal's own Rock2 at 6/4 is stored + // as 3, and "count-in on" over it wrote 57. The rule, from the spec: on + // writes Pattern exactly when the section is off (the borrow); off + // writes it exactly when the section was borrowed for the count (the + // hand-back). Both are refused at every uncharted beat, before any byte + // moves; everything else moves PlayCount alone and passes. Every beat + // but 4/4, both toggles, every section state. + { + namespace countin = usecases::countin; + struct Section { + long long state, playCount, pattern; + }; + const Section sections[] = { + { 0, 0, kRock2At64 }, // silent, the pedal's own groove + { 0, 0, rc0::kRhythmPatternBlank }, // silent, 57 + { 0, 0, 0 }, // silent, factory 0 + { 0, rc0::kRhythmPlayCount1Meas, kRock2At64 }, // PlayCount set, section off + { rc0::kRhythmStateOn, 0, kRock2At64 }, // playing, no count + { rc0::kRhythmStateOn, 0, rc0::kRhythmPatternBlank }, // on with 57, no count + { rc0::kRhythmStateOn, rc0::kRhythmPlayCount1Meas, kRock2At64 }, // count over a groove + { rc0::kRhythmStateOn, rc0::kRhythmPlayCount1Meas, rc0::kRhythmPatternBlank }, // the app's own shape + }; + std::vector uncharted; + for (const usecases::Choice& b : usecases::beat::kBeats) + if (b.number != kFourFour) + uncharted.push_back(b.number); + for (const long long odd : { -1LL, 17LL, 99LL }) // numbers the list lacks + uncharted.push_back(odd); + CHECK_EQ(uncharted.size(), 19u); + + for (const long long beat : uncharted) { + for (const Section& s : sections) { + const std::string before = atBeat(bodyWith(s.state, s.playCount, s.pattern), beat); + for (const bool on : { true, false }) { + const bool writesPattern = on + ? s.state != rc0::kRhythmStateOn + : s.state == rc0::kRhythmStateOn && s.playCount == rc0::kRhythmPlayCount1Meas + && s.pattern == rc0::kRhythmPatternBlank; + const auto why = countin::refusal(before, on); + CHECK_EQ(why.has_value(), writesPattern); + if (writesPattern) { + CHECK(why == (on ? countin::Refusal::onBorrowsSection + : countin::Refusal::offReturnsSection)); + std::string after; + CHECK_THROWS(after = countin::apply(before, on), "4/4 list"); + CHECK(after.empty()); + } else { + const std::string after = countin::apply(before, on); + CHECK(onlyTheseFieldsMoved(before, after, { "PlayCount" })); + CHECK_EQ(rc0::field(after, "Pattern"), s.pattern); + CHECK_EQ(rc0::field(after, "State"), s.state); + CHECK_EQ(rc0::field(after, "PlayCount"), + on ? rc0::kRhythmPlayCount1Meas : 0); + } + } + } + } + + // The sentence names the beat as the screen prints it, not the number; + // says what the click would have written and what to do instead; and + // for a number the list lacks, says that. + { + const std::string silent = atBeat(bodyWith(0, 0, kRock2At64), kSixFour); + std::string after; + CHECK_THROWS(after = countin::apply(silent, true), "at 6/4"); + CHECK_THROWS(after = countin::apply(silent, true), "Blank"); + CHECK_THROWS(after = countin::apply(silent, true), "switch the rhythm on first"); + CHECK(after.empty()); + try { + countin::apply(silent, true); + CHECK(false); + } catch (const Error& e) { + CHECK(std::string(e.what()).find("at 4") == std::string::npos); + } + const std::string borrowed + = atBeat(bodyWith(rc0::kRhythmStateOn, rc0::kRhythmPlayCount1Meas, + rc0::kRhythmPatternBlank), + kSixFour); + CHECK_THROWS(after = countin::apply(borrowed, false), "at 6/4"); + CHECK_THROWS(after = countin::apply(borrowed, false), "on the pedal"); + CHECK(after.empty()); + CHECK_THROWS(countin::apply(atBeat(bodyWith(0, 0, kRock2At64), 17), true), + "BEAT 17, not in the manual's list"); + CHECK_THROWS(countin::apply(atBeat(bodyWith(0, 0, kRock2At64), -1), true), "BEAT -1"); + } + + // At 4/4 nothing is refused, whatever the section holds: the rule is + // the beat's, not the pattern's. + for (const Section& s : sections) + for (const bool on : { true, false }) + CHECK(!countin::refusal(bodyWith(s.state, s.playCount, s.pattern), on).has_value()); + + // Nothing is "at risk" at an uncharted beat: the switch that would + // replace the pattern is refused there, and the refusal is what the + // card says instead of the warning. The same section at 4/4 warns. + CHECK(!countin::patternAtRisk(atBeat(bodyWith(0, 0, kRock2At64), kSixFour)).has_value()); + CHECK(!countin::patternAtRisk(atBeat(bodyWith(0, 0, 19), kSixFour)).has_value()); + CHECK(!countin::patternAtRisk(atBeat(bodyWith(0, 0, kSomeGroove), 17)).has_value()); + CHECK(countin::patternAtRisk(bodyWith(0, 0, kRock2At64)).has_value()); + + // The read model carries the refusal of the NEXT click — on where the + // count is off, off where it is on — and nothing where that click is + // the player's. + { + std::string text = testkit::syntheticMemoryText(); + const auto put = [&text](int slot, const std::string& body) { + text = rc0::replaceSlotBody(text, slot, body); + }; + put(5, atBeat(bodyWith(0, 0, kRock2At64), kSixFour)); // silent: on refused + put(6, atBeat(bodyWith(rc0::kRhythmStateOn, 0, kRock2At64), kSixFour)); // playing: on passes + put(7, atBeat(bodyWith(rc0::kRhythmStateOn, rc0::kRhythmPlayCount1Meas, + rc0::kRhythmPatternBlank), + kSixFour)); // borrowed: off refused + put(8, atBeat(bodyWith(rc0::kRhythmStateOn, rc0::kRhythmPlayCount1Meas, kRock2At64), + kSixFour)); // count over a groove: off passes + put(9, bodyWith(0, 0, kRock2At64)); // silent at 4/4: the warning + CHECK(catalog::readSlot(text, 5).countInRefused == countin::Refusal::onBorrowsSection); + CHECK(!catalog::readSlot(text, 5).countInTakesPattern); + CHECK(!catalog::readSlot(text, 5).countIn); + CHECK(!catalog::readSlot(text, 6).countInRefused.has_value()); + CHECK(catalog::readSlot(text, 7).countInRefused == countin::Refusal::offReturnsSection); + CHECK(catalog::readSlot(text, 7).countIn); + CHECK(!catalog::readSlot(text, 8).countInRefused.has_value()); + CHECK(catalog::readSlot(text, 8).countIn); + CHECK(!catalog::readSlot(text, 9).countInRefused.has_value()); + CHECK(catalog::readSlot(text, 9).countInTakesPattern); + CHECK(!catalog::readSlot(text, 1).countInRefused.has_value()); + } + } + return testkit::summary("usecase_count_in_tests"); } diff --git a/tests/usecase_rhythm_tests.cpp b/tests/usecase_rhythm_tests.cpp index 43d682b..476d6cb 100644 --- a/tests/usecase_rhythm_tests.cpp +++ b/tests/usecase_rhythm_tests.cpp @@ -26,6 +26,7 @@ #include "support.hpp" #include +#include #include #include @@ -75,12 +76,17 @@ namespace return rc0::sectionField(body, rc0::kSectionRhythm, tag); } + std::string atBeat(std::string body, long long beat) + { + return rc0::setSectionField(body, rc0::kSectionRhythm, "Beat", beat); + } + // The same body at 6/4, holding `pattern` — a number from the 6/4 list, // which we cannot name. std::string atSixFour(std::string body, long long pattern) { - body = rc0::setSectionField(body, rc0::kSectionRhythm, "Beat", kSixFour); - return rc0::setSectionField(body, rc0::kSectionRhythm, "Pattern", pattern); + return rc0::setSectionField(atBeat(std::move(body), kSixFour), rc0::kSectionRhythm, + "Pattern", pattern); } } @@ -627,7 +633,11 @@ int main() CHECK_THROWS(after = rhythm::applySwitch(blankOff, true), "on the pedal"); CHECK_THROWS(after = rhythm::apply(blankOff, { .on = true }), "6/4"); CHECK(after.empty()); - CHECK(rhythm::switchRefusal(rhythm::read(blankOff), false, true).has_value()); + CHECK(rhythm::switchRefusal(rhythm::read(blankOff), false, true) + == rhythm::SwitchRefusal::onNeedsGroove); + // The typed refusal and its sentence are one fact, told two ways. + CHECK_THROWS(rhythm::applySwitch(blankOff, true), + rhythm::switchRefusalText(rhythm::SwitchRefusal::onNeedsGroove, kSixFour)); // The count-in's silence (State on, Blank) is the same refusal on the // way on, and nothing at all on the way off: it is already off. const std::string countSilence @@ -647,7 +657,10 @@ int main() CHECK_THROWS(after = rhythm::applySwitch(counted, false), "count-in"); CHECK_THROWS(after = rhythm::apply(counted, { .on = false }), "6/4"); CHECK(after.empty()); - CHECK(rhythm::switchRefusal(rhythm::read(counted), true, false).has_value()); + CHECK(rhythm::switchRefusal(rhythm::read(counted), true, false) + == rhythm::SwitchRefusal::offNeedsBlank); + CHECK_THROWS(rhythm::applySwitch(counted, false), + rhythm::switchRefusalText(rhythm::SwitchRefusal::offNeedsBlank, kSixFour)); // The same memory with the count-in off is State alone again. CHECK(!rhythm::switchRefusal(rhythm::read(counted), false, false).has_value()); // Together with a move to 4/4 the numbers are 4/4 numbers: both pass. @@ -687,5 +700,47 @@ int main() CHECK_EQ(rhythm::describe({ .beat = kSixFour }, kSixFour), "beat 6/4"); CHECK_EQ(rhythm::describe({ .on = true, .kit = 2 }, kSixFour), "switched on, kit Jazz"); + // --- a beat the manual's list lacks: read and labelled, never a throw on a reading --- + // + // kBeats is inferred past its one anchor, so a memory can hold a number + // the list lacks. Reading and labelling it must not throw — a tab paints + // on every snapshot — while an action on BEAT itself still fails loudly, + // and a pattern number is as refused there as at 6/4. + CHECK_EQ(usecases::beat::label(kSixFour), "6/4"); + CHECK_EQ(*usecases::beat::nameIfListed(kFourFour), "4/4"); + for (const long long odd : { -1LL, 17LL, 99LL }) { + const std::string number = std::to_string(odd); + const std::string body = atBeat(bodyWith(0, 0, kRock2At64), odd); + const rhythm::Values v = rhythm::read(body); + CHECK_EQ(v.beat, odd); + CHECK(!v.patternCharted); + CHECK_EQ(v.pattern, kRock2At64); + CHECK(!usecases::beat::nameIfListed(odd).has_value()); + CHECK_EQ(usecases::beat::label(odd), "BEAT " + number + ", not in the manual's list"); + CHECK_THROWS(rhythm::beatName(odd), "BEAT " + number); + // A State-only switch passes: no pattern number is needed for it. + CHECK(!rhythm::switchRefusal(v, false, true).has_value()); + const std::string on = rhythm::applySwitch(body, true); + CHECK(onlyTheseFieldsMoved(body, on, { "State" })); + CHECK(rhythm::applySwitch(on, false) == body); + CHECK(rhythm::apply(body, { .on = true }) == on); + // The two pattern-writing clicks are refused, with the number told as + // the truth it is rather than a name the list does not have. + const std::string blank = atBeat(bodyWith(0, 0, rc0::kRhythmPatternBlank), odd); + CHECK(rhythm::switchRefusal(rhythm::read(blank), false, true) + == rhythm::SwitchRefusal::onNeedsGroove); + CHECK_THROWS(rhythm::applySwitch(blank, true), "BEAT " + number + ", not in the manual's list"); + // A pattern edit is refused, before any byte moves; the rest of the + // card is still the player's, and so is the way out: BEAT itself. + std::string after; + CHECK_THROWS(after = rhythm::apply(body, { .pattern = kRock2At44 }), + "PATTERN cannot be chosen at BEAT " + number); + CHECK(after.empty()); + CHECK_THROWS(rhythm::describe({ .pattern = kRock2At44 }, odd), "BEAT " + number); + CHECK(onlyTheseFieldsMoved(body, rhythm::apply(body, { .kit = 2 }), { "Kit" })); + CHECK_EQ(rhythm::read(rhythm::apply(body, { .beat = kFourFour })).beat, kFourFour); + CHECK(rhythm::read(rhythm::apply(body, { .beat = kFourFour })).patternCharted); + } + return testkit::summary("usecase_rhythm_tests"); } From 8f55c699a0821c912218cc67a586b5e0139c63b2 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Sun, 4 Oct 2026 16:50:01 +0200 Subject: [PATCH 5/7] Say a refused click in the player's words, before the click, on every 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. --- app/MainComponent.cpp | 15 +++++++- app/RefusalWords.h | 71 +++++++++++++++++++++++++++++++++++ app/RhythmPane.cpp | 19 +++++----- app/SlotInspector.cpp | 11 +++++- app/SlotTable.cpp | 27 +++++++++++-- app/SlotTable.h | 1 + tests/rhythm_pane_harness.cpp | 35 ++++++++++++++++- tests/slot_table_harness.cpp | 31 ++++++++++++++- 8 files changed, 192 insertions(+), 18 deletions(-) create mode 100644 app/RefusalWords.h diff --git a/app/MainComponent.cpp b/app/MainComponent.cpp index f110a0e..300e230 100644 --- a/app/MainComponent.cpp +++ b/app/MainComponent.cpp @@ -2022,7 +2022,20 @@ void MainComponent::editRhythm(int slot, long long beat, usecases::rhythm::Edits { if (!CardPermissions::of(snapshot.family).rhythm) return; - auto note = std::make_shared(utf8(usecases::rhythm::describe(edits, beat))); + // Caught rather than dropped: the tab locks PATTERN where these 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. Nothing above + // this frame catches on the message thread — a throw here would end + // the app, not report. + std::shared_ptr note; + try { + note = std::make_shared(utf8(usecases::rhythm::describe(edits, beat))); + } catch (const Error& e) { + banners.showError(banners::Source::job, + "Rhythm on slot " + juce::String(slot) + ": " + utf8(e.what())); + return; + } const auto options = makeWriteOptions(); worker.enqueue(recorded("rhythm", options, { "Rhythm on slot " + juce::String(slot), diff --git a/app/RefusalWords.h b/app/RefusalWords.h new file mode 100644 index 0000000..0607702 --- /dev/null +++ b/app/RefusalWords.h @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE. +// SPDX-License-Identifier: AGPL-3.0-or-later + +#pragma once + +#include "Strings.h" + +#include +#include +#include + +#include + +//============================================================================== +// loopercat::words — the player's one line for a refusal the core states as +// a fact. The core refuses a click that would write a PATTERN number at a +// beat whose list is not charted (Beat.hpp, #149) and says so in full for +// the banner and the log; the Rhythm tab, the Play Count-In card and the +// table say it in one line beside the lamp, before the click, with no date +// and no field name in it. One place for those lines, so the three never +// drift and there is one list to reword. +//============================================================================== +namespace loopercat::words +{ + +// The beat as the screen prints it ("6/4"), or the truth about a number the +// manual's list does not have — never a throw: these lines are painted on +// every snapshot (Beat.hpp, label). +inline juce::String beatLabel(long long beat) { return utf8(usecases::beat::label(beat)); } + +// Beside the rhythm switch, where its click is refused. +inline juce::String rhythmSwitchRefused(usecases::rhythm::SwitchRefusal why, long long beat) +{ + switch (why) { + case usecases::rhythm::SwitchRefusal::onNeedsGroove: + return "Switching on at " + beatLabel(beat) + + " needs a pattern: choose one on the pedal first."; + case usecases::rhythm::SwitchRefusal::offNeedsBlank: + return "Switching off at " + beatLabel(beat) + + " would drop the pattern: switch the count-in off first."; + } + jassertfalse; + return {}; +} + +// Beside the Play Count-In switch (the card, the table cell's hover), where +// its click is refused. +inline juce::String countInRefused(usecases::countin::Refusal why, long long beat) +{ + switch (why) { + case usecases::countin::Refusal::onBorrowsSection: + return "Switching on at " + beatLabel(beat) + + " would replace the pattern: switch the rhythm on first."; + case usecases::countin::Refusal::offReturnsSection: + return "Switching off at " + beatLabel(beat) + + " would reset the pattern: set it on the pedal."; + } + jassertfalse; + return {}; +} + +// The PATTERN caption at a beat whose list is not charted, in the shape BEAT +// takes under a take ("BEAT (fixed by the take)"). +inline juce::String patternCaptionLocked(long long beat) +{ + if (const auto named = usecases::beat::nameIfListed(beat)) + return "PATTERN (list at " + utf8(std::string(*named)) + " not charted)"; + return "PATTERN (BEAT " + juce::String(beat) + " is not in the manual's list)"; +} + +} // namespace loopercat::words diff --git a/app/RhythmPane.cpp b/app/RhythmPane.cpp index fe1b572..ffcd0a4 100644 --- a/app/RhythmPane.cpp +++ b/app/RhythmPane.cpp @@ -3,6 +3,7 @@ #include "RhythmPane.h" +#include "RefusalWords.h" #include "Strings.h" #include @@ -191,16 +192,15 @@ void RhythmControls::refresh() // list is charted (Rhythm.hpp, #149): elsewhere the box shows the number // the card holds — as text, with no item behind it — and is a lamp, in // the shape BEAT takes under a take. A 4/4 name for a 6/4 number is the - // one thing this tab must never show. + // one thing this tab must never show. The caption never throws: a memory + // can hold a beat the manual's list lacks, and this runs on every snapshot. if (values_.patternCharted) pattern_.setSelectedId(static_cast(values_.pattern) + 1, juce::dontSendNotification); else pattern_.setText(juce::String(values_.pattern), juce::dontSendNotification); pattern_.setEnabled(isEnabled() && values_.patternCharted); - patternCaption_.setText(values_.patternCharted - ? juce::String("PATTERN") - : "PATTERN (list at " + name(rhythm::beatName(values_.beat)) - + " not charted)", + patternCaption_.setText(values_.patternCharted ? juce::String("PATTERN") + : words::patternCaptionLocked(values_.beat), juce::dontSendNotification); kit_.setSelectedId(static_cast(values_.kit) + 1, juce::dontSendNotification); beat_.setSelectedId(static_cast(values_.beat) + 1, juce::dontSendNotification); @@ -335,16 +335,17 @@ void RhythmPane::refresh() : "No drums with this memory."); // A click the core would refuse is not offered: at an uncharted beat the // two clicks that write a PATTERN number make the switch a lamp, and the - // line beside it says why — the core's own sentence, so the tab and a - // banner can never disagree (Rhythm.hpp, switchRefusal). - const std::optional refused = rhythm::switchRefusal(v, info_.countIn, !v.on); + // line beside it says why in the player's words (RefusalWords.h) — the + // same typed refusal the core would throw, so the tab and a banner can + // never disagree about when (Rhythm.hpp, switchRefusal). + const auto refused = rhythm::switchRefusal(v, info_.countIn, !v.on); if (refused) switch_.setEnabled(false); // Otherwise the groove that "off" would forget: only with a count-in in // front of playing drums does off mean Blank rather than State off // (Rhythm.hpp). That click is refused at an uncharted beat, so the name // is looked up only where the list is charted. - cost_.setText(refused ? name(*refused) + cost_.setText(refused ? words::rhythmSwitchRefused(*refused, v.beat) : v.on && info_.countIn ? "Switching it off keeps the count-in and forgets " + name(rhythm::patternName(v.pattern)) + "." diff --git a/app/SlotInspector.cpp b/app/SlotInspector.cpp index 407c968..26a741b 100644 --- a/app/SlotInspector.cpp +++ b/app/SlotInspector.cpp @@ -3,6 +3,7 @@ #include "SlotInspector.h" +#include "RefusalWords.h" #include "SlotTable.h" #include "Strings.h" @@ -124,7 +125,11 @@ void SlotInspector::refresh() nameEditor_.setReadOnly(!can.rename); tempoEditor_.setEnabled(live && can.tempo); tempoEditor_.setReadOnly(!can.tempo); - countIn_.setEnabled(live && can.countIn); + // A click the core would refuse is not offered: where the count-in's + // switch would write a PATTERN number at a beat whose list is not + // charted (CountIn.hpp, #149), the card is a lamp and its orange line + // says why, before the click rather than in a banner after it. + countIn_.setEnabled(live && can.countIn && !info_.countInRefused); oneShot_.setEnabled(live && can.oneShot); playStop_.setEnabled(live && can.playStop); footer_.setText(can.anyWrite() ? juce::String("Disconnect to hear the changes.") @@ -155,7 +160,9 @@ void SlotInspector::refresh() info_.countIn ? "One bar of count at " + SlotTable::formatTempo(info_.tempoTenths) + " BPM, then the loop." : "No count: the loop starts the moment you press play.", - !info_.countIn && info_.countInTakesPattern + info_.countInRefused + ? words::countInRefused(*info_.countInRefused, info_.rhythm.beat) + : !info_.countIn && info_.countInTakesPattern ? juce::String::fromUTF8("Switching it on replaces a rhythm pattern chosen on the " "pedal.") : juce::String()); diff --git a/app/SlotTable.cpp b/app/SlotTable.cpp index 7b62090..7b91666 100644 --- a/app/SlotTable.cpp +++ b/app/SlotTable.cpp @@ -3,6 +3,7 @@ #include "SlotTable.h" +#include "RefusalWords.h" #include "Strings.h" #include @@ -292,8 +293,10 @@ void SlotTable::cellClicked(int row, int columnId, const juce::MouseEvent& e) onOneShotToggled(slotOfRow(row)); return; } - // Same gesture for the Count-In cell. - if (columnId == kCountIn && onCountInToggled && slotOfRow(row) > 0 && allowed().countIn) { + // Same gesture for the Count-In cell — unless the core would refuse the + // click (CountIn.hpp, #149): then the dot is a lamp, and its hover says why. + if (columnId == kCountIn && onCountInToggled && slotOfRow(row) > 0 && allowed().countIn + && !rows_[static_cast(row)].info.countInRefused) { onCountInToggled(slotOfRow(row)); return; } @@ -305,6 +308,19 @@ void SlotTable::cellClicked(int row, int columnId, const juce::MouseEvent& e) } } +// The sentence a seven-pixel dot cannot carry: hovering the Count-In cell of +// a memory whose click the core would refuse says why, in the card's words +// (RefusalWords.h). Every other cell says nothing. +juce::String SlotTable::getCellTooltip(int row, int columnId) +{ + if (columnId != kCountIn || slotOfRow(row) == 0) + return {}; + const SlotRow& r = rows_[static_cast(row)]; + if (!r.info.countInRefused) + return {}; + return words::countInRefused(*r.info.countInRefused, r.info.rhythm.beat); +} + // --- per-row busy indication --- void SlotTable::setBusySlot(int slot) @@ -532,9 +548,12 @@ void SlotTable::paintCell(juce::Graphics& g, int row, int columnId, int width, i if (columnId == kOneShot || columnId == kCountIn) { // The cell is the toggle: filled = on, hollow = off (click flips it). // On a card this app only reads it is a lamp, not a button: the - // same dot, dimmed, and a click does nothing. + // same dot, dimmed, and a click does nothing. So is a Count-In dot + // whose click the core would refuse (CountIn.hpp, #149). const bool on = columnId == kOneShot ? r.info.oneShot : r.info.countIn; - const bool clickable = columnId == kOneShot ? allowed().oneShot : allowed().countIn; + const bool clickable = columnId == kOneShot + ? allowed().oneShot + : allowed().countIn && !r.info.countInRefused; const float d = 7.0f; const float x = static_cast(area.getX()) + 2.0f; const float y = (static_cast(height) - d) * 0.5f; diff --git a/app/SlotTable.h b/app/SlotTable.h index 92cb9f6..c661f3a 100644 --- a/app/SlotTable.h +++ b/app/SlotTable.h @@ -108,6 +108,7 @@ class SlotTable final : public juce::Component, void selectedRowsChanged(int lastRowSelected) override; void cellDoubleClicked(int row, int columnId, const juce::MouseEvent&) override; void cellClicked(int row, int columnId, const juce::MouseEvent&) override; + juce::String getCellTooltip(int row, int columnId) override; juce::var getDragSourceDescription(const juce::SparseSet& selectedRows) override; // Internal row drag (the swap gesture). File drags use the interface above. diff --git a/tests/rhythm_pane_harness.cpp b/tests/rhythm_pane_harness.cpp index d24b3d3..481c42f 100644 --- a/tests/rhythm_pane_harness.cpp +++ b/tests/rhythm_pane_harness.cpp @@ -21,6 +21,9 @@ // the caption saying why, and only PATTERN is a lamp; the switch is a // lamp exactly where its click would need a pattern number, and the // line beside it says why before the click +// - a slot at a beat the manual's list does not even have (BEAT is +// inferred past its one anchor) is shown, not thrown on: the caption +// says the number is not in the list, and a State-only switch is offered // - a busy tab is a lamp until the job is done; no slot means no controls #include "support.hpp" @@ -89,7 +92,9 @@ bool paneSays(juce::Component& pane, const juce::String& fragment) // Then four at 6/4 (Beat 4), the beat whose list the hardware showed is not // ours (#147): slot 10 holds 3 — the pedal's own Rock2 at 6/4 — with drums // on; slot 11 the same with a count-in in front; slot 12 holds 57, off; slot -// 13 holds 19 — the number the pedal could not name — off. +// 13 holds 19 — the number the pedal could not name — off. Then two at Beat +// 17, a number the manual's list lacks: slot 14 holds 3, off; slot 15 holds +// 57, off. std::vector rowsOf() { std::string text = testkit::syntheticMemoryText(); @@ -115,6 +120,10 @@ std::vector rowsOf() set(11, "RHYTHM", "PlayCount", rc0::kRhythmPlayCount1Meas); set(12, "RHYTHM", "Pattern", rc0::kRhythmPatternBlank); set(13, "RHYTHM", "Pattern", 19); + set(14, "RHYTHM", "Beat", 17); + set(14, "RHYTHM", "Pattern", 3); + set(15, "RHYTHM", "Beat", 17); + set(15, "RHYTHM", "Pattern", rc0::kRhythmPatternBlank); std::vector rows; for (auto& info : catalog::listSlots(text)) rows.push_back({ std::move(info), "", "", { "" } }); @@ -433,6 +442,30 @@ int main() CHECK_EQ(heard.calls, 15); CHECK(heard.last.pattern.has_value() && *heard.last.pattern == 12); + // --- a beat the list lacks: shown and said, never thrown on --- + // Reaching the next line at all is the first check: setSlot paints the + // caption and the cost line, and a throw there would end the harness. + pane.setSlot(&rows[13]); + CHECK(captionReads(pane, "PATTERN (BEAT 17 is not in the manual's list)")); + CHECK(!pattern->isEnabled()); + CHECK_EQ(pattern->getText(), "3"); + CHECK_EQ(beat->getSelectedId(), 0); // no item for 17: the box names nothing + CHECK(beat->isEnabled()); // and the way out is still the player's + CHECK(toggle->isEnabled() && !toggle->isOn()); // State alone: offered + CHECK(!paneSays(pane, "BEAT 17")); + press(); + CHECK_EQ(heard.calls, 16); + CHECK_EQ(heard.slot, 14); + CHECK(heard.last.on.has_value() && *heard.last.on); + // Holding 57 there, "on" would need a pattern number: a lamp, and the + // line tells the truth about the number. + pane.setSlot(&rows[14]); + CHECK(!toggle->isEnabled()); + CHECK(paneSays(pane, "BEAT 17, not in the manual's list")); + CHECK(paneSays(pane, "on the pedal")); + press(); + CHECK_EQ(heard.calls, 16); + // --- no slot again: the controls go away --- pane.setSlot(nullptr); CHECK(!toggle->isVisible() && !pattern->isShowing()); diff --git a/tests/slot_table_harness.cpp b/tests/slot_table_harness.cpp index d3d391a..d26543e 100644 --- a/tests/slot_table_harness.cpp +++ b/tests/slot_table_harness.cpp @@ -85,7 +85,21 @@ int main() SlotTable table; table.setSize(900, 400); std::vector rows; - for (auto& info : catalog::listSlots(testkit::syntheticMemoryText())) + // Slot 2 (row 1) sits at 6/4 (Beat 4) with its rhythm silent: the + // count-in's "on" there would write a 4/4 pattern number, which the core + // refuses (#149). Slot 3 (row 2) is at 6/4 with the rhythm playing, + // where the count alone is still the player's. + std::string text = testkit::syntheticMemoryText(); + const auto set = [&text](int slot, const char* tag, long long value) { + text = rc0::replaceSlotBody( + text, slot, rc0::setSectionField(rc0::slotBody(text, slot), "RHYTHM", tag, value)); + }; + set(2, "Beat", 4); + set(2, "Pattern", 3); + set(3, "Beat", 4); + set(3, "Pattern", 3); + set(3, "State", rc0::kRhythmStateOn); + for (auto& info : catalog::listSlots(text)) rows.push_back({ std::move(info), "", "", { "" } }); table.setRows(rows); @@ -157,6 +171,21 @@ int main() doubleClick(nameColumn); CHECK_EQ(activations, 1); // unchanged: the edit path, no play + // A Count-In dot whose click the core would refuse is a lamp on an RC-5 + // too, and its hover says why in the card's words; a dot that can be + // clicked, and every other cell, says nothing. + model->cellClicked(1, countInColumn, clickAt(table)); + CHECK_EQ(countInClicks, 1); + CHECK(model->getCellTooltip(1, countInColumn).contains("6/4")); + CHECK(model->getCellTooltip(1, countInColumn).contains("switch the rhythm on first")); + model->cellClicked(2, countInColumn, clickAt(table)); + CHECK_EQ(countInClicks, 2); + CHECK(model->getCellTooltip(2, countInColumn).isEmpty()); + CHECK(model->getCellTooltip(0, countInColumn).isEmpty()); + CHECK(model->getCellTooltip(1, oneShotColumn).isEmpty()); + model->cellClicked(1, oneShotColumn, clickAt(table)); // One Shot is not beat-relative + CHECK_EQ(oneShotClicks, 2); + // A right-click is the context menu on either card — a read of the // situation, never a write — so it is not gated here. int menus = 0; From c88cdab90b23485692264251e54ad5afd12f8667 Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Sun, 4 Oct 2026 16:50:01 +0200 Subject: [PATCH 6/7] Guide: the count-in waits at time signatures other than 4/4 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. --- docs/guide.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/guide.md b/docs/guide.md index 91843d9..6f3b5d0 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -129,7 +129,11 @@ memory. No power cycling. you switch it on: a *silent* rhythm section is what the count borrows, and it will replace that pattern. A rhythm that is actually playing is left alone — the count simply joins it, and switching the count off later leaves - your groove where it was. + your groove where it was. At a time signature other than 4/4 the switch + waits where the click would have to write a rhythm pattern number — only + the 4/4 pattern list is charted so far — and the card says what to do + instead; the Rhythm tab shows such a pattern as a number for the same + reason. - **One Shot** — play once and stop instead of looping. Both switches are also the lamps in the table, if you keep those columns on: From 2d5041d72fb94a69785bc90ceba85994a13700fe Mon Sep 17 00:00:00 2001 From: Alisa Lafoks Date: Sun, 4 Oct 2026 17:11:21 +0200 Subject: [PATCH 7/7] BEAT stays at 4/4 while the count-in borrows the rhythm section 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. --- app/RefusalWords.h | 11 +++- app/RhythmPane.cpp | 16 ++++-- core/include/loopercat/usecases/Beat.hpp | 2 +- core/include/loopercat/usecases/CountIn.hpp | 2 +- core/include/loopercat/usecases/Rhythm.hpp | 26 +++++++-- tests/rhythm_pane_harness.cpp | 22 +++++++- tests/usecase_count_in_tests.cpp | 4 +- tests/usecase_rhythm_tests.cpp | 58 ++++++++++++++++++++- 8 files changed, 124 insertions(+), 17 deletions(-) diff --git a/app/RefusalWords.h b/app/RefusalWords.h index 0607702..a2946d7 100644 --- a/app/RefusalWords.h +++ b/app/RefusalWords.h @@ -53,12 +53,21 @@ inline juce::String countInRefused(usecases::countin::Refusal why, long long bea + " would replace the pattern: switch the rhythm on first."; case usecases::countin::Refusal::offReturnsSection: return "Switching off at " + beatLabel(beat) - + " would reset the pattern: set it on the pedal."; + + " would reset the pattern: switch the count-in off on the pedal instead."; } jassertfalse; return {}; } +// The BEAT caption and the line beside the switch while the count-in +// borrows the rhythm section at 4/4 (Rhythm.hpp, beatHeldByCountIn): the +// caption in the shape the take's lock takes, the line saying the way out. +inline juce::String beatCaptionHeld() { return "BEAT (held by the count-in)"; } +inline juce::String beatHeldByCountIn() +{ + return "BEAT stays at 4/4 while the count-in is on: switch the count-in off first."; +} + // The PATTERN caption at a beat whose list is not charted, in the shape BEAT // takes under a take ("BEAT (fixed by the take)"). inline juce::String patternCaptionLocked(long long beat) diff --git a/app/RhythmPane.cpp b/app/RhythmPane.cpp index ffcd0a4..6770f12 100644 --- a/app/RhythmPane.cpp +++ b/app/RhythmPane.cpp @@ -205,9 +205,13 @@ void RhythmControls::refresh() kit_.setSelectedId(static_cast(values_.kit) + 1, juce::dontSendNotification); beat_.setSelectedId(static_cast(values_.beat) + 1, juce::dontSendNotification); variation_.setSelectedId(static_cast(values_.variation) + 1, juce::dontSendNotification); - // The manual's rule, said where it applies: a recorded take fixes the beat. - beat_.setEnabled(isEnabled() && !values_.beatLocked); - beatCaption_.setText(values_.beatLocked ? "BEAT (fixed by the take)" : "BEAT", + // The manual's rule, said where it applies: a recorded take fixes the + // beat. And ours (Rhythm.hpp, #149): while the count-in borrows the + // section at 4/4, BEAT stays — the way out is on the pane's line. + beat_.setEnabled(isEnabled() && !values_.beatLocked && !values_.beatHeldByCountIn); + beatCaption_.setText(values_.beatLocked ? juce::String("BEAT (fixed by the take)") + : values_.beatHeldByCountIn ? words::beatCaptionHeld() + : juce::String("BEAT"), juce::dontSendNotification); // A field being typed in is the user's, not ours. @@ -341,11 +345,13 @@ void RhythmPane::refresh() const auto refused = rhythm::switchRefusal(v, info_.countIn, !v.on); if (refused) switch_.setEnabled(false); - // Otherwise the groove that "off" would forget: only with a count-in in - // front of playing drums does off mean Blank rather than State off + // Otherwise the BEAT that stays while the count-in borrows the section, + // or the groove that "off" would forget: only with a count-in in front + // of playing drums does off mean Blank rather than State off // (Rhythm.hpp). That click is refused at an uncharted beat, so the name // is looked up only where the list is charted. cost_.setText(refused ? words::rhythmSwitchRefused(*refused, v.beat) + : v.beatHeldByCountIn ? words::beatHeldByCountIn() : v.on && info_.countIn ? "Switching it off keeps the count-in and forgets " + name(rhythm::patternName(v.pattern)) + "." diff --git a/core/include/loopercat/usecases/Beat.hpp b/core/include/loopercat/usecases/Beat.hpp index 97cbc47..7aec4e1 100644 --- a/core/include/loopercat/usecases/Beat.hpp +++ b/core/include/loopercat/usecases/Beat.hpp @@ -71,7 +71,7 @@ inline std::string label(long long n) { if (const auto named = nameIfListed(n)) return std::string(*named); - return "BEAT " + std::to_string(n) + ", not in the manual's list"; + return "BEAT " + std::to_string(n) + " (not in the manual's list)"; } } // namespace loopercat::usecases::beat diff --git a/core/include/loopercat/usecases/CountIn.hpp b/core/include/loopercat/usecases/CountIn.hpp index fea67e9..9369bf3 100644 --- a/core/include/loopercat/usecases/CountIn.hpp +++ b/core/include/loopercat/usecases/CountIn.hpp @@ -109,7 +109,7 @@ inline std::string refusalText(Refusal why, long long atBeat) case Refusal::offReturnsSection: return "Switching the count-in off at " + beat::label(atBeat) + " would hand the rhythm back the factory pattern, a 4/4 number, and " + fact - + ": set it on the pedal."; + + ": switch the count-in off on the pedal instead."; } throw Error("unknown count-in refusal " + std::to_string(static_cast(why))); } diff --git a/core/include/loopercat/usecases/Rhythm.hpp b/core/include/loopercat/usecases/Rhythm.hpp index 0c763a3..c9c171c 100644 --- a/core/include/loopercat/usecases/Rhythm.hpp +++ b/core/include/loopercat/usecases/Rhythm.hpp @@ -45,12 +45,21 @@ // and shown, never named, and no edit writes — neither this // feature's nor the count-in's. The next uncharted edge is the reading // itself: isOn() still takes 57 for Blank at every beat, a 4/4 fact used -// for display only, until the other lists are charted. +// for display only, until the other lists are charted. 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. // // One rule the manual states outright (p. 10): BEAT cannot be changed after // a track is recorded. A slot with an indexed take refuses a Beat edit; the // bar arithmetic that assumes 4/4 (Params.hpp, Commands.hpp) is #92's, and -// this feature leaves every length field alone. +// this feature leaves every length field alone. And one rule of ours +// (#149): BEAT cannot leave 4/4 while the count-in borrows the rhythm +// section (State on, Blank, count on) — at any other beat the count-in's +// hand-back and this switch would both need a pattern number, and BEAT +// itself would be the only way back: a memory stuck in the app. Every +// other BEAT move keeps the pattern bytes exactly, and a move to 4/4 is +// never refused. #pragma once @@ -222,6 +231,9 @@ struct Values { long long toneLow; // −10..+10, as on the screen long long toneHigh; // −10..+10, as on the screen bool beatLocked; // a take is recorded: BEAT is read-only (manual p. 10) + bool beatHeldByCountIn; // at 4/4 with the count-in's own shape in the section + // (State on, Blank, count on): BEAT cannot leave 4/4, + // or the memory is stuck in the app (#149) bool operator==(const Values&) const = default; }; @@ -229,8 +241,9 @@ struct Values { inline Values read(std::string_view slotBody) { const long long beat = detail::rhythm(slotBody, "Beat"); + const long long pattern = detail::rhythm(slotBody, "Pattern"); return { isOn(slotBody), - detail::rhythm(slotBody, "Pattern"), + pattern, patternListCharted(beat), detail::rhythm(slotBody, "Kit"), beat, @@ -239,7 +252,9 @@ inline Values read(std::string_view slotBody) detail::rhythm(slotBody, "Reverb"), toneOnScreen(detail::rhythm(slotBody, "ToneLow")), toneOnScreen(detail::rhythm(slotBody, "ToneHigh")), - beatLocked(slotBody) }; + beatLocked(slotBody), + patternListCharted(beat) && countin::isOn(slotBody) + && pattern == rc0::kRhythmPatternBlank }; } // --- the beat's list, or none --- @@ -374,6 +389,9 @@ inline std::string apply(std::string_view slotBody, const Edits& edits) if (beatLocked(slotBody)) throw Error("BEAT cannot be changed once a track is recorded (RC-5 reference " "manual p. 10)"); + if (!patternListCharted(*edits.beat) && read(slotBody).beatHeldByCountIn) + throw Error("BEAT cannot leave 4/4 while the count-in borrows the rhythm section: " + "switch the count-in off first"); } // PATTERN is chosen from the list of the beat the memory will be at once // this edit lands — its own, or the one the edit carries — and so is the diff --git a/tests/rhythm_pane_harness.cpp b/tests/rhythm_pane_harness.cpp index 481c42f..aec1559 100644 --- a/tests/rhythm_pane_harness.cpp +++ b/tests/rhythm_pane_harness.cpp @@ -94,7 +94,8 @@ bool paneSays(juce::Component& pane, const juce::String& fragment) // on; slot 11 the same with a count-in in front; slot 12 holds 57, off; slot // 13 holds 19 — the number the pedal could not name — off. Then two at Beat // 17, a number the manual's list lacks: slot 14 holds 3, off; slot 15 holds -// 57, off. +// 57, off. Slot 16 is 4/4 in the count-in's own shape (State on, Blank, +// count on), where BEAT must stay. std::vector rowsOf() { std::string text = testkit::syntheticMemoryText(); @@ -124,6 +125,9 @@ std::vector rowsOf() set(14, "RHYTHM", "Pattern", 3); set(15, "RHYTHM", "Beat", 17); set(15, "RHYTHM", "Pattern", rc0::kRhythmPatternBlank); + set(16, "RHYTHM", "State", rc0::kRhythmStateOn); + set(16, "RHYTHM", "Pattern", rc0::kRhythmPatternBlank); + set(16, "RHYTHM", "PlayCount", rc0::kRhythmPlayCount1Meas); std::vector rows; for (auto& info : catalog::listSlots(text)) rows.push_back({ std::move(info), "", "", { "" } }); @@ -461,11 +465,25 @@ int main() // line tells the truth about the number. pane.setSlot(&rows[14]); CHECK(!toggle->isEnabled()); - CHECK(paneSays(pane, "BEAT 17, not in the manual's list")); + CHECK(paneSays(pane, "BEAT 17 (not in the manual's list)")); CHECK(paneSays(pane, "on the pedal")); press(); CHECK_EQ(heard.calls, 16); + // --- 4/4 with the count-in's own shape in the section: BEAT stays --- + // State on, Blank, count on: moved away from 4/4 the memory would be + // stuck, so BEAT is a lamp with the way out on the line; PATTERN and the + // switch are still the player's at 4/4. + pane.setSlot(&rows[15]); + CHECK(!beat->isEnabled()); + CHECK(captionReads(pane, "BEAT (held by the count-in)")); + CHECK(paneSays(pane, "switch the count-in off first")); + CHECK(pattern->isEnabled() && kit->isEnabled()); + CHECK(toggle->isEnabled() && !toggle->isOn()); + choose(*beat, 4); // a lamp reports nothing, whoever reaches past its greyed face + CHECK_EQ(heard.calls, 16); + CHECK_EQ(beat->getText(), "4/4"); + // --- no slot again: the controls go away --- pane.setSlot(nullptr); CHECK(!toggle->isVisible() && !pattern->isShowing()); diff --git a/tests/usecase_count_in_tests.cpp b/tests/usecase_count_in_tests.cpp index edb0948..34febd1 100644 --- a/tests/usecase_count_in_tests.cpp +++ b/tests/usecase_count_in_tests.cpp @@ -294,7 +294,9 @@ int main() CHECK_THROWS(after = countin::apply(borrowed, false), "on the pedal"); CHECK(after.empty()); CHECK_THROWS(countin::apply(atBeat(bodyWith(0, 0, kRock2At64), 17), true), - "BEAT 17, not in the manual's list"); + "at BEAT 17 (not in the manual's list) would"); + CHECK_THROWS(after = countin::apply(borrowed, false), + "switch the count-in off on the pedal instead"); CHECK_THROWS(countin::apply(atBeat(bodyWith(0, 0, kRock2At64), -1), true), "BEAT -1"); } diff --git a/tests/usecase_rhythm_tests.cpp b/tests/usecase_rhythm_tests.cpp index 476d6cb..36615bf 100644 --- a/tests/usecase_rhythm_tests.cpp +++ b/tests/usecase_rhythm_tests.cpp @@ -716,7 +716,7 @@ int main() CHECK(!v.patternCharted); CHECK_EQ(v.pattern, kRock2At64); CHECK(!usecases::beat::nameIfListed(odd).has_value()); - CHECK_EQ(usecases::beat::label(odd), "BEAT " + number + ", not in the manual's list"); + CHECK_EQ(usecases::beat::label(odd), "BEAT " + number + " (not in the manual's list)"); CHECK_THROWS(rhythm::beatName(odd), "BEAT " + number); // A State-only switch passes: no pattern number is needed for it. CHECK(!rhythm::switchRefusal(v, false, true).has_value()); @@ -729,7 +729,8 @@ int main() const std::string blank = atBeat(bodyWith(0, 0, rc0::kRhythmPatternBlank), odd); CHECK(rhythm::switchRefusal(rhythm::read(blank), false, true) == rhythm::SwitchRefusal::onNeedsGroove); - CHECK_THROWS(rhythm::applySwitch(blank, true), "BEAT " + number + ", not in the manual's list"); + CHECK_THROWS(rhythm::applySwitch(blank, true), + "at BEAT " + number + " (not in the manual's list) needs"); // A pattern edit is refused, before any byte moves; the rest of the // card is still the player's, and so is the way out: BEAT itself. std::string after; @@ -742,5 +743,58 @@ int main() CHECK(rhythm::read(rhythm::apply(body, { .beat = kFourFour })).patternCharted); } + // --- BEAT stays at 4/4 while the count-in borrows the rhythm section --- + // + // State on, Blank, count on is the count-in's own shape. Moved away from + // 4/4 it is stuck in the app: the hand-back and the rhythm's switch would + // both need a pattern number there, and BEAT itself would be the only + // way back. So that one move is refused before any byte moves; every + // other BEAT move keeps the pattern bytes as it did, and a move TO 4/4 + // is never refused. + { + const std::string borrowed = bodyWith(rc0::kRhythmStateOn, rc0::kRhythmPlayCount1Meas, + rc0::kRhythmPatternBlank); + CHECK(rhythm::read(borrowed).beatHeldByCountIn); + std::string after; + CHECK_THROWS(after = rhythm::apply(borrowed, { .beat = kSixFour }), "BEAT cannot leave 4/4"); + CHECK(after.empty()); + CHECK_THROWS(after = rhythm::apply(borrowed, { .beat = 16 }), "switch the count-in off first"); + CHECK(after.empty()); + CHECK_THROWS(after = rhythm::apply(borrowed, { .kit = 3, .beat = kSixFour }), + "BEAT cannot leave 4/4"); + CHECK(after.empty()); + // The same beat is not a move, and the rest of the card is still the player's. + CHECK(rhythm::apply(borrowed, { .beat = kFourFour }) == borrowed); + CHECK(onlyTheseFieldsMoved(borrowed, rhythm::apply(borrowed, { .kit = 3 }), { "Kit" })); + // The way out is the count-in's own switch: off, and BEAT moves again. + const std::string released = usecases::countin::apply(borrowed, false); + CHECK(!rhythm::read(released).beatHeldByCountIn); + CHECK(onlyTheseFieldsMoved(released, rhythm::apply(released, { .beat = kSixFour }), + { "Beat" })); + // Not held: a count over a groove — 4/4 -> 6/4 passes and 12 stays 12; + // a rhythm on with Blank but no count; the factory slot. + const std::string counted + = bodyWith(rc0::kRhythmStateOn, rc0::kRhythmPlayCount1Meas, kRock2At44); + CHECK(!rhythm::read(counted).beatHeldByCountIn); + const std::string moved = rhythm::apply(counted, { .beat = kSixFour }); + CHECK_EQ(rhythmField(moved, "Pattern"), kRock2At44); + CHECK(onlyTheseFieldsMoved(counted, moved, { "Beat" })); + CHECK(!rhythm::read(bodyWith(rc0::kRhythmStateOn, 0, rc0::kRhythmPatternBlank)) + .beatHeldByCountIn); + CHECK(!rhythm::read(factory).beatHeldByCountIn); + // A memory already stuck at 6/4 is not "held" — the hold is a 4/4 + // fact — and BEAT back to 4/4 is never refused; nor is a move between + // two uncharted beats, which changes nothing about it. + const std::string stuck = atSixFour(borrowed, rc0::kRhythmPatternBlank); + CHECK(!rhythm::read(stuck).beatHeldByCountIn); + const std::string home = rhythm::apply(stuck, { .beat = kFourFour }); + CHECK(rhythm::read(home).beatHeldByCountIn); + CHECK(onlyTheseFieldsMoved(stuck, home, { "Beat" })); + CHECK(onlyTheseFieldsMoved(stuck, rhythm::apply(stuck, { .beat = 5 }), { "Beat" })); + // Under a take the manual's refusal comes first. + CHECK_THROWS(rhythm::apply(withTake(borrowed), { .beat = kSixFour }), + "BEAT cannot be changed"); + } + return testkit::summary("usecase_rhythm_tests"); }