Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,6 @@ desktop.ini
# NOTE: pyquadcortex/protocol/proto/*_pb2.py are generated but INTENTIONALLY committed,
# so that installing the package needs no protoc toolchain. Do not ignore them.
# See docs/architecture.md.

# Hardware-suite working artifacts. The findings go in docs/, not here.
tests/hardware/captures/
50 changes: 42 additions & 8 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,14 +145,48 @@ types decoded, with a liveness heartbeat proving the link was up. But all three
listened, and none of them asked. **A control the device never announces may
still answer a READ.** Nothing has tried one.

So MODE is an open protocol investigation rather than a settled dead end. Nothing
in the library changes: there was no MODE surface before and there is none now.
The reason it is worth an entry is that the withdrawn claim is what the coverage
audit and the model design were both written against. The correction is in
`docs/protocol.md`, `docs/manual-coverage.md`, `docs/capture.md` and
`docs/domain-model.md`, and the decision it forced - that the model shows a
control it cannot yet drive and refuses it, rather than omitting it or guessing -
is ADR-0007.
So MODE is an open protocol investigation rather than a settled dead end. **It was
asked, and it answered** - see the next entry.

### The Tempo menu's MODE switch is readable and writable

`qc.tempo_mode()` returns a `TempoMode` - `PRESET` or `GLOBAL` - and
`qc.set_tempo_mode(TempoMode.GLOBAL)` moves the switch. It is the DEVICE tempo
block's parameter 1, carried in `GlobalTempo.params`.

**This is a global setting**, despite riding a tempo message. It affects every
preset and there is nothing to save afterwards, so read it first if you intend to
put it back. It does not move either tempo block: the unit keeps the preset's
settings and the device's at the same time, and MODE only picks which one plays.

The entry above withdrew the claim that this control was not on the wire. It was
on the wire the whole time - though not via a naive READ, see the caveat below. The three tests that found nothing were measuring
something real and narrower - the unit emits no CHANGE EVENT when the switch moves -
and the mistake was reading that as "cannot be asked". The current value in fact
rides the tempo stream the unit sends anyway. Confirmed on the wire, on the unit's
own screen, and by the tempo actually in effect, which switched between the two
blocks' stored values.

The method that found it - capture the whole readable state in each position
and diff, rather than looking for the field you expect - is now what ADR-0010
requires before any control is written down as having no wire path.

Watch out for one thing if you read `GlobalTempo` yourself: it alternates two
message shapes, one carrying the running clock and one carrying the 25
parameters. Wait for a reply that actually has parameters. Taking the first
`GlobalTempo` to arrive is what produced the original dead end.

### `TEMPO` takes bpm: the span is 40 to 240

`set_tempo_param("TEMPO", real=120)` now works, and `tempo_bpm()` /
`bpm_to_tempo()` convert if you want the numbers directly. Previously `real=`
was refused here, because the catalog publishes a placeholder range for this
parameter and converting against it gives a number that means something else.

The span was measured off the screen instead: 59 bpm at `0.095`, 111 at `0.355`,
120 at `0.400`, each exact to the displayed integer. The endpoints are the fit's
rather than driven, and they land on the 40-240 range the unit's manual
documents.

### Regenerating the protobuf bindings can no longer walk the pin backwards

Expand Down
17 changes: 17 additions & 0 deletions docs/ADR.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,20 @@ Records are append-only once `Decided` and built upon: a shipped decision is nev
- `send` is deliberately not refused: it is fire-and-forget and cannot deadlock. A listener that writes owns the delay it adds to the read loop.
- Registering in time for the connect handshake's burst needs `protocol.connect(before_handshake=...)`, because the burst arrives after `connect()` returns.
- Existing behaviour is untouched: listeners consume nothing, and `send`/`request`/`collect`/`await_broadcast` answer exactly as they did with no listener registered.

## ADR-0010: A control with no known wire path gets a bounded search before it is modelled as refused

- **Status:** Decided (2026-08-12)
- **Decision:** ADR-0007's rule stands and is unchanged: a control we understand but cannot drive is modelled and refuses. What changes is what has to happen first. Before a control is recorded as having no wire path, it gets a **differential state capture**: read everything the device will answer, in each position of the control, and diff the two - including field numbers the recovered schema does not know. "No broadcast was observed" is not a finding about a wire path and does not on its own justify the refusal. TEMPO MODE, the case that raised ADR-0007, is no longer an instance of it: `Tempo.mode` is an ordinary readable, writable property.
- **Context:** ADR-0007 recorded that three tests had watched for a broadcast when the MODE switch moves and seen nothing, that this had been over-read as "not on the wire at all", and that the honest state was an open investigation. It was right about all of that, and the investigation took one session. **MODE is the DEVICE tempo block's parameter 1**, carried in `GlobalTempo.params`: `0.0` PRESET, `1.0` GLOBAL, readable by `GlobalTempo{READ}` and writable by a `GlobalTempo{UPDATE, params{index: 1, param_values}}`. Confirmed three ways - the wire value moved and moved back with nothing else in the device's readable state moving either way; a host write moved the unit's own menu, watched at the unit; and the tempo in effect switched between the two blocks' stored values, 111 bpm from the preset's `0.355` and 120 from the device's `0.400`, both exact on a 40-240 range. `protocol.md`, "MODE is the DEVICE tempo block's parameter 1", is the record.
- **Options:**
- **(a) Require a differential capture before recording "no wire path" - chosen.** Costs one session per control. The cost is bounded and known, because it is now a harness (`tests/hardware/state_snapshot.py`) rather than a bespoke experiment.
- **(b) Leave ADR-0007 as it stands.** Its rule is sound, and the failure was not in the rule. But nothing in it required anyone to ASK before concluding, and the eight releases the wrong claim survived were the cost of that gap.
- **(c) Treat every unfound control as merely undiscovered and model nothing until found.** Collapses back into design principle 3's omission, which ADR-0007 rejected for good reasons that have not changed.
- **Open Questions:** ADR-0007's - how a refusal reads in practice - is now unforced, because the model has no refused control left. It stays open and gets settled by the first genuine instance rather than by TEMPO MODE.
- **Rationale:** The three tests were good instruments honestly reported. What went wrong is that a listener answers "does the device announce this?" and the conclusion drawn was "is this on the wire?" - a different question, never asked. Diffing rather than hunting is what makes the search bounded: the earlier work looked for the field it expected in the message it expected, so a field one index away in a message shape it had already written off was invisible to it. Requiring the capture also removes the incentive to reach for ADR-0007 as an easier answer than another hardware session.
- **Consequences:**
- `Tempo.mode` is readable and writable, and is a **device** setting - it affects every preset and there is nothing to save. It ships with the rest of `Tempo` at M3. `QuadCortex.tempo_mode()` / `set_tempo_mode()` and `TempoMode` exist at the protocol layer now.
- ADR-0007 keeps its status and its rule. It currently has no instance, which is the healthy state for it.
- Epic #8's dependency on the TEMPO MODE wire path is resolved. It never gated M1; it no longer gates M3.
- A negative result about device traffic now states which question the instrument answered. "The unit does not announce X" and "X is not on the wire" are separate claims and the second needs a READ.
15 changes: 15 additions & 0 deletions docs/STEERING.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Decisions for this area are recorded in [`ADR.md`](ADR.md):
| ADR-0007 | The model may represent a control whose wire path is still open |
| ADR-0008 | The generator floor joins the bindings/pin unit, with a gate at regeneration and a CI check on the pin |
| ADR-0009 | Persistent listeners run on the RX thread, which may not read from the device |
| ADR-0010 | A control with no known wire path gets a bounded search before it is modelled as refused |

## 8. Open Questions

Expand Down Expand Up @@ -121,6 +122,20 @@ Single-device, single-connection USB HID at interactive rates (129-byte reports)

## Change Log

### 2026-08-12 - TEMPO MODE closes, and ADR-0010

**What changed:**
- **The Tempo menu's MODE switch is readable and writable.** It is the DEVICE tempo block's parameter 1, carried in `GlobalTempo.params`: `0.0` PRESET, `1.0` GLOBAL. `QuadCortex.tempo_mode()` / `set_tempo_mode()` and the `TempoMode` enum ship at the protocol layer; `docs/protocol.md` gains "MODE is the DEVICE tempo block's parameter 1" and a coverage-table row
- ADR.md: ADR-0010 - a control with no known wire path gets a differential state capture before it is recorded as having none. ADR-0007's rule is unchanged and now has no instance, which is the healthy state for it
- `docs/domain-model.md`: `Tempo.mode` stops being refused and becomes an ordinary property; §13's *Genuinely open* loses its first entry and the *Closed* table records where the answer lives; both appendix tempo rows updated. `manual-coverage.md` gains a MODE row and its tally moves to 104 / 65 yes
- `docs/capture.md` gains "Diff the whole state, do not hunt for a field" - the method that found it, and the four things in the harness that are load-bearing. Its listener chapter, which used this claim as its exemplar, now carries the ending
- **`TEMPO`'s span fits 40..240 bpm**, from three INTERIOR screen-vs-wire points measured during the same session, exact to the displayed integer at each. The endpoints are the fit's, not driven. `real=` on that parameter now takes bpm, via `tempo_bpm()` / `bpm_to_tempo()`; `protocol.md`'s placeholder-span list now has two of its eight parameters' spans measured and seven covered; splitter `FREQUENCY` is the one still unrecovered
- `tests/hardware/state_snapshot.py` is the harness, reusable for the next control of this kind. It subscribes through `Transport.add_listener` (ADR-0009), which landed in the same release and is exactly the hook it needs - the first version predated it and monkey-patched `_dispatch`; `tests/test_state_snapshot.py` proves offline that it can see an unknown field number, a presence-tracked zero, and a value in only one of two message shapes

**Why:**
- The wire path was a named dependency of Epic #8 and a prerequisite of M3's device-settings work. Three earlier tests had established that the unit never BROADCASTS the switch, which had been over-read as "not on the wire"; a READ found it in one session
- The method is the durable part. Earlier attempts hunted for the field they expected, in the messages they expected; MODE was one index away inside a message shape the investigation had already written off. Diffing the whole answerable state finds a thing without knowing where to look

### 2026-08-12 - A persistent broadcast subscription at the protocol layer, and ADR-0009

**What changed:**
Expand Down
46 changes: 43 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ already read and need no connection; calling them as methods raises
| **Footswitches** | `set_stomp_assignment(row, column, footswitch)`, `set_stomp_momentary()`, `set_stomp_label()`, `protocol.stomp_assignments(preset)` |
| **Expression pedals** | `set_expression(row, column, param, pedal, minimum, maximum)` |
| **Preset MIDI Out** | `set_midi_out(source, [MidiOut.cc(...)])`, `set_preset_load_midi_out([...])`, `protocol.midi_out(preset)` |
| **Tempo MODE** | `tempo_mode()`, `set_tempo_mode(TempoMode.GLOBAL)` - global, and it picks which tempo block plays |
| **Per-preset tempo** | `set_tempo_param(name, ...)`, `set_tempo_option(name, n)`, `protocol.tempo_params(preset)`, `set_tempo_led(on)`, `set_metronome_volume(v)` |
| **Metronome** | `set_tempo_subdivision()`, `set_metronome_sound()`, `set_metronome_routing()`, `set_time_signature()` - all taking full enums |
| **Per-beat accents** | `set_beat(n, MetronomeBeat.ACCENT)`, `set_beats([...])`, `protocol.beats(preset)` |
Expand Down Expand Up @@ -256,14 +257,53 @@ pv = param_state(preset, row=0, column=3, param_index=0) # .scene_mode, .value

## Per-preset tempo and the metronome

Each preset carries its own tempo block, separate from the global tempo:
Each preset carries its own tempo block, separate from the global tempo. **The unit
holds both at once, and the Tempo menu's MODE switch picks which one plays:**

```python
qc.set_tempo_led(False) # this preset's TEMPO LED off
qc.set_metronome_muted(True) # silence the click - the unit's own MUTE
from pyquadcortex.protocol import TempoMode

qc.tempo_mode() # TempoMode.PRESET or TempoMode.GLOBAL
qc.set_tempo_mode(TempoMode.GLOBAL) # run every preset on the device's tempo
```

`set_tempo_mode` is **global**: it affects every preset and there is nothing to save,
so read it first if you mean to put it back. It moves neither tempo block.

Which block you HEAR follows MODE - measured, on one unit minutes apart: 111 bpm under
PRESET from the preset's stored `0.355`, 120 under GLOBAL from the device's `0.400`. The
setters below address the preset's block by construction, since they write
`tempoProgramData`, so writing one while MODE is GLOBAL should store a value you will not
hear until you switch back. That last step is inferred from those two facts rather than
measured, so treat it as a caution and not as a verified behaviour.

The device emits no CHANGE EVENT when the switch moves, but the current value
rides the ambient `GlobalTempo` params push (measured: twice per 14-second window),
so a state tracker CAN follow it - it just cannot be told the moment it moves.
`tempo_mode()` waits for a reply carrying parameters rather than the running clock,
which can take a few seconds.

**A read straight after a write returns the previous value, and "a moment" is not
enough.** This message type does not echo `request_id` - zero of 64 captured pushes
carried one - so `tempo_mode()` returns the next ambient params push, which may have
been generated before your write. That shape arrives only about every seven seconds,
so wait longer than that: **ten seconds** is what the hardware suite uses. Measured
the hard way - a write followed by a 3-second settle read back the old value, while
the write had in fact landed.

The per-preset controls:

```python
qc.set_tempo_param("TEMPO", real=120) # bpm - three points fit a 40..240 span
qc.set_tempo_led(False) # this preset's TEMPO LED off
qc.set_metronome_muted(True) # silence the click - the unit's own MUTE
qc.set_tempo_param("TIME SIGNATURE", value=0.1)
```

`TEMPO` is the one tempo parameter whose `real=` comes from a measurement rather than
the catalog, which publishes a placeholder range for it. `tempo_bpm()` and
`bpm_to_tempo()` convert if you need the numbers directly.

Use `set_metronome_muted` and not the volume to silence a click:
`set_metronome_volume(0.0)` is **-60 dB, quiet but still audible**, not silence.

Expand Down
8 changes: 5 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,9 +480,11 @@ next, roughly in order of how well the ground is prepared:
`RecentsFavorites`, `PresetDirty`, `Updater`, `ModelRepo` and others are
decoded and pushed to us but have no API. These are the cheapest additions:
the type already exists in the registry, so it is one client method plus
tests. (`GlobalTempo` is a special case: it is global rather than per preset and
only ever returned a running clock, so the useful per-preset tempo controls live
in `tempoProgramData` instead - see `set_tempo_param`.)
tests. (`GlobalTempo` is a special case: it is global rather than per preset,
and it alternates a clock shape with a 25-parameter shape, so a reader has to
match on a reply that actually carries parameters. Its parameter 1 is the Tempo
menu's MODE switch - see `tempo_mode`. The per-preset tempo controls live in
`tempoProgramData` instead - see `set_tempo_param`.)
- **Types not in the registry at all.** The schema declares 71 message types.
Whole feature areas are untouched: `Tuner` / `ShowTuner`, `Looper`,
`MIDISettings`, `NeuralCapture` / `NeuralCapture2`, `Screenshot`,
Expand Down
Loading
Loading