# 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.