Files
reasampler/docs/product/single-cycle-and-wavetable.md
T
daniel d5280687f3 docs: record Daniel's static-captures ruling on the wavetable direction
R1 (2026-08-03) closes F5 — no frame-indexed wavetables yet — deferring W1 with
"yet" preserved as scope rather than rejection, and confirming W3 out. Near-term
scope narrows to W0 alone. Cascades the consequences: F1's threshold amended
(static does not mean one-cycle, so detection classifies nothing now), F2
simplified, F3 and F7 firmed, F8 narrowed to a binary. F6/band-limiting left
open and explicitly not pre-ruled. PLAN.md item 4 updated to match.
2026-08-03 15:21:03 -04:00

751 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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; ONE ruling has since landed against it —
R1, recorded in full at §7.1.** 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).
**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.
**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.
**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
the plan's standing claim about unanswered questions is not weakened by a direction whose
shape is decided but whose dispatch is not.
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.
**After R1 it is also the only one of the four shapes whose scope is still undecided.**
**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** |
| **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** |
| **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** |
**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.
**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.
Fork **F1** decides whether the default is allowed to be material-aware at all.
**A distinction R1 forces, and it is easy to get wrong: *static* does not mean *one
cycle*.** R1 says the captures are static — the waveform does not evolve — and that is what
kills W1, because a position knob scrubbing across identical cycles is a knob that does
nothing. It does **not** say every capture is exactly one period long. A static grab that
happens to run three or four cycles is still in scope, and on it:
- the loop **length** must still be a whole number of periods, or §1.3(a)'s seam
discontinuity returns exactly as Daniel heard it;
- the loop **position** is immaterial, because every cycle is the same cycle.
So the period grid keeps its job on multi-cycle material even with W1 deferred, and
`period_detect` keeps an in-scope consumer there (it also, separately and unaffected by any
of this, keeps its Preserve/PSOLA consumer — see §4). This is what amends F1's threshold in
§3: "detection failed" and "this is table-like material" stopped being the same test the
moment W1 left, because the static multi-cycle capture is now the case that would fall
through it.
### 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 3060 Hz — but it cannot express intent
The overlay maps the whole frame count across its column span. At 8001600 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 3060 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. **R1 settled two of them — W1 deferred, W3
confirmed out — so what follows is one body of work (W0), one open question (W2), and two
sections kept for the record.**
### 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 — DEFERRED by R1 (2026-08-03)
**Daniel, 2026-08-03: *"I don't want actual frame-indexed wave tables yet, that is out of
scope. just static captures right now."* A loop start expressed as `k·P` IS a frame index
into a table, so this is W1 by name.** The deferral is sound on this doc's own §8 test
rather than only on the ruling: W1 is worth doing *if the captures are ever multi-cycle and
evolving*, and static material makes every frame of the table the same frame. A scrub across
identical cycles is inaudible. **The knob would have been dead weight, exactly as §8
predicted.**
**"Yet" is the operative word — this is a scope ruling, not a verdict on the idea.** The
argument below is kept whole, and unchanged, because nothing in it was refuted; only its
premise went away. **What would revive it: capture material that both runs to several cycles
AND evolves across them** — a filter sweep, a bowed attack, a slowly-detuning pair. That is a
change in Daniel's practice, not a change in the code, and it is the only trigger. If it
arrives, re-open F5 and read from here; do not re-derive.
The rest of this section is the preserved case.
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 — UNAFFECTED by R1, still open
**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.**
§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 — OUT (recommended out; CONFIRMED by R1)
A bank entry that is N frames of a fixed size, a morph parameter across frames, and import
of third-party wavetable `.wav` files. **This is "actual frame-indexed wave tables" in the
most literal sense, and R1 names it out of scope directly** — so it is now out by Daniel's
ruling and not merely by this doc's recommendation. The recommendation stood on its own and
is kept because the structural reason is the durable one:
- **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.
**R1 amends condition 1, and this is a genuine correction rather than a restatement.**
Condition 1 used *detection failure* as the classifier because in the original framing
detection **succeeding** meant W1 territory, and W1 supplied its own default (one period at
the loop's start). With W1 deferred there is nothing downstream to catch that case, so a
static multi-cycle capture (§1.4) — detection succeeds, file is short, cycles are
interchangeable — falls straight through the threshold to the unimproved last-quarter
default. That is precisely the defect W0 exists to remove. The threshold should now read:
- **the frame count lies inside the band** `sr/kPeriodDetectMaxHz … sr/kPeriodDetectMinHz`,
i.e. 24 … 3200 frames at 48 kHz — this alone now carries the classification; **and**
- **a period is known**: **detected** where `detectPeriod` returns one, **declared as the
file length** where it does not (§1.5).
**Detection moves from being the classifier to being the period *source*, which is what it
was always better suited to be.** The false positive the band admits is unchanged (a very
short non-periodic one-shot), and so is the tightening available against it. **F1's lean is
unmoved — still (a), derived-only, governing defaults and the snap grid and never the
sound** — only the test inside (a) changes. F1(c), no classification at all, is untouched by
R1 and remains the serious conservative alternative.
**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 at the single-cycle limit the period is **declared** from the file/loop length, while on a
capture running to several cycles it is **detected**. Two sources, one grid. **R1 does not
collapse them into one** — deferring W1 removes the *scrub*, not the multi-cycle capture
(§1.4), so both arms of the split still have in-scope work to do.
**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). **Any one-period loop on micro material is
far below that** — which under W0 is the *default* case, not an exotic one — 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 the
detector's answer and the loop's length are then 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."
**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.
---
## 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.
**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.**
- **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.**
- **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. *(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.
**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.
### 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. *(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.**
**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.
### 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.
**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.
### 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 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.
---
## 7. Forks — EIGHT total: ONE settled (F5), SEVEN 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.
| 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) |
| **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 |
| **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 |
| **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 |
**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).
- 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
that: capturing low remains the correct practice for the reason in §1.3, not a tolerance
the design absorbs.
- **W1's deferral is a scope ruling, not a structural result** — the one item in this list
that IS revisitable, on the single named trigger in §2 (material that is both multi-cycle
and evolving).
### 7.1 Rulings recorded
**R1 — Daniel, 2026-08-03.** Verbatim:
> *"I don't want actual frame-indexed wave tables yet, that is out of scope. just static
> captures right now."*
**What it settles:**
- **F5 → (a), not now.** A loop start expressed as `k·P` is a frame index into a table; on
static material every frame of that table is the same frame, so the knob scrubs between
identical cycles. This is §8's own kill test, met.
- **W3 confirmed out**, now by ruling and not only by this doc's argument.
- **W1 deferred, with "yet" recorded as Daniel's word.** Scope, not rejection. §2 keeps the
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:**
- **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.**
- **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.
---
## 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 — 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.**
- **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.
---
## 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**, 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
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
Ψ / Ω one.