diff --git a/docs/PLAN.md b/docs/PLAN.md index eca1fed..7234b71 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -215,6 +215,34 @@ in Γ-W4-T1 and changes no wave boundary. is `docs/product/parameter-automation.md` §8. **That doc is no longer scoping-only — §§6–10 are the specification Γ-W4-T1 is built from.** +4. **Single-cycle material and the wavetable direction is FRAMED but is NOT a phase, and + deliberately so.** Daniel, 2026-08-03, after DAW-testing a one-cycle 60 Hz sine capture: + *"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 carries EIGHT open [Daniel]-class forks and none is ruled**, so it gets a pointer + here rather than a phase section: `PLAN.md` is the active on-deck specification list, and + a phase with eight 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 forks are ruled, the doc becomes a phase's backing product doc in the ordinary + way (the Γ / Ε / Ρ / Λ pattern, not the doc-less Ψ / Ω one). + + **Two things in it are decision-grade for work already in this plan, which is why it is + flagged rather than merely filed:** + + - **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. + ## Phase-wide acceptance criteria These bind every track in this plan and are stated once here rather than repeated diff --git a/docs/product/single-cycle-and-wavetable.md b/docs/product/single-cycle-and-wavetable.md new file mode 100644 index 0000000..069ba48 --- /dev/null +++ b/docs/product/single-cycle-and-wavetable.md @@ -0,0 +1,585 @@ +# Single-cycle material and the wavetable direction — product notes + +Framing and design directions behind Daniel's observation of 2026-08-03, after testing +ReaSampler 9000 in REAPER on a literal single-cycle 60 Hz sine capture: + +> *"if I have a literal single-cycle capture at 60hz, sine wave, if I turn on loop I cant +> move the loop points, they loop start snaps to the middle and can't move, and the end +> point is stuck at the last zero crossing. the x-fade moves freely and works as expected, +> but because the loop is stuck to half of the cycle, it doubles to tonic frequency and adds +> 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. + +Status: **framed by product-designer, 2026-08-03. NOTHING here is ruled.** Eight +[Daniel]-class forks are open (§7). **No `PLAN.md` phase exists for this work and none is +proposed until the forks are ruled** — `PLAN.md`'s "Flagged for awareness" item 4 is the +only pointer to this doc, deliberately, so the plan's standing claim about unanswered +questions is not weakened by a direction that has not been decided. + +Everything below is a product-designer call with its reasoning stated. Contradict it in +review with an argument, not a preference. + +--- + +## 0. TL;DR + +**The reframe: the instrument is already a wavetable oscillator in every respect but +one — the loop grid.** A one-period loop read at `readPos_ += ratio_` *is* the wavetable +read. Nothing about the voice path needs a new concept. What is missing is that three +mechanisms are each calibrated for macro material and each degenerates at one-period +lengths: the zero-crossing snap picks the wrong grid, the pre-seam crossfade collapses to +the identity map, and the loop's integer bound becomes a tuning quantizer. So the near work +is a **recalibration plus a period grid**, not a synthesis engine. + +**The honest second half: there IS one genuinely new commitment hiding behind the words +"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. + +**Recommended shape, four candidates, in order:** + +| | What | Cost | Lean | +|---|---|---|---| +| **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 | **Take** | +| **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 | **Take next** | +| **W2** | **Band-limited playback** — mip pyramid + a better interpolator | Real DSP build; changes the sound of existing captures unless made conditional | **Its own decision** | +| **W3** | A first-class multi-frame **wavetable kind** — N×2048 tables, morph parameter, `.wav` wavetable import | Breaks "one loaded capture, one parameter set" | **Out** | + +**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. + +--- + +## 1. What "this micro scale" actually is + +Frame arithmetic at 48 kHz, because every boundary below is a frame count: + +| Source | Frames per cycle | +|---|---| +| 30 Hz (Daniel's routine working material) | 1600 | +| 60 Hz (the reported test) | 800 | +| 220 Hz | 218.2 | +| 440 Hz | 109.1 | +| 2000 Hz (`kPeriodDetectMaxHz`) | 24 | + +**Sub-50 Hz is treated throughout this doc as first-class, not as an edge case.** Nothing +proposed here narrows a range to dodge it, and §1.3 argues that Daniel's 30 Hz habit is not +a tolerance the design must absorb — it is the *correct* practice for this material, for a +reason that falls out of the arithmetic. + +The rest of this section walks each mechanism that stops making sense as the sample +shrinks toward one cycle. Each is read out of the code, not assumed. + +### 1.1 The zero-crossing snap picks the wrong grid — and a radius does not fix that + +`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. + +**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 +material, where a discontinuity click is the enemy and crossings are dense relative to a +drag's precision. On quasi-periodic material that premise is only half right: + +- Two zero crossings one **half**-period apart are both continuous by the snap's own test. +- Exactly one of them makes a loop that is a whole number of periods. + +**Continuity is necessary; it is not sufficient. The sufficient condition is that the loop +span is a whole number of periods.** So the right grid for this material is not the +crossing — it is the period. A radius bound stops the teleport; it does not stop a one-pixel +drag at 30 Hz (where one column ≈ 1.5 frames) from landing a span that is 0.997 periods +long, which is the same buzz at lower amplitude. + +### 1.2 The pre-seam crossfade degenerates to the identity map at one-period lengths + +This is the most load-bearing result in the doc, and it is structural rather than a tuning +problem. + +The fade is pre-seam and one-tap: the incoming tap is the same read head one loop length +earlier, `pos - length` (`src/core/instrument/engine/loop/loop_span.h:26-28, 79-87`). + +- **When `length` is exactly one period `P` of a `P`-periodic source, the tap value equals + the head value at every position.** The blend `v + xw·(in − v)` is then the identity map + for every weight. **The crossfade does literally nothing.** +- **When `length` is *not* exactly `P` — Daniel's actual failure — the tap is the same + material at a different phase.** The fade then replaces a discontinuity with a + *phase-smear over the fade window*: a wobble instead of a click. Longer fades make it + smoother, not more correct. + +**So the crossfade can never be the smoothness mechanism at micro scale, in either +direction. Period exactness is the only mechanism available.** Any proposal that tries to +buy micro-scale smoothness by widening the fade is wrong by construction and should be +refused on this paragraph. + +Two corollaries worth recording: + +- `maxCrossfade(start, length) = min(start, length)` (`loop_span.h:19-21`) means a loop + starting at frame 0 — the natural single-cycle loop — has **zero** available crossfade. The + documented reason (there is no material ahead of it) is correct and needs no change; it + merely happens to also be harmless, because the fade was inert there anyway. +- **`loop_span` therefore needs no change for this direction.** It is already right; it just + needs to be *understood* as inert at micro scale. Whether the UI should say so — grey the + XFADE mark when the loop is one period — is fork **F7**. + +Daniel's report that *"the x-fade moves freely and works as expected"* is consistent with +this: what he was moving was a handle whose audible effect was the identity map. + +### 1.3 The integer loop bound is a tuning quantizer, and its error is `≈ 1731/L` cents + +`Voice::advanceFrame` wraps by subtracting an integer loop length from a fractional read +position — `readPos_ -= loopLen` (`src/core/instrument/engine/voice.h:456-459`) — which +preserves phase, so there is **no drift**. But the sounding period is exactly `L / ratio` +output frames, so the pitch is `sr · ratio / L` and is quantized by `L` being an integer. +A one-frame miss costs `1200·log₂((L+1)/L) ≈ 1731/L` cents: + +| Source | `L` @48k | Worst-case tuning error from integer `L` | +|---|---|---| +| 30 Hz | 1600 | **1.1 ¢** | +| 60 Hz | 800 | 2.2 ¢ | +| 220 Hz | 218 | 7.9 ¢ | +| 440 Hz | 109 | 15.8 ¢ | +| 1 kHz | 48 | 36 ¢ | + +**This is why capturing low is the right practice rather than a quirk to accommodate.** The +quantizer is one frame regardless of frequency, so tuning resolution is bought purely by +making the cycle long. At Daniel's 30 Hz the residual is ~1 cent — solo inaudible, and +against a unison partner at A4 it is a ~0.28 Hz beat, a ~3.6 s period. Real but marginal. At +1 kHz it is a third of a semitone and unusable. **The design should state this as guidance +and should not spend a bank-format change to work around it** (fork **F4**). + +Two distinct problems live in this neighbourhood and must not be conflated: + +- **(a) Seam discontinuity** — `L` is not a whole multiple of `P`, so the waveform steps at + the wrap. This is what Daniel heard as *"harmonics from the shape distortion."* Fixed by a + period-aligned loop grid (§2). +- **(b) Detune** — `L` is a fine loop but `sr/L` is not the frequency of the MIDI root note + the capture is mapped to. A 30 Hz capture mapped to B0 (30.868 Hz) is 49 cents flat before + anyone touches a knob. Fixed by correcting *tuning*, not the loop (fork **F3**). + +For a true single-cycle capture, (a) solves itself the moment the loop is the whole file, +because the file is one period by construction of how the user made it — one cycle of time +selection, rendered with the exact-bounds guarantee. Daniel's (a) was not a bad capture; it +was the snap forcing `L` to half the file. + +### 1.4 `defaultLoopBounds` parks the loop in the wrong place for this material + +`defaultLoopBounds` puts the handles in the last quarter (`loop_span.h:89-96`) — the right +answer for a sustaining instrument sample and meaningless for a single cycle, where the last +quarter is a quarter of a waveform. Combined with §1.1 this is the mechanical cause of +*"loop start snaps to the middle."* At micro scale the correct default is the whole file; +for a multi-cycle capture under W1 it is one period at the loop's own start. Fork **F1** +decides whether the default is allowed to be material-aware at all. + +### 1.5 `period_detect` structurally cannot see a single cycle — the period must be DECLARED + +`detectPeriod` shortens its search band when the span is short: +`if (spanCount < 2 * lagHi) lagHi = spanCount / 2;` +(`src/core/instrument/engine/period_detect.cpp:135`). This is not a tuning constant to +loosen. YIN correlates a window against a lagged copy of itself, so finding period `P` +requires the analysed span to contain at least `2P`. **A one-cycle file contains exactly one +period; the detector cannot find it, by construction of autocorrelation. No threshold +change, no band widening, and no probe-count change fixes this.** + +The consequence for the design is clean and structural rather than a choice: + +- **At the single-cycle limit the period is DECLARED, not detected — and the declaration is + the file length itself** (or the loop span the user set). +- **Detection is for the multi-cycle case**, which is exactly where a wavetable *scrub* (W1) + lives. The probe geometry wants roughly four periods before it is meaningful. + +So the two halves of this direction use two different sources of truth for the same +quantity, and that split is forced rather than designed. Any future "simplify these into +one" is a regression; §6.6 names the specific circularity to avoid. + +**The detection band is `kPeriodDetectMinHz = 15.0` to `kPeriodDetectMaxHz = 2000.0` +(`period_detect.h:39-40`).** 30 Hz sits comfortably inside it. **No narrowing of that band is +proposed anywhere in this doc.** + +### 1.6 Pixel addressability is not the binding limit at 30–60 Hz — but it cannot express intent + +The overlay maps the whole frame count across its column span. At 800–1600 frames against +an ~1100-column overlay there are *more columns than frames*, so every frame is reachable by +a one-pixel drag and `resolveDragFrame`'s frameToX→walk→xToFrame round trip +(`waveform_view.cpp:166-179`) is exact. Addressability only becomes the limit above roughly +1.4 kHz, where a cycle is shorter than a column — one more argument for capturing low. + +What no amount of pixel precision buys is **intent**: a drag cannot express "make this +exactly three periods." That is the argument for a snap grid (fork **F2**) rather than for +finer dragging or a numeric entry field. + +### 1.7 Preserve/SOLA has nothing to preserve at one period + +The Preserve engine advances the read at the source rate while the shifter transposes. Its +window is ~50 ms (`period_detect.h:34-38` derives the 15 Hz floor from "~1.25 windows … at +the product's 50 ms window") — that is 30 to 75 cycles of a 30–60 Hz source. An +infinitely-sustaining one-period loop has **no duration to preserve**; Varispeed is not +merely acceptable there, it is definitionally the correct engine, because a Varispeed read +of a one-period loop *is* a wavetable oscillator. + +Fork **F7** decides whether the surface says so (grey/refuse Preserve when the loop is one +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 + +`Voice::advanceFrame` reads with **linear interpolation** between the two bracketing source +frames at `readPos_`, advancing by `ratio_` (`voice.h:622-627, 705`). Two independent +deficiencies, both invisible on a sine: + +- **No band-limiting.** A 30 Hz single cycle of a saw or a captured complex wave carries + partials up to Nyquist. Played at C5 (523.25 Hz) the ratio is ~17.4, so everything in the + source above ~1.4 kHz folds back into the audible band. Sine has no partials above the + fundamental, which is why the reported test sounded like a clean octave error rather than + a mess. +- **Linear interpolation is a poor reconstruction filter** even at unity — a sinc² sag in the + 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. + +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. + +**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**. + +--- + +## 2. What "wavetable" means *here* — four candidate shapes + +The brief's own framing is the right one: this is a question of what *changes*, not what +gets built new. Single-cycle captures already flow through capture → bank → instrument +today. Four shapes, in ascending commitment. + +### W0 — Recalibrate the loop grid. No new concept. + +Everything in §1 that is a miscalibration rather than a missing feature: + +- **A period-aware snap grid** — when a period is known (declared at the single-cycle limit, + detected above it), loop-mark drags snap to `loopStart + k·P` rather than to the nearest + crossing. This makes the octave error *unreachable by a drag* instead of merely unlikely. +- **A micro-appropriate loop default** — the whole file rather than the last quarter. +- **Tune from the loop** — the sounding frequency of the loop is `sr/L`; the instrument can + say what that is and offer to correct it, using the `pitchOffsetSemitones` parameter that + already exists (params payload v16, `map/play_seconds.h:92-93`). +- **Preserve made honest at one period** (§1.7). +- **The XFADE mark made honest at one period** (§1.2). + +**Cost: no format change, no bank field, no new parameter id, no hot-path change, and it is +migration-clean** — a snap grid applies during a drag, so no saved instance's stored loop +points move and no saved instance's sound changes. This is defect-class work that makes +Daniel's material usable at all, and it is the recommended first move. + +### W1 — The loop start becomes a wavetable POSITION + +The cheap, high-leverage feature, and the one that earns the word "wavetable." + +Given a multi-cycle capture with a detected period `P`, express the loop start as `k·P` +from the capture start and give `k` a knob. **That is a wavetable position control** — +which cycle of the recorded evolution you are oscillating on. A four-second capture of a +filter sweep, a bowed string's evolution, or a slowly-detuning oscillator pair *is already a +wavetable*; the only thing missing is a way to scrub the one-period window through it. + +Why this is the right second move rather than W3: + +- **It adds one parameter that re-expresses a parameter that already exists.** Loop start is + already stored, already drawn, already draggable, already in the reload tier. +- **It reuses `period_detect` unchanged**, at the place it already runs + (`map/sample_map.cpp:337-342`). +- **It needs no new data model, no table format, no import path, no second capture.** +- **The precedent is the original one.** The PPG/Waldorf wavetable-position knob meant + literally "which cycle of the stored table," which is exactly this. Serum, Massive and + Vital inherited the control; we would be inheriting the control without inheriting the + format. + +The optional second half — **interpolating between adjacent period-frames** for a true morph +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 + +§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**. + +### W3 — A first-class wavetable KIND — RECOMMENDED OUT + +A bank entry that is N frames of a fixed size, a morph parameter across frames, and import +of third-party wavetable `.wav` files. Recommended out, with force: + +- **It breaks a settled invariant.** *"The instrument holds ONE loaded capture and ONE set of + playback parameters"* (`src/core/instrument/CLAUDE.md`) is the whole result of the zone + retirement (item 16). A frame stack is a keymap of captures by another name. +- **It makes the instrument a data-format consumer**, which is a different product. Today the + instrument reads the bank; a wavetable-file importer reads someone else's convention. (The + de-facto convention is a power-of-two frame — 2048 samples in Serum's format — + `[unverified against vendor documentation in this pass]`.) +- **W1 gets most of its musical value from material that already exists**, without any of it. + +If W3 ever comes back, it should come back as its own product question with its own doc, not +as an extension of this one. + +--- + +## 3. Does a single-cycle capture become a recognized KIND? + +Three ways the system could know, and the D-B ownership rule decides between them. + +**(a) Derive it; never store it.** A capture is "micro" when the audio says so. The precedent +is explicit and close: `period_detect`'s own header states that a period is *"DERIVED from +the audio, so it is cache and not state: nothing persists it, and it takes no rung of the +payload ladder."* The identical logic applies to single-cycle-ness. + +**(b) A bank intrinsic on `Sample`.** D-B says bank intrinsics are *facts about the captured +file*, and "this file is one cycle" is such a fact — the same class as root note and loop +points. **But it is a cheaply and deterministically derivable fact**, so storing it buys +nothing except a field that can be wrong, plus an additive `Sample` extension and a migration +story. **Reject.** + +**(c) A user-set instrument-side mode.** Honest, zero heuristic risk, and per D-B it is +per-instance `ComponentState` rather than a bank field. Costs one control on a deck whose +82 px headroom (`instrument-control-surface.md` §1.6) is already spoken for. + +**Recommendation: (a), with (c) as an override, and with a hard constraint attached — a +derived classification may change DEFAULTS and the SNAP GRID, and may never change the +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. + +**A threshold derived from the codebase rather than from taste.** A capture is a micro +candidate when *both*: + +1. `detectPeriod` returned none (there is not enough material to correlate), **and** +2. the frame count lies inside the band a single cycle could occupy at all — + `sr/kPeriodDetectMaxHz … sr/kPeriodDetectMinHz`, i.e. **24 … 3200 frames at 48 kHz**. + +Neither number is invented; both fall out of constants the detector already owns. Condition 2 +alone would misclassify a short drum one-shot; a 100 ms one-shot is 4800 frames and falls +outside it, which is the discrimination the band buys. A further tightening — requiring the +first and last frames to nearly match — is available if false positives show up in practice +and is deliberately not proposed pre-emptively. + +**The named alternative, and it deserves a real hearing: no classification at all.** Make the +period grid always available, let "the period is the whole file" be simply what the user gets +when the loop spans the whole file, and accept a bad initial loop default on micro material. +That removes every heuristic from the system at the cost of one worse default. It is the most +conservative option on the table and it is fork **F1(c)**. + +--- + +## 4. The pitch path — does this ride the Preserve/PSOLA work, or fight it? + +**It rides it, with one structural exception that is forced rather than chosen.** + +**What rides, unchanged:** + +- `period_detect` produces a **fractional** period, refined below one frame by the full-rate + difference function. That is precisely the quantity a period grid snaps to. +- It already runs **once per load, off the audio thread by link graph**, at exactly the right + place — the loader, `map/sample_map.cpp:337-342`, handing down `SampleData::sourcePeriodFrames`. +- `periodAnalysisSpan` already **prefers the loop region** when the loop is long enough, on + the stated reasoning that the loop is what a Gate voice asymptotically plays. That + reasoning is even more true when the loop *is* the wavetable frame. +- The existing PSOLA consumer is untouched: `setSourcePeriod` feeds the Preserve splice's + jump (`voice.cpp:251-252`), and nothing in this direction changes it. If F7 makes Preserve + inert at one period, the detector's answer is simply unused on that path — no code changes. + +**The one exception (§1.5):** the single-cycle case is permanently outside detection's reach, +so W0's period is declared from the file/loop length while W1's is detected. Two sources, one +grid. + +**A genuine tension worth naming before someone "fixes" it into a bug.** `periodAnalysisSpan` +uses `[loopStart, loopEnd)` only when that span is at least one full probe block +(`2 · longestLagFrames`, 6400 frames at 48 kHz). Under W1 the loop is one period — always far +below that — so the detector will always fall back to analysing the whole source. **That is +correct**: you want the period of the source material, not the period of a one-period loop, +which would be circular. But once W1 ships, the detector's answer and the loop's length are +measuring deliberately different things, and the code will look like it has an inconsistency +it does not have. It should be commented at the seam, per the comment conventions' "warnings +against a plausible-but-wrong change." + +--- + +## 5. Prior art borrowed, and what each contributes + +- **PPG Wave / Waldorf Microwave** — the wavetable-position knob as "which cycle of the stored + table." W1 is this control, transplanted onto captured material rather than authored tables. +- **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. +- **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. +- **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. +- **Ableton Simpler's classic/one-shot split** — precedent for a mode that changes defaults and + affordances per material class without changing the underlying data, which is §3's + "defaults and snap only, never the sound." + +--- + +## 6. Collisions with existing invariants — the check, item by item + +### 6.1 Capture and placement are separate acts — UNTOUCHED + +Nothing in W0/W1/W2 places a timeline item. A single-cycle capture is made by the existing +capture verb over a one-cycle time selection; the instrument only reads it. The load-bearing +principle is not stressed by this direction in any of its shapes. + +### 6.2 Precision invariants — UNTOUCHED, and one of them helps + +Exact bounds already deliver the precise frame count a one-cycle time selection asks for, +which is what makes §1.3's "the file is one period by construction" true. The lossless +mono-collapse rule (Ψ.6) is quietly useful here: a dual-mono single cycle lands as a +1-channel file, which is what a table should be. + +### 6.3 The instrument never writes the bank — UNTOUCHED + +W0, W1 and W2 write nothing to the bank. W3 would have wanted to, which is one more reason it +is out. + +### 6.4 One capture, one parameter set — HELD by W0/W1/W2, BROKEN by W3 + +Stated in §2 and the sole structural reason W3 is recommended out. + +### 6.5 The migration bar — HELD by W0/W1, AT RISK from W2 + +- **W0 is migration-clean.** A snap grid only acts during a drag; stored loop points do not + move and no saved instance changes sound. A changed *default* only affects a capture that + has no stored loop. +- **W1 is migration-clean** if the position parameter defaults to the position the stored loop + start already denotes. +- **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. + +### 6.6 The hot path — HELD, and the guardrails are satisfiable by construction + +- The period grid, the classification and the tune-from-loop computation are **load-time and + UI-thread only**. +- 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. +- 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) is the one item that would touch `process()`** — a second read + tap per frame. It must be measured against the guardrail before it is promised, and "drop + it" is a legitimate outcome. + +### 6.7 Phase Ω and the in-flight snap-radius track — the real sequencing constraint + +This is the item most likely to cost someone a merge. + +- **Ω-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.8 The frozen VST3 parameter table and the one-way doors + +Any new parameter (W1's position, W2's mode) **appends** to the FOREVER-FROZEN id table from +Γ-W4-T1; ids are never renumbered. `param_id.h`'s "signal-flow order" comment already carries +one annotated exception from Ω-W1-T4, so a second annotation is cheap precedent. But +`parameter-automation.md` §8's one-way-door sweep binds: **a new parameter's taper and range +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. + +### 6.9 The editor's space budget + +W1's position control and W2's mode toggle both want deck real estate against +`instrument-control-surface.md` §1.6's 82 px headroom, and `reasampler_editor.h` is already +tight against the ~600-line ceiling with Phase Ω spending its margin. This is a real +constraint on how many of these shapes can ship together, not a footnote. + +--- + +## 7. Forks — EIGHT, all OPEN, none ruled + +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. + +| Fork | Question | Options | 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 | **(a)**, with (c) as the serious conservative alternative | +| **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 | **(b)** — it makes the octave error unreachable rather than unlikely | +| **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 | **(b)** — honest, visible, and uses a parameter that already exists. (c) is a hidden pitch modifier | +| **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 | **(a)**, hard. ~1 ¢ at Daniel's own working range does not justify a bank-format change | +| **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 — **a second read tap on `process()`; measure before promising** | **(b)** if it ships | +| **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 | **(b)** if it ships. This is the fork that decides whether "wavetable synth" is rhetoric or a commitment | +| **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 | **(b)**. (c) changes the sound of a saved instance and should be rejected | +| **F8** | Does this become a Phase, and when? | (a) a phase sized **W0-only** first, dispatched after Ω-W1-T5 and the snap-radius track land; (b) one phase covering W0+W1; (c) not now | **(a)** — W0 is defect-class and unblocks Daniel's material; W1 is a feature that can be scoped once W0's grid exists | + +**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. +- `period_detect` cannot see a single cycle (§1.5). Structural. +- W3 breaks "one capture, one parameter set" (§2). If it returns, it returns as its own + product question. +- Sub-50 Hz is first-class. Nothing here narrows a range to avoid it. + +--- + +## 8. What would have to be true for this to be worth doing + +Stated plainly so the direction can be killed cheaply if it is not: + +- **W0 is worth doing regardless of the wavetable ambition.** 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. +- **W1 is worth doing if Daniel's captures are ever multi-cycle and evolving.** If every + single-cycle capture is a static one-cycle grab, the position knob has nothing to scrub and + W1 is dead weight. **This is the one question this doc cannot answer from the code, and it + is the thing most worth asking before F5 is ruled.** +- **W2 is worth doing if the material is ever anything but sine.** 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." + +--- + +## 9. Registration + +This doc is **not** listed in root `CLAUDE.md`'s "Product design docs" file list — +product-designer may not edit `CLAUDE.md`. **Flagged for staff-engineer or doc-keeper: add +`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**, because +every fork above is open and `PLAN.md` is the *active on-deck specification list*. Adding a +phase with eight unruled forks 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. When the forks are ruled, this doc becomes the backing product doc for a phase in +the ordinary way — the Γ / Ε / Ρ / Λ pattern, not the doc-less Ψ / Ω one.