diff --git a/docs/PLAN.md b/docs/PLAN.md index 8c5bc97..ef0c1e5 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -220,40 +220,62 @@ in Γ-W4-T1 and changes no wave boundary. *"supporting smoothness at this micro scale would turn this into a wavetable synth, which is desirable."* The product framing is `docs/product/single-cycle-and-wavetable.md`. - **It framed EIGHT [Daniel]-class forks. ONE is now ruled and SEVEN remain open**, so it - still gets a pointer here rather than a phase section: `PLAN.md` is the active on-deck - specification list, and a phase whose specification rests on seven unruled forks is - neither briefable nor consistent with the standing claim above that **Λ is the only phase - in this plan with unanswered [Daniel]-class questions**. That claim is unaffected by this - item — there is no phase here to except. When the remaining forks are ruled, the doc - becomes a phase's backing product doc in the ordinary way (the Γ / Ε / Ρ / Λ pattern, not - the doc-less Ψ / Ω one). + **It framed EIGHT [Daniel]-class forks; THREE rulings have since landed against it, and it + now carries TWELVE forks — ONE settled and ELEVEN open.** So it still gets a pointer here + rather than a phase section: `PLAN.md` is the active on-deck specification list, and a + phase whose specification rests on eleven unruled forks is neither briefable nor consistent + with the standing claim above that **Λ is the only phase in this plan with unanswered + [Daniel]-class questions**. That claim is unaffected by this item — there is no phase here + to except. When the remaining forks are ruled, the doc becomes a phase's backing product + doc in the ordinary way (the Γ / Ε / Ρ / Λ pattern, not the doc-less Ψ / Ω one). - **The ruling — Daniel, 2026-08-03:** *"I don't want actual frame-indexed wave tables yet, - that is out of scope. just static captures right now."* It closes fork F5 (the - wavetable-position knob does not ship now), confirms the multi-frame wavetable kind out by - ruling rather than only by the doc's argument, and **narrows the near-term scope to the - doc's W0 — loop-grid recalibration for micro material, which is defect-class work with no - new parameter, no format change and no `process()` involvement.** *"Yet"* is recorded as a - scope ruling, not a rejection; the doc keeps the deferred case whole and names the single - trigger that would revive it. **The ruling does NOT settle band-limiting** — the question - that decides it is still outstanding with Daniel, and nothing in the doc pre-rules it. Per - the doc's §7.1. + **R1 — Daniel, 2026-08-03:** *"I don't want actual frame-indexed wave tables yet, that is + out of scope. just static captures right now."* It closes fork F5 (the wavetable-position + knob does not ship now) and confirms the multi-frame wavetable kind out by ruling rather + than only by the doc's argument. *"Yet"* is recorded as a scope ruling, not a rejection; + the doc keeps the deferred case whole and names the single trigger that would revive it. - **Two things in it are decision-grade for work already in this plan, which is why it is - flagged rather than merely filed:** + **R2 — Daniel, 2026-08-03**, answering the doc's own outstanding question (*is the material + ever anything but sine?*): *"yes, complex waveforms must be supported purely."* **It closes + fork F6 IN — band-limited playback is in scope** — and reads *purely* as *faithfully, + without artifacts the capture does not contain*. Consequences that matter to this plan: + + - **The near-term scope is now the doc's W0 AND W2, in that order** — W0 (loop-grid + recalibration; defect-class, no new parameter, no format change, no `process()` + involvement) and then W2, which consumes W0's declared period. + - **W2 is the first item in that doc that reaches `process()`.** The per-sample read binds + a level-indexed buffer rather than a fixed member: no branch, no dispatch, no virtual — + so this plan's `process()` guardrail is satisfied in kind — but `voice.h` is header-inline + with no LTO configured, so it is a **measurement obligation, not an argument**. A W2 track + with no before/after number has an opinion, not an acceptance criterion. + - **It re-opens a one-way door and re-contends the deck headroom.** W2 wants a declared + mode: a permanent VST3 parameter id on Γ-W4-T1's frozen table, and a control against + `instrument-control-surface.md` §1.6's 82 px. The doc's §6.8/§6.9 cost both, including + the caption-slack slot that may make the control free. + - **R2 does NOT rule the shape.** Four new forks (F9 table shape, F10 level selection, F11 + interpolator, F12 Preserve coverage) are minted open with a recommended package. + + **R3 — Daniel, 2026-08-03**, on the Phase Ω conflict this item used to record: *"call it + part of omega, that was a fence you built not me."* **The conflict is RESOLVED, and the + resolution is recorded in Phase Ω itself** — the phase's zero-crossing-snap criterion and + Ω-W1-T5's two criterion sites are amended in place. The snap-radius work is **Ω-W2-T5** + (branch `omega-w2-t5-snap-radius`, `b7b7e88`); the fence it appeared to break was a scope + constraint on Ω's own tracks, not a product ruling, and Ω-W1-T5's work is not invalidated + by it. R3 also creates a documentation gap — **Ctrl-defeats-snap on a marker drag** is a new + user-visible gesture that `docs/product/instrument-control-surface.md` §4.1 currently + contradicts — flagged for doc-keeper at Ω-W2-T5's note in this file. + + **What remains decision-grade for work already in this plan:** - - **The immediate defect it came from is NOT its subject.** `nearestZeroCrossing` - (`src/core/instrument/ui/waveform_view.cpp:181-210`) fans out across the whole buffer - with no radius bound; a snap-radius track is in flight against it. **But Phase Ω's own - acceptance criteria name the zero-crossing snap as a thing that "must come out the other - side identical", and Ω-W1-T5's outline entry repeats it.** A snap-radius change alters - exactly that behaviour. **Whoever owns Ω's criteria must reconcile the two** — this item - records the conflict, it does not resolve it. - **Sequencing, if any of it is ever dispatched.** Every candidate touches `waveform_view.{h,cpp}`, `editor_input_waveform.cpp` and `editor_controls.cpp`'s - `waveMarksFor` / `grabbableMarks` — Ω-W1-T5's exact surface. Nothing from that doc - should dispatch until Ω-W1-T5 and the snap-radius track have both landed. + `waveMarksFor` / `grabbableMarks` — Ω-W1-T5's exact surface. **Nothing from that doc + dispatches until Ω-W1-T5 has landed**, and it should build on Ω-W2-T5's radius rule + rather than replacing it. + - **F8 is re-opened by R2, not narrowed.** W0 and W2 now differ in kind — one is + migration-clean defect work, the other a measured DSP build with a frozen parameter and + five unruled forks — so "two phases rather than one" became a serious option again. **If + F9–F12 are unruled when W0 is ready, do not let W0 wait on them.** ## Phase-wide acceptance criteria @@ -353,6 +375,14 @@ statements become untrue the moment this phase lands: **Do not re-litigate these.** There is no product doc to index them in; this table is their one home, and a brief for any Ω track is written against it. +**One later ruling binds this phase from OUTSIDE that table, and is named here so the table is +not read as complete: R3 (Daniel, 2026-08-03)** — *"call it part of omega, that was a fence you +built not me"* — which makes the snap-radius work **Ω-W2-T5** and supersedes this phase's +zero-crossing-snap criterion. It is recorded in `docs/product/single-cycle-and-wavetable.md` +§7.1 rather than here, because it arose against that doc; it is deliberately **not** renamed +into the `Ω-F` series, since one ruling with two identifiers is worse than one ruling with a +cross-reference. + | Ruling | What it settled | Bound into | |---|---|---| | **Ω-F1** | **Widen the marker-alignment work to the correct scope.** The defect is the overlay↔waveform coordinate mapping as a whole, not the START mark — a class defect with four independent causes, inherited by every overlay | Ω-W1-T5 | @@ -371,9 +401,16 @@ hope. These bind every track in this phase, in addition to the plan-wide set above. - **No behaviour changes outside the named defect.** Every track carries that criterion - explicitly. The Gate deck face, the zero-crossing snap, the ballistics constants, the 500 ms - bank/bake poll body and the audio thread are each named below as things that must come out - the other side identical. + explicitly. The Gate deck face, the ballistics constants, the 500 ms bank/bake poll body and + the audio thread are each named below as things that must come out the other side identical. + **The zero-crossing snap was named here too, and that clause is SUPERSEDED — ruling R3, + Daniel, 2026-08-03:** *"call it part of omega, that was a fence you built not me."* It was a + **scope fence** — written to stop Ω's own tracks perturbing the snap while they fixed the + coordinate mapping — never a finding that `nearestZeroCrossing`'s unbounded fan-out was + correct. Daniel's DAW evidence (a one-cycle 60 Hz capture whose loop start teleports to the + half-cycle) supersedes it. **The superseding track is Ω-W2-T5 `snap-radius`**, which bounds + the snap to `kMarkerGrabWidth` and adds Ctrl-defeats-snap on a marker drag. The fence still + binds every OTHER Ω track: no track but Ω-W2-T5 may change the snap. - **Nothing is added to any hot path.** No track here touches `process()`, the `peaks` envelope compute, audition, or the realtime-capture tick. Ω-W1-T3's work is entirely UI-thread paint cost; Ω-W1-T1's is entirely main-thread REAPER API cost. @@ -808,11 +845,20 @@ stays 1:1 with the sample's time axis" **is currently false by 2 px.** - **De-inset the waveform** — `[propose]`, **requires justification before it is taken**: it touches the shared `draw_kit`, which also serves the docked bank panel and the browse-card thumbnails, so its blast radius is three surfaces rather than one. -- **The zero-crossing snap must be preserved EXACTLY.** `nearestZeroCrossing` - (`waveform_view.cpp:170-199`) fans out symmetrically, probing `t−d` before `t+d` so an +- **The zero-crossing snap must be preserved EXACTLY *by this track*.** `nearestZeroCrossing` + (`waveform_view.cpp:181-210`) fans out symmetrically, probing `t−d` before `t+d` so an equidistant tie resolves to the lower frame; it runs on the mono cache at drag time and is exempt for the crossfade handle. **Behaviour-identical after the change** is an acceptance criterion, not an assumption. + + > **Amended, ruling R3 (Daniel, 2026-08-03) — read this as scope, not as a verdict on the + > snap.** This clause bound T5 to change the coordinate mapping WITHOUT perturbing the snap, + > and **T5 met it: the fan-out it received is the fan-out it returned, and its tie-rule + > fixtures pass unmodified.** The snap's behaviour was subsequently changed on purpose by a + > DIFFERENT track — **Ω-W2-T5 `snap-radius`**, which bounds the fan-out to + > `kMarkerGrabWidth` — so a later reader finding a bounded snap in the tree is looking at + > Ω-W2-T5's work, not at a T5 violation. **Bounding which frames are REACHABLE does not + > change how a reachable tie RESOLVES**, which is why T5's fixtures survive it untouched. - **One question the track must ANSWER, not dodge:** what "sample-accurate" means when `frames < columns` and one frame spans many pixel columns. A marker must land somewhere inside its own frame's column span; which end, and whether the inverse `xToFrame` @@ -828,6 +874,9 @@ stays 1:1 with the sample's time axis" **is currently false by 2 px.** at the last frame, and at an interior frame, asserted in the pure fixtures. - `frames < columns` and `frames > columns` are both covered. - The zero-crossing snap returns the identical frame for the identical input, ties included. + **(Scope criterion on THIS track, discharged. Superseded phase-wide by R3 — the snap's + radius bound is Ω-W2-T5's deliberate change, and this track's tie-rule fixtures pass + unmodified under it.)** - Trigger shows no loop marks and no loop caption; Gate-with-loop-off shows grey marks that still drag, and dragging one still turns the enable on. - `docs/product/instrument-control-surface.md` §6.3(d) and §6.4 agree with the shipped @@ -848,8 +897,27 @@ fix** (the Λ-W3 precedent). | **T1** `view-topology-cache` | `src/core/view/` — tree / `parentOf` / handle-map memoization | | **T2** `content-detect-throttle` | `src/shell/panel/panel_input.cpp` — the 30 Hz detector's cadence | | **T3** `omega-verification-sweep` | `docs/VERIFICATION.md` — the DAW record for all of Ω-W1 | +| **T5** `snap-radius` | `src/core/instrument/ui/waveform_view.{h,cpp}` + the marker-drag input path — **spec'd nowhere; see below** | -**Shared files in the wave:** none. The three tracks touch three disjoint directories. +**Shared files in the wave:** none. T1–T3 touch three disjoint directories, and T5 touches a +fourth (Ω-W1-T5's surface, which lands first). + +**Ω-W2-T5 has no spec section here, on purpose.** It arrived from ruling R3 — Daniel, +2026-08-03, recorded in `docs/product/single-cycle-and-wavetable.md` §7.1 and summarized at +"Flagged for awareness" item 4 above — after this phase was written, and is **committed on +branch `omega-w2-t5-snap-radius` (`b7b7e88`)**: it +bounds `nearestZeroCrossing`'s fan-out to `kMarkerGrabWidth` (5 px) and adds +**Ctrl-defeats-snap** on a marker drag. **Numbered T5 rather than T4 because it inherits +Ω-W1-T5's file surface; there is no Ω-W2-T4.** Two things it needs from other owners: + +- **Flagged for doc-keeper:** its record belongs in `docs/COMPLETED.md`. This plan names it + only so the phase's own superseded criteria can point at what superseded them. +- **Flagged for doc-keeper — a documentation gap, not a defect.** *Ctrl-defeats-snap on a + marker drag* is a new **user-visible gesture** with no home in + `docs/product/instrument-control-surface.md`, and §4.1's exclusion block there — which + excludes waveform markers from the modifier law because *"overloading Shift there would + collide with a shipped gesture"* — now reads as a claim that markers carry no modifier at + all. That block needs amending and the gesture needs documenting. #### Ω-W2-T1 — `view-topology-cache` *(measurement-gated on Ω-W1-T1's result)* diff --git a/docs/product/single-cycle-and-wavetable.md b/docs/product/single-cycle-and-wavetable.md index f576787..94f1662 100644 --- a/docs/product/single-cycle-and-wavetable.md +++ b/docs/product/single-cycle-and-wavetable.md @@ -10,28 +10,52 @@ ReaSampler 9000 in REAPER on a literal single-cycle 60 Hz sine capture: > harmonics from the shape distortion. supporting smoothness at this micro scale would turn > this into a wavetable synth, which is desirable."* -**The immediate defect — `nearestZeroCrossing`'s unbounded fan-out — is owned by a -snap-radius track already in flight and is NOT this doc's subject.** It is described here -only where the reasoning needs it, because the defect and the direction share a diagnosis -and the fix for one does not deliver the other. +**The immediate defect — `nearestZeroCrossing`'s unbounded fan-out — is owned by +Ω-W2-T5 `snap-radius` and is NOT this doc's subject.** It is described here only where the +reasoning needs it, because the defect and the direction share a diagnosis and the fix for one +does not deliver the other. -Status: **framed by product-designer 2026-08-03; ONE ruling has since landed against it — -R1, recorded in full at §7.1.** Daniel, 2026-08-03: +Status: **framed by product-designer 2026-08-03; THREE rulings have since landed against it — +R1, R2 and R3, recorded in full at §7.1.** + +**R1 — Daniel, 2026-08-03:** > *"I don't want actual frame-indexed wave tables yet, that is out of scope. just static > captures right now."* R1 **closes F5** (W1, the wavetable-position knob, does not ship now) and **confirms W3 out -from Daniel** rather than only from this doc's argument. **Seven forks remain open** (§7). +from Daniel** rather than only from this doc's argument. -**R1 does NOT rule F6 / band-limiting.** The question that decides F6 — *is the material ever -anything but sine?* — is still outstanding, Daniel knows it is outstanding, and it is -deliberately not pre-ruled anywhere below. Nothing in R1 touches aliasing: a *static* -single-cycle capture of anything but a sine, played across the keyboard, is exactly the case -§1.8 describes. +**R2 — Daniel, 2026-08-03**, answering this doc's own second outstanding question (*is the +material ever anything but sine?*): -**The near-term scope is therefore W0, and only W0.** Read §2 as one body of work plus two -deferrals, not as four equal candidates. +> *"yes, complex waveforms must be supported purely."* + +**R2 closes F6 IN, toward band-limiting — W2 is in scope.** *"Purely"* is read here as +**faithfully: without artifacts the capture does not contain**, and §1.8's aliasing is +precisely the artifact that reading names. What that commits to, what shape it can take and +what it costs is worked out at §1.8 and §2's W2 section. **It is the one item in this doc +that reaches `process()`** — the post-R1 claim that the near-term scope was entirely +load-time and UI-thread is **RETIRED**, and §6.6 states what replaces it. + +**R3 — Daniel, 2026-08-03**, on the Phase Ω conflict this doc recorded at §6.7: + +> *"call it part of omega, that was a fence you built not me"* + +**R3 dissolves that conflict rather than sequencing around it.** The snap-radius work IS a +Phase Ω track (Ω-W2-T5); Ω's *"the zero-crossing snap comes out the other side identical"* +criterion was a **scope fence** built to stop Ω's own tracks perturbing the snap while they +fixed the coordinate mapping — never a product ruling that the snap's behaviour was correct — +and Daniel's DAW evidence supersedes it. §6.7 is now a record, not an open item. + +**The near-term scope is therefore W0 AND W2** — two bodies of work, one deferral (W1), one +confirmed out (W3). They are not peers: under the recommended shape W2 **consumes W0's +declared period** (§1.8), so the wave order is forced rather than chosen. + +**TWELVE forks: ONE settled (F5), ELEVEN open.** R2 narrows F6 to a binary and mints four new +forks — **F9–F12**, carrying W2's shape — because the shape is genuinely unruled and is +Daniel's to rule, not this doc's to assume. §7 states a recommended package he can take or +override fork by fork. **No `PLAN.md` phase exists for this work and none is proposed until F8 is ruled** — `PLAN.md`'s "Flagged for awareness" item 4 is the only pointer to this doc, deliberately, so @@ -57,33 +81,39 @@ is a **recalibration plus a period grid**, not a synthesis engine. "wavetable synth," and it is not the loop — it is band-limiting.** A 30 Hz single cycle of anything but a pure sine, played at C5, resamples by ~17× and folds every partial above ~1.4 kHz. Daniel's test case is a sine, which is exactly the one waveform that hides this. -Real wavetable synths solve it with a mip pyramid of per-octave lowpassed tables. That is a -real build with a real migration cost, and it is separable from everything else here. -**After R1 it is also the only one of the four shapes whose scope is still undecided.** +Real wavetable synths solve it with per-octave lowpassed tables. That is a real build with a +real migration cost. **R2 rules it IN.** It is no longer this doc's hedge; it is half the +work, and §1.8's second half is where its cost is actually derived. **The four shapes, and where each now stands:** | | What | Cost | Standing | |---|---|---|---| -| **W0** | Recalibrate the loop grid for micro material — period snap, whole-file default, tune-from-loop, Preserve inert | No format change, no bank field, no hot-path change, migration-clean | **THE NEAR-TERM SCOPE** | +| **W0** | Recalibrate the loop grid for micro material — period snap, whole-file default, tune-from-loop, Preserve inert | No format change, no bank field, no hot-path change, migration-clean | **NEAR-TERM SCOPE, first** | | **W1** | Loop start becomes a **wavetable position** — scrub the loop through a multi-cycle capture in whole periods | One parameter that re-expresses one that exists; reuses `period_detect` unchanged | **DEFERRED — R1, 2026-08-03** | -| **W2** | **Band-limited playback** — mip pyramid + a better interpolator | Real DSP build; changes the sound of existing captures unless made conditional | **OPEN — F6, undecided** | +| **W2** | **Band-limited playback** — per-octave lowpassed tables + a better interpolator | Real DSP build; **the one item that reaches `process()`**; needs a declared mode to hold the migration bar | **NEAR-TERM SCOPE, second — IN by R2.** Shape unruled: F9–F12 | | **W3** | A first-class multi-frame **wavetable kind** — N×2048 tables, morph parameter, `.wav` wavetable import | Breaks "one loaded capture, one parameter set" | **OUT — confirmed by R1** | +**W0 first is not a preference, it is a dependency.** The recommended W2 build (F9(a)) builds +each level by zeroing the partials above its limit in the table's own DFT — which is exact +only when the table is a whole number of periods, i.e. only when W0's declared period exists. +Take a different F9 and the dependency weakens; take F9(c) and it inverts. §1.8. + **R1 is a scope ruling, not a rejection of the idea.** *"Yet"* is Daniel's word and it is load-bearing: W1's §2 section is preserved in full, argument intact, so a future reader can pick it back up rather than re-derive it. What would revive it is named there. **W0 stands entirely on its own merits and always did.** It is defect-class work — Daniel cannot currently set a correct loop on his own routine material — and none of its five parts -depends on a wavetable ambition. R1 does not shrink W0; it removes what sat on top of it. +depends on a wavetable ambition. R1 does not shrink W0; R2 does not enlarge it. -**And the operational headline: this direction lands in the exact files Ω-W1-T5 -`overlay-mapping` owns right now**, and Phase Ω's own acceptance criteria name the -zero-crossing snap as a thing that "must come out the other side identical" (`PLAN.md`, -Phase-Ω acceptance criteria; Ω-W1-T5's outline entry says *"Zero-crossing snap -behaviour-identical"*). Both the in-flight snap-radius track and anything from this doc -contend with that. §6.7 states what needs reconciling. +**And the operational headline, rewritten by R3: the Phase Ω collision is settled, and the +sequencing constraint that survives it is a file-ownership one, not a criteria one.** +Everything here lands in the files Ω-W1-T5 `overlay-mapping` owns, so nothing from this doc +dispatches until that track has landed. The *criteria* conflict — Ω naming the zero-crossing +snap as behaviour-identical while a snap-radius change alters exactly that — is dissolved: +the snap-radius work is Ω's own Ω-W2-T5, and the fence it appeared to break was a scope +fence, now superseded. §6.7. --- @@ -112,8 +142,8 @@ shrinks toward one cycle. Each is read out of the code, not assumed. `nearestZeroCrossing` (`src/core/instrument/ui/waveform_view.cpp:181-210`) fans out from the target frame across the entire buffer with no radius bound. On an 800-frame single-cycle sine there are two crossings, so a mark parked in the last quarter by `defaultLoopBounds` -teleports to the one at the half-cycle. That is Daniel's octave-doubling exactly, and the -radius track in flight closes it. +teleports to the one at the half-cycle. That is Daniel's octave-doubling exactly, and +Ω-W2-T5's radius bound closes it. **But the deeper point survives the radius fix.** The snap's premise is that a zero crossing is where a splice is *safe* — a heuristic borrowed from destructive editing of complex @@ -275,7 +305,7 @@ Fork **F7** decides whether the surface says so (grey/refuse Preserve when the l period) or leaves the user to discover it. Auto-switching engines is rejected outright below — it would change the sound of a saved instance. -### 1.8 Aliasing — the real boundary, and the one Daniel's sine hides +### 1.8 Aliasing — the real boundary, and the one Daniel's sine hid until R2 `Voice::advanceFrame` reads with **linear interpolation** between the two bracketing source frames at `readPos_`, advancing by `ratio_` (`voice.h:622-627, 705`). Two independent @@ -290,23 +320,95 @@ deficiencies, both invisible on a sine: top octave plus imaging. Acceptable for a sample played near its root; conspicuous for a table played several octaves up, which is the wavetable use case by definition. -**The standard answer, and it is what makes a wavetable synth a wavetable synth: a mip -pyramid.** Per-octave successively lowpassed copies of the table, precomputed at load, with -the read level chosen per voice from the ratio. Its properties fit this codebase unusually -well: - -- Precompute is **cold and off the audio thread** — exactly where `period_detect` already - sits, by link graph (`period_detect.h:5-7`). -- Level selection is **per note-on**, not per sample: no dispatch on the hot path, satisfying - structural heuristic 3 and the `process()` guardrail. -- Memory is bounded at ~2× the table. +**The standard answer, and it is what makes a wavetable synth a wavetable synth: per-octave +successively lowpassed copies of the table, precomputed at load, with the read level chosen +per voice from the playback ratio.** Precompute is **cold and off the audio thread** — +exactly where `period_detect` already sits, by link graph (`period_detect.h:5-7`) — and the +levels are DERIVED from the audio, so like `sourcePeriodFrames` they are cache and not state: +nothing persists them and they take no rung of the payload ladder. Raising the read to cubic Hermite is a separate, straight-line 4-tap change with no -indirection — also guardrail-safe, but it changes the sound of *every* capture. +indirection. Note one property that matters for its migration story: at `frac == 0` a cubic +Hermite read returns `pcm[i0]` exactly as the linear read does, so **a capture played at its +root with no offsets is bit-identical under either interpolator**; only a repitched read +changes. **Both collide head-on with the plan-wide migration bar** ("a project saved before a change -reopens sounding identical"). They must therefore be **conditional and declared**, not -default. Fork **F6**. +reopens sounding identical"). Fork **F6**, which R2 narrows to *conditional or unconditional* +rather than *whether at all*. + +#### 1.8.1 What R2 actually commits to, worked out rather than assumed + +R2's word is *purely*, and the honest reading is **faithfully — the instrument must not add +artifacts the capture does not contain.** Four consequences follow, and three of them +retire claims this doc made after R1. + +**(1) It reaches `process()`, and that is unavoidable.** Precompute is load-time, but +*selecting* a level is per-voice and *reading* it is per-sample: `advanceFrame` binds +`pcm` from `sample_->frames` (`voice.h:448-449`) and reads it every frame. Band-limiting +means that bind resolves to a level-indexed buffer instead of a fixed member. That is **not a +branch, not a virtual, and not a dispatch** — root `CLAUDE.md`'s guardrail language forbids +indirection on the per-voice-per-sample path, and an indexed vector bind is none of those — +but it IS a change to the hottest loop in the program, in a header that inlines with no LTO +configured. **It must be measured, not argued.** This is the first item in this doc that +cannot be discharged by reasoning about the code. + +**(2) The ratio is not constant within a note, so "select per note-on" is an approximation, +not a design.** `ratio_ = baseRatio_ * envFactor` (`voice.h:652`) — the pitch envelope +multiplies the read increment per frame, and the Pitch knob is a LIVE control re-applied to a +sounding voice (`pitchOffsetRatio_`, `voice.h:722`). A note latched at its base ratio spends +a +12 semitone pitch-envelope peak an octave under-filtered, which is exactly the artifact R2 +forbids. Fork **F10** carries the four available answers; the cheap ones are wrong at the +peak and the correct ones cost either dullness or a second read tap. + +**(3) Preserve is a different resampler and a mip level does not reach it.** Under Preserve +the voice's read advances at `stretchRate_` (`voice.h:616`) and the transposition happens +inside the SOLA shifter's **fractional ring read, linear interp** (`pitch_shift.h:184`). +A level chosen from the voice's read ratio therefore does nothing there. Covering Preserve +means feeding the ring from a level chosen by the *shift* ratio — a second build, inside a +module whose artifact budget is already dominated by its splice. Fork **F12**. Whichever way +it goes, **§2's claim that W2 "improves every ordinary sample played more than an octave +above its root" is true only for Varispeed unless F12 goes to (b)**, and that qualification +belongs in the claim rather than in a footnote. + +**(4) It needs a declared mode, which is a frozen parameter id and a deck slot.** §6.5, §6.8 +and §6.9 are each re-derived below; all three of the post-R1 reliefs are spent. + +#### 1.8.2 The 30 Hz case makes band-limiting HARDER in depth and EASIER in kind — and the easier half decides it + +Daniel's sub-50 Hz practice is first-class (§1.2 of this doc's framing, §1.3's argument), so +this interaction is worked out rather than deferred. + +**Harder — depth and fold count.** The level count needed is the octaves of upward playback. +A 30 Hz table played to C8 (4186 Hz) is 7.1 octaves → **eight levels**; a 440 Hz table to C8 +is 3.25 octaves → four. Capturing low doubles the pyramid's depth. It also maximizes the fold +count: at C5 a 30 Hz source keeps only `24000/523.25 ≈ 45` partials un-aliased out of the up +to `24000/30 = 800` it can carry. For a saw-like 1/n spectrum the folded energy is roughly +1.3 % of total power (≈ −19 dB) — audible hash, not a theoretical concern. + +**Easier — and this is the decisive half.** At exactly one period the table's DFT bins **are** +its harmonics. Each level can therefore be built by transforming, zeroing every partial above +that level's limit, and transforming back: **exact, with no filter design, no transition band +and no phase error.** That is how the Serum/Vital class of synth builds its tables (§5), and +it requires knowing the period exactly. §1.5 already establishes that at the single-cycle +limit the period is **DECLARED** — it is the file length. **The very property that makes +detection structurally impossible is what makes exact band-limiting possible.** A 220 Hz +capture (218.2 frames per cycle, not an integer) has no such alignment and needs a real +filter design instead. + +**And the depth 30 Hz forces is free exactly where it is forced.** Under the same-rate table +shape (F9(a)) eight levels of a 1600-frame mono table is 12,800 floats ≈ 51 KB. Depth is +cheap precisely because a low capture's table is short in absolute terms. It is the *long* +capture that is expensive — and the instrument loads **any** bank capture, a full-mix stem +bounce included, where `SampleData::frames` is a `std::vector` per channel of the +whole file. Eight same-rate levels of a three-minute stereo bounce is hundreds of megabytes. +**So the pyramid must be gated on length or on the micro classification, and that gating is +what collides with §3's never-change-the-sound constraint** — see §3. + +**One floor worth stating because it makes the migration argument cheap:** at or below the +root there is nothing to fold (`ratio ≤ 1`), so level 0 is the unmodified capture and a note +played at root is bit-identical with band-limiting on. The pyramid is an upward-only +mechanism. --- @@ -380,18 +482,39 @@ The optional second half — **interpolating between adjacent period-frames** fo rather than a step — costs a second read tap on the per-sample path and must be checked against the `process()` guardrail before it is promised. Fork **F5** splits scrub from morph. -### W2 — Band-limited playback — UNAFFECTED by R1, still open +### W2 — Band-limited playback — IN SCOPE by R2 (2026-08-03) -**R1 does not touch this and must not be read as touching it.** Band-limiting is a property -of playing *any* short table across the keyboard; whether the table's frames evolve is -irrelevant to whether its partials fold. A static single-cycle capture of a saw, played at -C5, aliases exactly as §1.8 describes. **F6 remains open and unruled.** +**Daniel, 2026-08-03: *"yes, complex waveforms must be supported purely."*** A static +single-cycle capture of a saw, played at C5, aliases exactly as §1.8 describes; *purely* +forbids that. **F6 is no longer "does this ship" — it is "conditional or unconditional".** -§1.8. Orthogonal to W0 and W1, and the only one of the four that is a genuine DSP build. It -is also **independently valuable outside this direction**: it improves every ordinary sample -played more than an octave above its root, which is a much larger population than -single-cycle captures. That argues for judging it on its own merits rather than as -wavetable-support. Fork **F6**. +It remains the only one of the four shapes that is a genuine DSP build, and it is +**independently valuable outside this direction**: it improves every ordinary sample played +more than an octave above its root, a much larger population than single-cycle captures — +**for Varispeed voices, and for Preserve voices only if F12 goes to (b)** (§1.8.1(3)). + +**It is no longer orthogonal to W0.** Under the recommended shape the levels are built by +zeroing partials above each limit in the table's own DFT, which is exact only over a whole +number of periods — so W2 consumes the declared period W0 establishes (§1.8.2). W0 first is +therefore a dependency, not a preference. Under F9(b) the dependency softens to a preference; +under F9(c) it inverts, because a resynthesized table supplies its own period. + +**The four shape decisions, each a fork, with the recommended package named.** They are +genuinely independent axes, so Daniel can take the package in one word or override any single +one: + +| Fork | Axis | Recommended | Why, and what it costs | +|---|---|---|---| +| **F6** | Conditional or unconditional | **(b) gated behind a declared mode** | Unconditional breaks the migration bar for every saved instance that plays above its root. The mode costs a frozen parameter id (§6.8) and a deck slot (§6.9) | +| **F9** | The table's shape | **(a) same-rate filtered copies** | Zero index-domain change — loop points, `startFrame`, the crossfade tap and `sourcePeriodFrames` all keep their meaning. Costs `L`×`N` memory, so it must be gated on capture length | +| **F10** | When the level is chosen | **(c) per-block reselection** | Matches the standing "observation is at block boundaries, never per frame" discipline; no per-sample branch. Costs a small spectral step at a level change | +| **F11** | Does the interpolator upgrade ride the same lever | **(c) cubic Hermite under the same mode** | One lever, one migration event; the two are the same complaint about reconstruction quality on a repitched read | +| **F12** | Does Preserve get covered | **(a) Varispeed only, for now** | Cheap, and defensible at micro scale where Preserve is definitionally wrong anyway (§1.7). Costs the unqualified version of the "improves every ordinary sample" claim | + +**What W2 does NOT do, stated so it is not assumed:** it writes nothing to the bank, adds no +`Sample` field, and takes no payload rung for the tables themselves — they are derived cache +on the `period_detect` precedent. Only the *mode* persists, as an additive `ComponentState` +tail on the established discipline. ### W3 — A first-class wavetable KIND — OUT (recommended out; CONFIRMED by R1) @@ -439,6 +562,25 @@ derived classification may change DEFAULTS and the SNAP GRID, and may never chan SOUND.** A heuristic that picks a better starting loop is safe because the user can drag it; a heuristic that changes playback is a surprise waiting to be filed as a bug. +**R2 puts that constraint under direct pressure, and the resolution matters more than the +threshold below it.** §1.8.2 shows the band-limited tables must be gated on capture length or +on the micro classification, because eight same-rate levels of a stem bounce is hundreds of +megabytes. But if a derived classification decides *whether a capture is band-limited*, then +a heuristic decides its sound — the exact thing this section forbids. **Split the two gates +and the constraint survives intact:** + +- **The MODE decides the sound, and the mode is the user's** (F6(b)). Band-limiting is on or + off because the user said so, on any capture, of any length. +- **The classification decides only whether the tables are built EAGERLY at load.** A + non-micro capture with the mode on still gets band-limited; it just pays its build (or a + bounded-depth build) on demand rather than eagerly. That is a memory-and-latency policy, not + a sound policy. + +**This is a recommendation, not a ruling — it changes what F1 is choosing between**, because +it means F1(a) no longer has to carry a sound decision it was explicitly built not to carry. +If Daniel prefers the simpler "derived classification also gates band-limiting", §3's hard +constraint has to be relaxed explicitly rather than eroded quietly. + **A threshold derived from the codebase rather than from taste.** A capture is a micro candidate when *both*: @@ -516,6 +658,17 @@ the seam, per the comment conventions' "warnings against a plausible-but-wrong c **R1 does not defer this.** The paragraph originally attached the circularity to W1; it in fact attaches to any one-period loop, so it arrives with W0 rather than after it. +**R2 gives the DECLARED half of the split a second consumer, and it is the more demanding +one.** W0 wants the period to place a snap grid — an error of a fraction of a frame there +costs a slightly wrong default. W2's recommended build wants it to align a DFT with the +table's harmonics, where the alignment is either exact or the whole method degrades into an +approximate filter design (§1.8.2). **That raises the bar on the declaration but not on the +detection**: at the single-cycle limit the declaration is the file length, which is exact by +the capture's own exact-bounds guarantee (§6.2), and above that limit the exactness has to be +earned from a whole-period loop rather than from a detector's fractional estimate. **The two +sources of truth stay two; R2 makes the declared one load-bearing for sound, where before it +was load-bearing only for a default.** + --- ## 5. Prior art borrowed, and what each contributes @@ -524,13 +677,17 @@ fact attaches to any one-period loop, so it arrives with W0 rather than after it table." W1 is this control, transplanted onto captured material rather than authored tables. **Recorded for whoever picks W1 back up; deferred by R1, and note that the PPG precedent presumes a table whose frames differ, which static capture material does not supply.** -- **Serum / Massive / Vital** — mip-mapped, per-octave band-limited tables. This is the whole - of W2, and their universal adoption of it is the evidence that §1.8 is not a theoretical - concern. **Still live: F6 is open.** +- **Serum / Massive / Vital** — per-octave band-limited tables, built by spectral truncation + of a whole-period table rather than by filter design. This is the whole of W2, and their + universal adoption of it is the evidence that §1.8 is not a theoretical concern. **Now + load-bearing: R2 rules W2 in, and the spectral-truncation build is what F9(a) borrows.** + The transplant is not free — those synths own their table format and can guarantee a + whole-period power-of-two frame, where we inherit whatever the capture is; §1.8.2 is where + that difference is paid. - **Kontakt / EXS-class samplers' loop-finding tools** — snap a loop to a *detected period* and offer a "find loop" pass, rather than snapping only to zero crossings. This is the - established precedent for F2's period grid; we would not be inventing it. **This is now the - load-bearing precedent, since W0 is the near-term scope.** + established precedent for F2's period grid; we would not be inventing it. **The load-bearing + precedent for W0, which is the first of the two near-term bodies of work.** - **Serum's import-a-sample-as-a-wavetable flow** — it asks the user to declare or confirm the cycle length rather than detecting it from a single cycle. That is independent confirmation of §1.5's declare-vs-detect split, arrived at from the same constraint. @@ -572,45 +729,103 @@ Stated in §2 and the sole structural reason W3 is recommended out. - **W1 is migration-clean** if the position parameter defaults to the position the stored loop start already denotes. *(Deferred by R1; kept for the record.)* - **W2 is not.** Both halves change the rendered audio of existing captures. Fork **F6** - exists to decide whether that cost is paid unconditionally or made conditional and declared. + now decides only whether that cost is paid unconditionally or made conditional and declared. -**After R1 the whole near-term scope is migration-clean**, and the only migration risk left -in this doc sits behind an open fork rather than inside the work. +**R2 retires the post-R1 relief here.** The near-term scope is no longer migration-clean as a +whole; W0 still is, and W2 is not. Two properties make the bar cheap to hold rather than +expensive, and both are worth stating because they are easy to miss: -### 6.6 The hot path — HELD, and the guardrails are satisfiable by construction +- **At or below the root nothing folds**, so level 0 is the unmodified capture and a note at + root is bit-identical with the mode on (§1.8.2). +- **A cubic Hermite read at `frac == 0` returns `pcm[i0]`**, exactly as the linear read does + — so the interpolator half is also bit-identical at unity ratio (§1.8). + +**The bar therefore breaks only for repitched playback, and only with the mode on.** Under +F6(b) a saved instance reopens with the mode off and sounds identical, which is the whole +argument for (b) over (c). + +### 6.6 The hot path — HELD for W0; W2 reaches it, and the post-R1 claim here is RETIRED + +**The retired claim, stated plainly so nobody carries it forward: after R1 this section said +"the entire near-term scope is load-time and UI-thread only. Nothing in W0 goes near +`process()`." That was true of W0 and is still true of W0. It is FALSE of the near-term scope +as R2 leaves it.** - The period grid, the classification and the tune-from-loop computation are **load-time and - UI-thread only**. + UI-thread only**. W0 is unchanged by R2 in every respect. - W1's scrub is a **reload-tier or note-on-latched** parameter, not a per-sample one; if it is wanted live, it belongs in the existing three-tier commit classification (`deckParamCommit` / `liveCommitFor`) rather than in a new mechanism. *(Deferred by R1.)* -- W2's mip level selection is **per note-on**. The interpolator change is a straight-line - 4-tap with no dispatch. -- **W1's optional morph (F5c) was the one item that would have touched `process()`** — a - second read tap per frame, to be measured against the guardrail before it was promised. - **R1 closes F5 at (a), so that item is off the table for now.** +- **W2's table build is load-time**, on `period_detect`'s own link-graph precedent. **Its + level selection and its read are not.** -**Consequence of R1 worth stating plainly: the entire near-term scope is load-time and -UI-thread only. Nothing in W0 goes near `process()`.** The guardrail question that would have -needed a measurement before anything could be promised no longer gates this direction. +**What W2 costs on the per-voice-per-sample path, as precisely as this doc can state it +without a measurement:** -### 6.7 Phase Ω and the in-flight snap-radius track — the real sequencing constraint +- **The read.** `advanceFrame` binds `pcm` from `sample_->frames` (`voice.h:448-449`) every + frame. Band-limiting makes that an indexed bind into a level array. **No branch, no virtual, + no dispatch** — so root `CLAUDE.md`'s guardrail wording and structural heuristic 3 are both + satisfied in kind. But `advanceFrame` is header-inline **by RT constraint with no LTO + configured**, and whether the compiler hoists the indexed load out of the block loop is a + fact about the generated code, not about the design. **Measure it.** +- **The level selection.** Under F10(c) it is once per voice per block — the same cadence as + the live block's seqlock read, which is an established discipline rather than a new + mechanism. Under F10(d) it is a **second read tap per frame**, which is the identical cost + §6.6 flagged for W1's morph and which must clear the guardrail before it is promised. +- **The interpolator.** Two extra loads and a handful of multiply-adds per channel per frame + under F11(b)/(c), straight-line, no dispatch. The cheapest of the three to reason about and + the most expensive to un-ship, because it changes every repitched note. +- **`voice.h` is already a documented ~600-line ceiling exception**, so W2's edits there need + a responsibility seam rather than added bulk — and F9(b)'s level-relative frame domain would + add far more of it than F9(a)'s bind change. That is a structural argument for F9(a) + independent of the memory one. -This is the item most likely to cost someone a merge. +**Net: W0 is guardrail-free; W2 is the first item in this direction that a specialist must +measure before it can be accepted.** A W2 track whose acceptance criteria contain no +before/after number has an opinion, not a criterion — the same rule Phase Ω applies to its own +two performance items. -- **Ω-W1-T5 `overlay-mapping` currently owns `src/core/instrument/ui/waveform_view.{h,cpp}`, - `editor_input_waveform.cpp` and `editor_controls.cpp`'s `waveMarksFor`/`grabbableMarks`** — - which is the complete file set any period-grid work touches. -- **Phase Ω's own acceptance criteria name the zero-crossing snap as a thing that must come - out the other side identical**, and Ω-W1-T5's outline entry repeats it - (*"Zero-crossing snap behaviour-identical"*). **A snap-radius track changes exactly that - behaviour.** Whether that criterion has already been amended for the in-flight track is not - something this doc can assert — it needs reconciling by whoever owns Ω's criteria, and the - purpose of this bullet is to make sure someone does. -- **Recommended sequencing: nothing from this doc dispatches until Ω-W1-T5 and the - snap-radius track have both landed.** The period grid should be written against the - post-Ω single coordinate mapping, not against today's four-discrepancy one, and it should - build on whatever radius rule the in-flight track settles rather than replacing it. +### 6.7 Phase Ω — the criteria conflict is SETTLED by R3; the file-ownership constraint stands + +**This was the item most likely to cost someone a merge. R3 removes half of it.** + +**Daniel, 2026-08-03:** *"call it part of omega, that was a fence you built not me."* + +- **The conflict this section recorded is dissolved, not sequenced around.** The snap-radius + work **is** a Phase Ω track — **Ω-W2-T5** (branch `omega-w2-t5-snap-radius`, commit + `b7b7e88`) — which bounds the snap to `kMarkerGrabWidth` (5 px, `waveform_view.h:70`) and + adds **Ctrl-defeats-snap** on a marker drag. +- **The fence was a scope constraint, not a product ruling.** Ω's acceptance criterion naming + the zero-crossing snap among the things that "must come out the other side identical" was + written to stop Ω's *own* tracks perturbing the snap while they fixed the coordinate + mapping. It was never a finding that the snap's unbounded fan-out was correct, and Daniel's + DAW evidence supersedes it. `PLAN.md` now records the supersession at all three criterion + sites. +- **Ω-W1-T5 is not invalidated by this.** It was required to preserve the snap exactly, and it + did; its tie-rule fixtures pass unmodified under the bounded entry, because bounding the + fan-out changes which frames are *reachable*, not how a reachable tie resolves. The change + came later and from a different track. +- **What survives is a pure file-ownership constraint, and it is unchanged in form.** Every + candidate in this doc touches `waveform_view.{h,cpp}`, `editor_input_waveform.cpp` and + `editor_controls.cpp`'s `waveMarksFor`/`grabbableMarks` — Ω-W1-T5's exact surface — so + **nothing here dispatches until Ω-W1-T5 has landed**, and the period grid should be written + against the post-Ω single coordinate mapping and on top of Ω-W2-T5's radius rule rather than + replacing either. *(At the time of writing, `dev`'s `nearestZeroCrossing` + (`waveform_view.cpp:181-210`) is still the unbounded fan-out — `b7b7e88` is on its own + branch. Confirm what has actually merged before dispatching, rather than reading landed + status out of this doc.)* +- **One documentation gap R3 creates, flagged rather than filled here.** + **Ctrl-defeats-snap on a marker drag is a new user-visible gesture with no home in + `docs/product/instrument-control-surface.md`** — and worse than absent, it is *contradicted* + there: §4.1's exclusion block states that waveform markers are excluded from the modifier + law because *"overloading Shift there would collide with a shipped gesture"*, which now + reads as a claim that markers carry no modifier at all. **Flagged for doc-keeper: that + block needs amending and the gesture needs documenting.** This doc does not write that + content. +- **And one cascade into this doc's own forks: Ctrl on a waveform-mark drag is now TAKEN.** + F2's option (c) — a modifier-selected choice between the period grid and the zero-crossing + grid — can no longer use Ctrl, and Shift is already excluded on marks for the reason quoted + above. F2(c) now has to name a modifier it can actually have. ### 6.8 The frozen VST3 parameter table and the one-way doors @@ -621,11 +836,25 @@ one annotated exception from Ω-W1-T4, so a second annotation is cheap precedent freeze the moment it ships**, so a position knob's scale must be right first time. W0 adds no parameter at all, which is one more reason to take it first. -**R1 removes the near-term exposure here entirely.** With W1 deferred, the only prospective -new parameter left in this doc is W2's mode, which sits behind an open fork — so **nothing in -the near-term scope opens a one-way door.** A position knob's taper, the item that most -needed to be right first time, is a decision this direction no longer has to make under time -pressure. +**R1 removed the near-term exposure here; R2 restores it, in a much cheaper form.** W2's mode +is a new parameter, so the door is open again — but a two-state mode is the least dangerous +parameter this doc could have produced: + +- **Its taper is trivial** and its range is `{off, on}`, so `parameter-automation.md` §8's + "the taper and range freeze the moment it ships" costs nothing to get right. +- **Its id is permanent regardless.** It appends to the FOREVER-FROZEN table from Γ-W4-T1 and + is never renumbered, and `param_id.h`'s signal-flow-order comment takes a second annotated + exception — cheap precedent, already set by Ω-W1-T4. +- **The real door is the mode's SEMANTICS, not its taper.** A boolean "band-limit on/off" + can never later become a three-way quality selector without either re-tapering a frozen + parameter or minting a second id and deprecating the first. **If more than two states are + ever plausible, that has to be decided before the parameter ships, not after** — this is the + one thing about W2 that must be right first time. +- **A capture-length or depth limit is NOT a parameter.** It is an internal policy (§3), and + keeping it out of the parameter table is what stops a memory decision from freezing into the + automation surface. + +**W0 still adds no parameter at all**, which remains an argument for taking it first. ### 6.9 The editor's space budget @@ -634,40 +863,70 @@ W1's position control and W2's mode toggle both want deck real estate against tight against the ~600-line ceiling with Phase Ω spending its margin. This was a real constraint on how many of these shapes could ship together, not a footnote. -**R1 relieves it for now.** W0's five parts add no control to the deck — a snap grid, a -default, and two greyed affordances are all changes to existing surfaces — and F3's -"Tune to loop" is the one item that might want a hit target, which is a far smaller ask than -a knob. **The 82 px is uncontended by the near-term scope.** It comes back the moment F6 is -ruled toward a mode toggle, so the headroom should not be spent elsewhere on the assumption -that this direction has released it permanently. +**R1 relieved it; R2 spends it back.** W0's five parts still add no control to the deck — a +snap grid, a default, and two greyed affordances are all changes to existing surfaces — and +F3's "Tune to loop" is the one item that might want a hit target, which is a far smaller ask +than a knob. **But W2's mode toggle is exactly the control §1.6 was warning about**, and the +warning at the end of the post-R1 paragraph ("it comes back the moment F6 is ruled toward a +mode toggle") has come true. + +**The conditional-cost option, and it should be measured before any px is budgeted: a +caption-row toggle in a group with caption slack costs ZERO width.** The precedent is +FILTER's `Band|Notch`, which rides its caption slack rather than the knob row and is the +−92 px that makes the SOUND row fit; a group carries **two** caption-toggle slots for exactly +this reason, and a `rowToggle` is the expensive alternative that widens the group. Whether a +second caption slot with slack exists in a group where a band-limit mode belongs — the read +path is a pitch-side concern — is a **measurement against `deck_groups`/`knob_deck`, not an +assumption this doc may make.** If a free slot exists, W2 costs 0 of the 82 px. If not, it +costs a `rowToggle`'s width and the headroom is genuinely spent. + +**Either way the headroom should not be spent elsewhere on the assumption that this direction +released it** — R1's relief was temporary and R2 ended it. --- -## 7. Forks — EIGHT total: ONE settled (F5), SEVEN open +## 7. Forks — TWELVE total: ONE settled (F5), ELEVEN open Each carries a product-designer lean and the cost of taking it. **Every one is Daniel's to rule.** None is pre-ruled here and none should be treated as settled by the lean. Rulings recorded against this doc are logged in §7.1; the lean column below states, per fork, whether -R1 moved it. +R1 or R2 moved it. + +**F9–F12 are new, minted by R2 to carry W2's shape.** R2 ruled that band-limiting happens; it +deliberately did not rule how. Four independent axes, so the recommended package can be taken +in one word or overridden fork by fork: + +> **Recommended package: F6(b) + F9(a) + F10(c) + F11(c) + F12(a)** — a declared mode, gating +> same-rate spectrally-truncated levels, reselected per block, with the cubic read on the same +> lever, Varispeed only for now. Costs: `L`×`N` memory on a gated set of captures, a permanent +> two-state parameter id, a deck toggle that may be free, a small spectral step at a level +> change, and a measurement obligation on the per-sample read. | Fork | Question | Options | Standing / lean | |---|---|---|---| -| **F1** | Is there a "micro" classification at all, and where does it come from? | (a) derived-only, governing **defaults and snap grid only, never the sound**; (b) derived + a user override toggle; (c) **no classification** — the period grid is always available and a bad default on micro material is accepted | **OPEN. Lean unmoved: (a)**, with (c) as the serious conservative alternative. **But R1 amends the test inside (a)** — the frame-count band now classifies and detection supplies the period, because "detection failed" stopped meaning "table-like material" when W1 left. See §3 | -| **F2** | What grid does a loop-mark drag snap to? | (a) zero-crossing always (today); (b) **period** when a period is known, zero-crossing otherwise; (c) both, modifier-selected — note `param_taper`'s modifier law explicitly excludes spline points, so a waveform-mark modifier is available but needs its own ruling | **OPEN. Lean unmoved: (b)** — it makes the octave error unreachable rather than unlikely. **Simplified by R1:** on a true one-cycle file the period grid admits exactly ONE loop (start 0, whole file), so "grid" there means "a single admissible loop"; the grid only has multiple positions on a multi-cycle capture, which R1 leaves in scope (§1.4) | +| **F1** | Is there a "micro" classification at all, and where does it come from? | (a) derived-only, governing **defaults and snap grid only, never the sound**; (b) derived + a user override toggle; (c) **no classification** — the period grid is always available and a bad default on micro material is accepted | **OPEN. Lean unmoved: (a)**, with (c) as the serious conservative alternative. **R1 amends the test inside (a)** — the frame-count band now classifies and detection supplies the period. **R2 adds a job (a) must NOT take**: gating band-limiting on a derived class would let a heuristic decide the sound. §3 recommends splitting the gates (mode decides sound, class decides eager build) so (a)'s constraint survives | +| **F2** | What grid does a loop-mark drag snap to? | (a) zero-crossing always (today); (b) **period** when a period is known, zero-crossing otherwise; (c) both, modifier-selected | **OPEN. Lean unmoved: (b)** — it makes the octave error unreachable rather than unlikely. **Simplified by R1:** on a true one-cycle file the period grid admits exactly ONE loop (start 0, whole file). **Narrowed by R3: (c)'s modifier vocabulary is now nearly empty** — Ω-W2-T5 takes **Ctrl** for defeat-snap on a marker drag, and `instrument-control-surface.md` §4.1 already excludes **Shift** on marks. (c) must now name a modifier it can actually have. **R2 raises (b)'s stakes** — a non-whole-period seam step scales with the source's high-frequency content, which is what a complex waveform has more of | | **F3** | Does the instrument correct tuning from the loop length? | (a) never — the user dials Pitch (today); (b) a **one-shot "Tune to loop"** that writes `pitchOffsetSemitones`; (c) automatic and continuous, derived and unstored | **OPEN. Lean (b) FIRMS.** (c)'s only real motivation was keeping tuning correct while a scrub moved the loop; R1 removes the scrub, so with a static loop length a one-shot write is unambiguous and (c) is now pure hidden-pitch-modifier with nothing bought | -| **F4** | Fractional loop bounds? | (a) **no** — integer forever; buy resolution by capturing low, and document that; (b) yes — fractional loop end, which changes a **bank intrinsic** on `Sample` and takes a payload rung | **OPEN. Lean unmoved: (a)**, hard. ~1 ¢ at Daniel's own working range does not justify a bank-format change. R1 does not touch this: the quantizer is one frame regardless of what the material does | +| **F4** | Fractional loop bounds? | (a) **no** — integer forever; buy resolution by capturing low, and document that; (b) yes — fractional loop end, which changes a **bank intrinsic** on `Sample` and takes a payload rung | **OPEN. Lean (a) HOLDS, but R2 puts it under real pressure and that should be visible.** The tuning arithmetic is unchanged (~1 ¢ at 30 Hz), but a one-frame phase error at the seam produces a step whose size scales with the waveform's slope — small on a sine, larger on a harmonically rich capture. The existing mitigation still carries it: a one-cycle file is one period by construction (§1.3), so the error is a bad-capture case, not a routine one. **Note that F9(c) would dissolve this fork entirely** by taking pitch off `L` — one more reason it is the option that deserves an explicit refusal rather than silence | | **F5** | Does W1 ship, and does it morph? | (a) not now; (b) scrub only — whole periods, no interpolation; (c) scrub + interpolate between adjacent period-frames | **SETTLED (a), Daniel 2026-08-03 (R1).** This inverts the lean, which was (b) — the lean assumed multi-cycle *evolving* material, and §8's own test said so. "Yet": revisit if the material changes, per §2's W1 section | -| **F6** | Band-limiting (W2) — in scope, and conditional? | (a) out for now; (b) **mip pyramid + better interpolator, gated behind a mode** so the migration bar holds; (c) unconditional, accept the break with a version note | **OPEN — explicitly NOT ruled by R1, and now the largest open question here.** Lean unchanged: **(b)** if it ships. This is the fork that decides whether "wavetable synth" is rhetoric or a commitment | +| **F6** | Band-limiting (W2) — conditional or unconditional? | ~~(a) out for now~~ — **eliminated by R2**; (b) **gated behind a declared mode** so the migration bar holds; (c) unconditional, accept the break with a version note | **OPEN, narrowed to a binary by R2 (2026-08-03).** R2 answered the *whether*: *"complex waveforms must be supported purely."* Lean unchanged: **(b)** — and it is cheap, because at root and at unity ratio the two paths are bit-identical (§6.5), so (b)'s off state is a genuine no-op rather than a maintained second engine. (c) buys one less parameter at the cost of every saved instance that plays above its root | | **F7** | Preserve, and the XFADE mark, at one-period loops | (a) leave both live and let the user discover they do nothing; (b) **refuse/grey both** when the loop is one period; (c) auto-switch the engine to Varispeed | **OPEN. Lean (b) FIRMS and simplifies.** R1 makes the one-period loop the *typical* shape rather than one of several, so the affordance is wrong more of the time; and with a static loop the condition is evaluated once rather than tracked across a moving one. (c) still changes the sound of a saved instance and should still be rejected | -| **F8** | Does this become a Phase, and when? | (a) a phase sized **W0-only**, dispatched after Ω-W1-T5 and the snap-radius track land; ~~(b) one phase covering W0+W1~~ — **eliminated by R1**; (c) not now | **OPEN, but narrowed to a binary (a) vs (c).** Lean unmoved: **(a)** — W0 is defect-class and unblocks Daniel's material. R1 makes (a) cleaner than it was: a W0-only phase is now the whole of the direction's near-term scope rather than the first slice of it | +| **F8** | Does this become a Phase, and when? | (a) **one phase, two waves — W0 then W2** — dispatched after Ω-W1-T5 lands; ~~(b) one phase covering W0+W1~~ — **eliminated by R1**; (c) not now; (d) **two phases** — W0 now as defect-class work, W2 separately once F9–F12 are ruled | **OPEN. Lean: (a)**, with **(d) the serious alternative R2 creates.** R1 had reduced this to a binary; R2 re-opens it, because W0 and W2 now differ in kind — W0 is migration-clean, parameter-free, guardrail-free defect work, and W2 is a measured DSP build with a frozen parameter and five unruled shape forks. (a) keeps the dependency (W2 consumes W0's declared period) inside one plan; (d) stops W0 waiting on W2's forks. **If F9–F12 are not ruled when W0 is ready, (d) is the honest answer** | +| **F9** | *(new, R2)* What SHAPE do the band-limited tables take? | (a) **same-rate filtered copies** — `L` full-length levels, **zero index-domain change**; (b) **decimated mip pyramid** — memory bounded at ~2× regardless of depth, but every frame-domain quantity (loop wrap, crossfade tap `pos − length`, Trigger span, spline phase, `sourcePeriodFrames`) becomes level-relative; (c) **resynthesize to a fixed power-of-two table** from the declared period | **OPEN. Lean: (a)**, gated on capture length. It keeps the change inside a buffer bind rather than spreading a second frame domain through `voice.h` — already a documented ceiling exception. (b) is the right answer if long captures must be covered without gating. **(c) is recommended AGAINST and the reason is identity, not cost:** it dissolves F4's quantizer and makes every level exact, but the instrument would stop playing the captured bytes — this tool's trust anchor is that what you captured is what you hear, and (c) is also the option that most resembles what R1 put out of scope | +| **F10** | *(new, R2)* WHEN is a voice's level chosen? | (a) note-on latch from `baseRatio_`; (b) note-on latch from the **worst-case** ratio the note can reach (pitch-env peak + velocity throw); (c) **per-block reselection**; (d) per-block with a crossfade between adjacent levels | **OPEN. Lean: (c)** — it matches the standing "observation is at block boundaries, never per frame" discipline and adds no per-sample branch. (a) is wrong under a pitch envelope: `ratio_ = baseRatio_ * envFactor` (`voice.h:652`) and the Pitch knob is LIVE, so a latched level is under-filtered at the peak — the artifact R2 forbids. (b) is the fallback if (c)'s step is audible: no step by construction, at the cost of a dull note start. (d) removes the step and costs a **second read tap per frame** — the same cost this doc refused to promise for W1's morph without a measurement | +| **F11** | *(new, R2)* Does the interpolator upgrade ride the same lever? | (a) linear stays; (b) **cubic Hermite unconditionally**; (c) **cubic under the same declared mode** as band-limiting | **OPEN. Lean: (c)** — one lever, one migration event, and the two are the same complaint about reconstruction quality on a repitched read. A mip fixes folding but not linear interpolation's sinc² sag and imaging, so (a) leaves half of *purely* undelivered. (b) is defensible on the grounds that it is bit-identical at `frac == 0` — root playback is unchanged — but it still moves every repitched note in every saved instance with no way to decline | +| **F12** | *(new, R2)* Does **Preserve** get covered? | (a) **Varispeed only, for now**; (b) also feed the SOLA ring from a level chosen by the SHIFT ratio | **OPEN. Lean: (a)**. Under Preserve the voice reads at `stretchRate_` (`voice.h:616`) and the transposition is the shifter's own fractional ring read (`pitch_shift.h:184`), so a voice-side level does nothing there — (b) is a second build inside a module whose artifact budget is already dominated by its splice. (a) is defensible at micro scale, where Preserve is definitionally the wrong engine anyway (§1.7). **The cost of (a) is a claim, and it must be qualified rather than dropped:** "improves every ordinary sample played above its root" is Varispeed-only under (a) | **What is NOT a fork, and should not be re-opened as one:** - The pre-seam crossfade cannot deliver micro-scale smoothness (§1.2). Structural — and - **untouched by R1**, which changes nothing about the loop's arithmetic. -- `period_detect` cannot see a single cycle (§1.5). Structural — and **untouched by R1**. The - declare-vs-detect split survives intact; deferring W1 removes the scrub, not the - multi-cycle capture that detection serves (§1.4). + **untouched by R1 and by R2 alike.** The identity-map result is arithmetic on any + `P`-periodic source, not a property of sines: at `length == P` the tap `pos − length` equals + the head for a saw exactly as it does for a sine. A complex waveform does not create a job + for the crossfade that it did not have before. +- `period_detect` cannot see a single cycle (§1.5). Structural — and **untouched by R1 and + R2**. The declare-vs-detect split survives intact; R2 gives the DECLARED arm a second and + more demanding consumer (§4) without changing what either arm can do. - W3 breaks "one capture, one parameter set" (§2), **and is now out by Daniel's ruling as well as by that argument.** If it returns, it returns as its own product question. - Sub-50 Hz is first-class. Nothing here narrows a range to avoid it, and R1 does not change @@ -694,16 +953,69 @@ R1 moved it. case whole and names the single trigger that revives it. - **F8's option (b) eliminated** — there is no W0+W1 phase to size. -**What it explicitly does NOT settle:** +**What it explicitly did NOT settle** *(both items have since been ruled — see R2 and R3)*: -- **F6 / band-limiting.** The deciding question — *is the material ever anything but sine?* — - is outstanding, and Daniel knows it is. Aliasing is orthogonal to whether a table's frames - evolve; a static single-cycle saw played at C5 folds exactly as §1.8 says. **Do not read - R1 as answering it.** +- **F6 / band-limiting.** Left outstanding by R1 and **answered by R2 below.** +- **The Phase Ω conflict in §6.7.** Left open by R1 and **dissolved by R3 below.** - **F1, F2, F3, F4, F7, F8** — all still Daniel's. R1 shifts what some of them are choosing between (the table above says which) without choosing. -- **The Phase Ω conflict in §6.7.** Still open, still with Daniel and whoever owns Ω's - acceptance criteria, and not this doc's to resolve. + +**R2 — Daniel, 2026-08-03.** Verbatim, answering this doc's own second outstanding question +(*is the material ever anything but sine?*): + +> *"yes, complex waveforms must be supported purely."* + +**What it settles:** + +- **F6's option (a) — "out for now" — is eliminated. W2 is in scope**, and the fork narrows + to conditional (b) vs unconditional (c). +- **The near-term scope becomes W0 AND W2**, in that order, because the recommended W2 build + consumes W0's declared period (§1.8.2). +- **§8's kill test for W2 is met by evidence rather than preference**, the same way R1 met + W1's. The condition was written before the answer existed. + +**What it costs, stated because a scope ruling that hides its price is not decision-grade — +all three of R1's reliefs are spent:** + +- **It reaches `process()`.** The read binds a level-indexed buffer (`voice.h:448-449`). No + branch, no dispatch, no virtual — but a measurement obligation, not an argument (§6.6). +- **It opens a one-way door**: a permanent VST3 parameter id whose *semantics* (two-state vs + a quality selector) must be right first time (§6.8). +- **It contends the 82 px deck headroom** again — possibly at zero cost via a caption-row + slot, which is a measurement and not an assumption (§6.9). +- **It breaks the migration bar for repitched playback**, which is why the lean is F6(b); + root playback and unity-ratio playback stay bit-identical either way (§6.5). + +**What it explicitly does NOT settle:** *the shape.* R2 rules that band-limiting happens, not +how. **F9 (table shape), F10 (level selection), F11 (interpolator), F12 (Preserve coverage) +are minted open**, each with a lean and a cost, and the recommended package at the head of §7 +is a recommendation and not a ruling. **F1, F2, F3, F4, F7, F8 remain Daniel's**; R2 moves +what F1, F2, F4 and F8 are choosing between (the table says how) without choosing. + +**R3 — Daniel, 2026-08-03.** Verbatim, on the Phase Ω conflict recorded at §6.7: + +> *"call it part of omega, that was a fence you built not me"* + +**What it settles:** + +- **The snap-radius work is a Phase Ω track — Ω-W2-T5** (branch `omega-w2-t5-snap-radius`, + commit `b7b7e88`): the snap is bounded to `kMarkerGrabWidth` (5 px) and a marker drag gains + **Ctrl-defeats-snap**. +- **Ω's "the zero-crossing snap comes out identical" criterion was a scope fence, not a + product ruling**, and is superseded by Daniel's DAW evidence. `PLAN.md` records the + supersession at the phase criterion and at Ω-W1-T5's two criterion sites. +- **Ω-W1-T5's work is not invalidated.** It was required to preserve the snap exactly and it + did; its tie-rule fixtures pass unmodified under the bounded entry. The change came later + and from a different track. +- **§6.7 becomes a record rather than an open item.** What survives from it is a file-ownership + sequencing constraint, unchanged in form. + +**Two consequences R3 creates, both recorded rather than resolved here:** + +- **Ctrl-defeats-snap is a new user-visible gesture that `instrument-control-surface.md` §4.1 + contradicts.** Flagged for doc-keeper; this doc does not write that content. +- **F2's option (c) loses Ctrl**, and Shift was already excluded on marks. (c) must name a + modifier it can have. --- @@ -711,21 +1023,29 @@ R1 moved it. Stated plainly so the direction can be killed cheaply if it is not: -- **W0 is worth doing regardless of the wavetable ambition — and R1 leaves that argument - exactly as it was.** Daniel cannot currently set a correct loop on his own routine material, - and the radius fix alone leaves a one-pixel drag able to produce a 0.997-period loop. That - is defect-class. **It is now also the whole of the near-term scope.** +- **W0 is worth doing regardless of the wavetable ambition — and neither ruling touches that + argument.** Daniel cannot currently set a correct loop on his own routine material, and + Ω-W2-T5's radius bound alone leaves a one-pixel drag able to produce a 0.997-period loop. + That is defect-class. **R2 adds a second reason rather than replacing this one:** a + non-whole-period seam step scales with the source's high-frequency content, so complex + material makes the same defect louder. - **W1 is worth doing if Daniel's captures are ever multi-cycle and evolving. ANSWERED 2026-08-03: they are not — *"just static captures right now."*** The position knob has nothing to scrub, and the test written here before the answer arrived is the reason F5 closes cleanly rather than by preference. The condition is unchanged and still live as a trigger: if the practice changes, this bullet is what re-opens F5. -- **W2 is worth doing if the material is ever anything but sine. STILL UNANSWERED — this is - the outstanding question.** A sine played up four octaves is fine; a captured saw is not. If - Daniel's practice is harmonically rich single-cycle captures played across the keyboard, W2 - stops being optional and becomes the actual answer to "turn this into a wavetable synth." - **R1 does not bear on it either way**, because staticness and harmonic content are - independent properties of the same capture. +- **W2 is worth doing if the material is ever anything but sine. ANSWERED 2026-08-03: it is — + *"yes, complex waveforms must be supported purely."*** A sine played up four octaves is + fine; a captured saw is not, and *purely* forbids the difference. W2 stops being optional + and becomes the actual answer to "turn this into a wavetable synth." **Both of this + section's tests have now been met by evidence rather than by preference, which is the whole + reason for writing them before the answers existed** — R1 killed W1 on the first, R2 + committed W2 on the second, and neither turned on anyone's taste. +- **The test that remains, and it now sits under W2 rather than beside it: is a DECLARED + quality mode acceptable, or must the instrument simply sound right with no switch?** F6(b) + buys the migration bar with a control; F6(c) buys one less control with a behaviour change + in every saved instance that plays above its root. If Daniel's answer is "it should just be + right", F6 goes to (c) and the parameter and deck-slot costs in §6.8/§6.9 vanish with it. --- @@ -736,15 +1056,27 @@ product-designer may not edit `CLAUDE.md`. **Flagged for staff-engineer or doc-k `single-cycle-and-wavetable.md` to that list.** `docs/PLAN.md` carries one pointer to this doc, as item 4 under "Flagged for awareness — not -blocking, but decision-grade." **No phase, no wave and no track is proposed there**, and R1 -does not change that — `PLAN.md` is the *active on-deck specification list*, and putting a -phase in it while F8 is unruled would both put an unbriefable phase in that list and falsify -the plan's standing claim that Λ is the only phase carrying unanswered [Daniel]-class +blocking, but decision-grade." **No phase, no wave and no track is proposed there**, and +neither R1 nor R2 changes that — `PLAN.md` is the *active on-deck specification list*, and +putting a phase in it while F8 is unruled would both put an unbriefable phase in that list and +falsify the plan's standing claim that Λ is the only phase carrying unanswered [Daniel]-class questions. -**What R1 does change is that the proposable phase is now well-defined: W0-only, F8(a).** -Five forks still sit inside W0's own content (F1, F2, F3, F4, F7), F8 governs whether and -when it dispatches, and F6 belongs to W2 rather than to any of this — so the *shape* is -settled while the *specification* is not. When F8 is ruled, this doc becomes the backing -product doc for that phase in the ordinary way — the Γ / Ε / Ρ / Λ pattern, not the doc-less +**What the two rulings change is what a proposable phase would contain.** After R1 it was +W0-only. After R2 it is **two waves — W0 then W2** — with the order forced by W2 consuming +W0's declared period: + +- **Wave 1 (W0)** is briefable the moment F1, F2, F3, F4 and F7 are ruled. It is + defect-class, migration-clean, parameter-free and guardrail-free. +- **Wave 2 (W2)** needs F6 plus the four new shape forks (F9–F12), and carries a measurement + obligation and a permanent parameter id. + +**That asymmetry is what re-opens F8** and makes (d) — two phases rather than one — a serious +option R1 had closed off: W0 should not wait on W2's forks. When F8 is ruled, this doc becomes +the backing product doc in the ordinary way — the Γ / Ε / Ρ / Λ pattern, not the doc-less Ψ / Ω one. + +**One documentation obligation this doc creates but does not own, restated here so it is not +lost:** Ω-W2-T5's **Ctrl-defeats-snap** gesture needs a home in +`docs/product/instrument-control-surface.md`, whose §4.1 currently reads as though waveform +markers carry no modifier at all. **Flagged for doc-keeper.**