R2 closes fork F6 IN: complex waveforms must be supported purely, so band-limited playback enters the near-term scope alongside the loop-grid recalibration. Re-derives the three post-R1 reliefs it spends — it reaches process(), reopens a parameter door, and recontends the deck headroom — and mints F9-F12 for the shape it deliberately does not rule. R3 makes the snap-radius work Phase Omega's own W2-T5 and supersedes the zero-crossing-snap criterion at its three sites, recording that W1-T5 met it.
75 KiB
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
Ω-W2-T5 snap-radius and is NOT this doc's subject. It is described here only where the
reasoning needs it, because the defect and the direction share a diagnosis and the fix for one
does not deliver the other.
Status: framed by product-designer 2026-08-03; THREE rulings have since landed against it — R1, R2 and R3, recorded in full at §7.1.
R1 — Daniel, 2026-08-03:
"I don't want actual frame-indexed wave tables yet, that is out of scope. just static captures right now."
R1 closes F5 (W1, the wavetable-position knob, does not ship now) and confirms W3 out from Daniel rather than only from this doc's argument.
R2 — Daniel, 2026-08-03, answering this doc's own second outstanding question (is the material ever anything but sine?):
"yes, complex waveforms must be supported purely."
R2 closes F6 IN, toward band-limiting — W2 is in scope. "Purely" is read here as
faithfully: without artifacts the capture does not contain, and §1.8's aliasing is
precisely the artifact that reading names. What that commits to, what shape it can take and
what it costs is worked out at §1.8 and §2's W2 section. It is the one item in this doc
that reaches process() — the post-R1 claim that the near-term scope was entirely
load-time and UI-thread is RETIRED, and §6.6 states what replaces it.
R3 — Daniel, 2026-08-03, on the Phase Ω conflict this doc recorded at §6.7:
"call it part of omega, that was a fence you built not me"
R3 dissolves that conflict rather than sequencing around it. The snap-radius work IS a Phase Ω track (Ω-W2-T5); Ω's "the zero-crossing snap comes out the other side identical" criterion was a scope fence built to stop Ω's own tracks perturbing the snap while they fixed the coordinate mapping — never a product ruling that the snap's behaviour was correct — and Daniel's DAW evidence supersedes it. §6.7 is now a record, not an open item.
The near-term scope is therefore W0 AND W2 — two bodies of work, one deferral (W1), one confirmed out (W3). They are not peers: under the recommended shape W2 consumes W0's declared period (§1.8), so the wave order is forced rather than chosen.
TWELVE forks: ONE settled (F5), ELEVEN open. R2 narrows F6 to a binary and mints four new forks — F9–F12, carrying W2's shape — because the shape is genuinely unruled and is Daniel's to rule, not this doc's to assume. §7 states a recommended package he can take or override fork by fork.
No PLAN.md phase exists for this work and none is proposed until F8 is ruled —
PLAN.md's "Flagged for awareness" item 4 is the only pointer to this doc, deliberately, so
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 per-octave lowpassed tables. That is a real build with a real migration cost. R2 rules it IN. It is no longer this doc's hedge; it is half the work, and §1.8's second half is where its cost is actually derived.
The four shapes, and where each now stands:
| What | Cost | Standing | |
|---|---|---|---|
| W0 | Recalibrate the loop grid for micro material — period snap, whole-file default, tune-from-loop, Preserve inert | No format change, no bank field, no hot-path change, migration-clean | NEAR-TERM SCOPE, first |
| W1 | Loop start becomes a wavetable position — scrub the loop through a multi-cycle capture in whole periods | One parameter that re-expresses one that exists; reuses period_detect unchanged |
DEFERRED — R1, 2026-08-03 |
| W2 | Band-limited playback — per-octave lowpassed tables + a better interpolator | Real DSP build; the one item that reaches process(); needs a declared mode to hold the migration bar |
NEAR-TERM SCOPE, second — IN by R2. Shape unruled: F9–F12 |
| W3 | A first-class multi-frame wavetable kind — N×2048 tables, morph parameter, .wav wavetable import |
Breaks "one loaded capture, one parameter set" | OUT — confirmed by R1 |
W0 first is not a preference, it is a dependency. The recommended W2 build (F9(a)) builds each level by zeroing the partials above its limit in the table's own DFT — which is exact only when the table is a whole number of periods, i.e. only when W0's declared period exists. Take a different F9 and the dependency weakens; take F9(c) and it inverts. §1.8.
R1 is a scope ruling, not a rejection of the idea. "Yet" is Daniel's word and it is load-bearing: W1's §2 section is preserved in full, argument intact, so a future reader can pick it back up rather than re-derive it. What would revive it is named there.
W0 stands entirely on its own merits and always did. It is defect-class work — Daniel cannot currently set a correct loop on his own routine material — and none of its five parts depends on a wavetable ambition. R1 does not shrink W0; R2 does not enlarge it.
And the operational headline, rewritten by R3: the Phase Ω collision is settled, and the
sequencing constraint that survives it is a file-ownership one, not a criteria one.
Everything here lands in the files Ω-W1-T5 overlay-mapping owns, so nothing from this doc
dispatches until that track has landed. The criteria conflict — Ω naming the zero-crossing
snap as behaviour-identical while a snap-radius change alters exactly that — is dissolved:
the snap-radius work is Ω's own Ω-W2-T5, and the fence it appeared to break was a scope
fence, now superseded. §6.7.
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
Ω-W2-T5's radius bound closes it.
But the deeper point survives the radius fix. The snap's premise is that a zero crossing is where a splice is safe — a heuristic borrowed from destructive editing of complex 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
lengthis exactly one periodPof aP-periodic source, the tap value equals the head value at every position. The blendv + xw·(in − v)is then the identity map for every weight. The crossfade does literally nothing. - When
lengthis not exactlyP— 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_spantherefore 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 —
Lis not a whole multiple ofP, 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 —
Lis a fine loop butsr/Lis 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 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 hid until R2
Voice::advanceFrame reads with linear interpolation between the two bracketing source
frames at readPos_, advancing by ratio_ (voice.h:622-627, 705). Two independent
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: per-octave
successively lowpassed copies of the table, precomputed at load, with the read level chosen
per voice from the playback ratio. Precompute is cold and off the audio thread —
exactly where period_detect already sits, by link graph (period_detect.h:5-7) — and the
levels are DERIVED from the audio, so like sourcePeriodFrames they are cache and not state:
nothing persists them and they take no rung of the payload ladder.
Raising the read to cubic Hermite is a separate, straight-line 4-tap change with no
indirection. Note one property that matters for its migration story: at frac == 0 a cubic
Hermite read returns pcm[i0] exactly as the linear read does, so a capture played at its
root with no offsets is bit-identical under either interpolator; only a repitched read
changes.
Both collide head-on with the plan-wide migration bar ("a project saved before a change reopens sounding identical"). Fork F6, which R2 narrows to conditional or unconditional rather than whether at all.
1.8.1 What R2 actually commits to, worked out rather than assumed
R2's word is purely, and the honest reading is faithfully — the instrument must not add artifacts the capture does not contain. Four consequences follow, and three of them retire claims this doc made after R1.
(1) It reaches process(), and that is unavoidable. Precompute is load-time, but
selecting a level is per-voice and reading it is per-sample: advanceFrame binds
pcm from sample_->frames (voice.h:448-449) and reads it every frame. Band-limiting
means that bind resolves to a level-indexed buffer instead of a fixed member. That is not a
branch, not a virtual, and not a dispatch — root CLAUDE.md's guardrail language forbids
indirection on the per-voice-per-sample path, and an indexed vector bind is none of those —
but it IS a change to the hottest loop in the program, in a header that inlines with no LTO
configured. It must be measured, not argued. This is the first item in this doc that
cannot be discharged by reasoning about the code.
(2) The ratio is not constant within a note, so "select per note-on" is an approximation,
not a design. ratio_ = baseRatio_ * envFactor (voice.h:652) — the pitch envelope
multiplies the read increment per frame, and the Pitch knob is a LIVE control re-applied to a
sounding voice (pitchOffsetRatio_, voice.h:722). A note latched at its base ratio spends
a +12 semitone pitch-envelope peak an octave under-filtered, which is exactly the artifact R2
forbids. Fork F10 carries the four available answers; the cheap ones are wrong at the
peak and the correct ones cost either dullness or a second read tap.
(3) Preserve is a different resampler and a mip level does not reach it. Under Preserve
the voice's read advances at stretchRate_ (voice.h:616) and the transposition happens
inside the SOLA shifter's fractional ring read, linear interp (pitch_shift.h:184).
A level chosen from the voice's read ratio therefore does nothing there. Covering Preserve
means feeding the ring from a level chosen by the shift ratio — a second build, inside a
module whose artifact budget is already dominated by its splice. Fork F12. Whichever way
it goes, §2's claim that W2 "improves every ordinary sample played more than an octave
above its root" is true only for Varispeed unless F12 goes to (b), and that qualification
belongs in the claim rather than in a footnote.
(4) It needs a declared mode, which is a frozen parameter id and a deck slot. §6.5, §6.8 and §6.9 are each re-derived below; all three of the post-R1 reliefs are spent.
1.8.2 The 30 Hz case makes band-limiting HARDER in depth and EASIER in kind — and the easier half decides it
Daniel's sub-50 Hz practice is first-class (§1.2 of this doc's framing, §1.3's argument), so this interaction is worked out rather than deferred.
Harder — depth and fold count. The level count needed is the octaves of upward playback.
A 30 Hz table played to C8 (4186 Hz) is 7.1 octaves → eight levels; a 440 Hz table to C8
is 3.25 octaves → four. Capturing low doubles the pyramid's depth. It also maximizes the fold
count: at C5 a 30 Hz source keeps only 24000/523.25 ≈ 45 partials un-aliased out of the up
to 24000/30 = 800 it can carry. For a saw-like 1/n spectrum the folded energy is roughly
1.3 % of total power (≈ −19 dB) — audible hash, not a theoretical concern.
Easier — and this is the decisive half. At exactly one period the table's DFT bins are its harmonics. Each level can therefore be built by transforming, zeroing every partial above that level's limit, and transforming back: exact, with no filter design, no transition band and no phase error. That is how the Serum/Vital class of synth builds its tables (§5), and it requires knowing the period exactly. §1.5 already establishes that at the single-cycle limit the period is DECLARED — it is the file length. The very property that makes detection structurally impossible is what makes exact band-limiting possible. A 220 Hz capture (218.2 frames per cycle, not an integer) has no such alignment and needs a real filter design instead.
And the depth 30 Hz forces is free exactly where it is forced. Under the same-rate table
shape (F9(a)) eight levels of a 1600-frame mono table is 12,800 floats ≈ 51 KB. Depth is
cheap precisely because a low capture's table is short in absolute terms. It is the long
capture that is expensive — and the instrument loads any bank capture, a full-mix stem
bounce included, where SampleData::frames is a std::vector<float> per channel of the
whole file. Eight same-rate levels of a three-minute stereo bounce is hundreds of megabytes.
So the pyramid must be gated on length or on the micro classification, and that gating is
what collides with §3's never-change-the-sound constraint — see §3.
One floor worth stating because it makes the migration argument cheap: at or below the
root there is nothing to fold (ratio ≤ 1), so level 0 is the unmodified capture and a note
played at root is bit-identical with band-limiting on. The pyramid is an upward-only
mechanism.
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·Prather 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 thepitchOffsetSemitonesparameter 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_detectunchanged, 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 — IN SCOPE by R2 (2026-08-03)
Daniel, 2026-08-03: "yes, complex waveforms must be supported purely." A static single-cycle capture of a saw, played at C5, aliases exactly as §1.8 describes; purely forbids that. F6 is no longer "does this ship" — it is "conditional or unconditional".
It remains the only one of the four shapes that is a genuine DSP build, and it is independently valuable outside this direction: it improves every ordinary sample played more than an octave above its root, a much larger population than single-cycle captures — for Varispeed voices, and for Preserve voices only if F12 goes to (b) (§1.8.1(3)).
It is no longer orthogonal to W0. Under the recommended shape the levels are built by zeroing partials above each limit in the table's own DFT, which is exact only over a whole number of periods — so W2 consumes the declared period W0 establishes (§1.8.2). W0 first is therefore a dependency, not a preference. Under F9(b) the dependency softens to a preference; under F9(c) it inverts, because a resynthesized table supplies its own period.
The four shape decisions, each a fork, with the recommended package named. They are genuinely independent axes, so Daniel can take the package in one word or override any single one:
| Fork | Axis | Recommended | Why, and what it costs |
|---|---|---|---|
| F6 | Conditional or unconditional | (b) gated behind a declared mode | Unconditional breaks the migration bar for every saved instance that plays above its root. The mode costs a frozen parameter id (§6.8) and a deck slot (§6.9) |
| F9 | The table's shape | (a) same-rate filtered copies | Zero index-domain change — loop points, startFrame, the crossfade tap and sourcePeriodFrames all keep their meaning. Costs L×N memory, so it must be gated on capture length |
| F10 | When the level is chosen | (c) per-block reselection | Matches the standing "observation is at block boundaries, never per frame" discipline; no per-sample branch. Costs a small spectral step at a level change |
| F11 | Does the interpolator upgrade ride the same lever | (c) cubic Hermite under the same mode | One lever, one migration event; the two are the same complaint about reconstruction quality on a repitched read |
| F12 | Does Preserve get covered | (a) Varispeed only, for now | Cheap, and defensible at micro scale where Preserve is definitionally wrong anyway (§1.7). Costs the unqualified version of the "improves every ordinary sample" claim |
What W2 does NOT do, stated so it is not assumed: it writes nothing to the bank, adds no
Sample field, and takes no payload rung for the tables themselves — they are derived cache
on the period_detect precedent. Only the mode persists, as an additive ComponentState
tail on the established discipline.
W3 — A first-class wavetable KIND — OUT (recommended out; CONFIRMED by R1)
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.
R2 puts that constraint under direct pressure, and the resolution matters more than the threshold below it. §1.8.2 shows the band-limited tables must be gated on capture length or on the micro classification, because eight same-rate levels of a stem bounce is hundreds of megabytes. But if a derived classification decides whether a capture is band-limited, then a heuristic decides its sound — the exact thing this section forbids. Split the two gates and the constraint survives intact:
- The MODE decides the sound, and the mode is the user's (F6(b)). Band-limiting is on or off because the user said so, on any capture, of any length.
- The classification decides only whether the tables are built EAGERLY at load. A non-micro capture with the mode on still gets band-limited; it just pays its build (or a bounded-depth build) on demand rather than eagerly. That is a memory-and-latency policy, not a sound policy.
This is a recommendation, not a ruling — it changes what F1 is choosing between, because it means F1(a) no longer has to carry a sound decision it was explicitly built not to carry. If Daniel prefers the simpler "derived classification also gates band-limiting", §3's hard constraint has to be relaxed explicitly rather than eroded quietly.
A threshold derived from the codebase rather than from taste. A capture is a micro candidate when both:
detectPeriodreturned none (there is not enough material to correlate), and- 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
detectPeriodreturns 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_detectproduces 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 downSampleData::sourcePeriodFrames. periodAnalysisSpanalready 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:
setSourcePeriodfeeds 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.
R2 gives the DECLARED half of the split a second consumer, and it is the more demanding one. W0 wants the period to place a snap grid — an error of a fraction of a frame there costs a slightly wrong default. W2's recommended build wants it to align a DFT with the table's harmonics, where the alignment is either exact or the whole method degrades into an approximate filter design (§1.8.2). That raises the bar on the declaration but not on the detection: at the single-cycle limit the declaration is the file length, which is exact by the capture's own exact-bounds guarantee (§6.2), and above that limit the exactness has to be earned from a whole-period loop rather than from a detector's fractional estimate. The two sources of truth stay two; R2 makes the declared one load-bearing for sound, where before it was load-bearing only for a default.
5. Prior art borrowed, and what each contributes
- 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 — per-octave band-limited tables, built by spectral truncation of a whole-period table rather than by filter design. This is the whole of W2, and their universal adoption of it is the evidence that §1.8 is not a theoretical concern. Now load-bearing: R2 rules W2 in, and the spectral-truncation build is what F9(a) borrows. The transplant is not free — those synths own their table format and can guarantee a whole-period power-of-two frame, where we inherit whatever the capture is; §1.8.2 is where that difference is paid.
- Kontakt / EXS-class samplers' loop-finding tools — snap a loop to a detected period and offer a "find loop" pass, rather than snapping only to zero crossings. This is the established precedent for F2's period grid; we would not be inventing it. The load-bearing precedent for W0, which is the first of the two near-term bodies of work.
- Serum's import-a-sample-as-a-wavetable flow — it asks the user to declare or confirm the cycle length rather than detecting it from a single cycle. That is independent confirmation of §1.5's declare-vs-detect split, arrived at from the same constraint.
- 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 now decides only whether that cost is paid unconditionally or made conditional and declared.
R2 retires the post-R1 relief here. The near-term scope is no longer migration-clean as a whole; W0 still is, and W2 is not. Two properties make the bar cheap to hold rather than expensive, and both are worth stating because they are easy to miss:
- At or below the root nothing folds, so level 0 is the unmodified capture and a note at root is bit-identical with the mode on (§1.8.2).
- A cubic Hermite read at
frac == 0returnspcm[i0], exactly as the linear read does — so the interpolator half is also bit-identical at unity ratio (§1.8).
The bar therefore breaks only for repitched playback, and only with the mode on. Under F6(b) a saved instance reopens with the mode off and sounds identical, which is the whole argument for (b) over (c).
6.6 The hot path — HELD for W0; W2 reaches it, and the post-R1 claim here is RETIRED
The retired claim, stated plainly so nobody carries it forward: after R1 this section said
"the entire near-term scope is load-time and UI-thread only. Nothing in W0 goes near
process()." That was true of W0 and is still true of W0. It is FALSE of the near-term scope
as R2 leaves it.
- The period grid, the classification and the tune-from-loop computation are load-time and UI-thread only. W0 is unchanged by R2 in every respect.
- W1's scrub is a reload-tier or note-on-latched parameter, not a per-sample one; if it is
wanted live, it belongs in the existing three-tier commit classification
(
deckParamCommit/liveCommitFor) rather than in a new mechanism. (Deferred by R1.) - W2's table build is load-time, on
period_detect's own link-graph precedent. Its level selection and its read are not.
What W2 costs on the per-voice-per-sample path, as precisely as this doc can state it without a measurement:
- The read.
advanceFramebindspcmfromsample_->frames(voice.h:448-449) every frame. Band-limiting makes that an indexed bind into a level array. No branch, no virtual, no dispatch — so rootCLAUDE.md's guardrail wording and structural heuristic 3 are both satisfied in kind. ButadvanceFrameis header-inline by RT constraint with no LTO configured, and whether the compiler hoists the indexed load out of the block loop is a fact about the generated code, not about the design. Measure it. - The level selection. Under F10(c) it is once per voice per block — the same cadence as the live block's seqlock read, which is an established discipline rather than a new mechanism. Under F10(d) it is a second read tap per frame, which is the identical cost §6.6 flagged for W1's morph and which must clear the guardrail before it is promised.
- The interpolator. Two extra loads and a handful of multiply-adds per channel per frame under F11(b)/(c), straight-line, no dispatch. The cheapest of the three to reason about and the most expensive to un-ship, because it changes every repitched note.
voice.his already a documented ~600-line ceiling exception, so W2's edits there need a responsibility seam rather than added bulk — and F9(b)'s level-relative frame domain would add far more of it than F9(a)'s bind change. That is a structural argument for F9(a) independent of the memory one.
Net: W0 is guardrail-free; W2 is the first item in this direction that a specialist must measure before it can be accepted. A W2 track whose acceptance criteria contain no before/after number has an opinion, not a criterion — the same rule Phase Ω applies to its own two performance items.
6.7 Phase Ω — the criteria conflict is SETTLED by R3; the file-ownership constraint stands
This was the item most likely to cost someone a merge. R3 removes half of it.
Daniel, 2026-08-03: "call it part of omega, that was a fence you built not me."
- The conflict this section recorded is dissolved, not sequenced around. The snap-radius
work is a Phase Ω track — Ω-W2-T5 (branch
omega-w2-t5-snap-radius, commitb7b7e88) — which bounds the snap tokMarkerGrabWidth(5 px,waveform_view.h:70) and adds Ctrl-defeats-snap on a marker drag. - The fence was a scope constraint, not a product ruling. Ω's acceptance criterion naming
the zero-crossing snap among the things that "must come out the other side identical" was
written to stop Ω's own tracks perturbing the snap while they fixed the coordinate
mapping. It was never a finding that the snap's unbounded fan-out was correct, and Daniel's
DAW evidence supersedes it.
PLAN.mdnow records the supersession at all three criterion sites. - Ω-W1-T5 is not invalidated by this. It was required to preserve the snap exactly, and it did; its tie-rule fixtures pass unmodified under the bounded entry, because bounding the fan-out changes which frames are reachable, not how a reachable tie resolves. The change came later and from a different track.
- What survives is a pure file-ownership constraint, and it is unchanged in form. Every
candidate in this doc touches
waveform_view.{h,cpp},editor_input_waveform.cppandeditor_controls.cpp'swaveMarksFor/grabbableMarks— Ω-W1-T5's exact surface — so nothing here dispatches until Ω-W1-T5 has landed, and the period grid should be written against the post-Ω single coordinate mapping and on top of Ω-W2-T5's radius rule rather than replacing either. (At the time of writing,dev'snearestZeroCrossing(waveform_view.cpp:181-210) is still the unbounded fan-out —b7b7e88is on its own branch. Confirm what has actually merged before dispatching, rather than reading landed status out of this doc.) - One documentation gap R3 creates, flagged rather than filled here.
Ctrl-defeats-snap on a marker drag is a new user-visible gesture with no home in
docs/product/instrument-control-surface.md— and worse than absent, it is contradicted there: §4.1's exclusion block states that waveform markers are excluded from the modifier law because "overloading Shift there would collide with a shipped gesture", which now reads as a claim that markers carry no modifier at all. Flagged for doc-keeper: that block needs amending and the gesture needs documenting. This doc does not write that content. - And one cascade into this doc's own forks: Ctrl on a waveform-mark drag is now TAKEN. F2's option (c) — a modifier-selected choice between the period grid and the zero-crossing grid — can no longer use Ctrl, and Shift is already excluded on marks for the reason quoted above. F2(c) now has to name a modifier it can actually have.
6.8 The frozen VST3 parameter table and the one-way doors
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 removed the near-term exposure here; R2 restores it, in a much cheaper form. W2's mode is a new parameter, so the door is open again — but a two-state mode is the least dangerous parameter this doc could have produced:
- Its taper is trivial and its range is
{off, on}, soparameter-automation.md§8's "the taper and range freeze the moment it ships" costs nothing to get right. - Its id is permanent regardless. It appends to the FOREVER-FROZEN table from Γ-W4-T1 and
is never renumbered, and
param_id.h's signal-flow-order comment takes a second annotated exception — cheap precedent, already set by Ω-W1-T4. - The real door is the mode's SEMANTICS, not its taper. A boolean "band-limit on/off" can never later become a three-way quality selector without either re-tapering a frozen parameter or minting a second id and deprecating the first. If more than two states are ever plausible, that has to be decided before the parameter ships, not after — this is the one thing about W2 that must be right first time.
- A capture-length or depth limit is NOT a parameter. It is an internal policy (§3), and keeping it out of the parameter table is what stops a memory decision from freezing into the automation surface.
W0 still adds no parameter at all, which remains an argument for taking it first.
6.9 The editor's space budget
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 relieved it; R2 spends it back. W0's five parts still add no control to the deck — a snap grid, a default, and two greyed affordances are all changes to existing surfaces — and F3's "Tune to loop" is the one item that might want a hit target, which is a far smaller ask than a knob. But W2's mode toggle is exactly the control §1.6 was warning about, and the warning at the end of the post-R1 paragraph ("it comes back the moment F6 is ruled toward a mode toggle") has come true.
The conditional-cost option, and it should be measured before any px is budgeted: a
caption-row toggle in a group with caption slack costs ZERO width. The precedent is
FILTER's Band|Notch, which rides its caption slack rather than the knob row and is the
−92 px that makes the SOUND row fit; a group carries two caption-toggle slots for exactly
this reason, and a rowToggle is the expensive alternative that widens the group. Whether a
second caption slot with slack exists in a group where a band-limit mode belongs — the read
path is a pitch-side concern — is a measurement against deck_groups/knob_deck, not an
assumption this doc may make. If a free slot exists, W2 costs 0 of the 82 px. If not, it
costs a rowToggle's width and the headroom is genuinely spent.
Either way the headroom should not be spent elsewhere on the assumption that this direction released it — R1's relief was temporary and R2 ended it.
7. Forks — TWELVE total: ONE settled (F5), ELEVEN open
Each carries a product-designer lean and the cost of taking it. Every one is Daniel's to rule. None is pre-ruled here and none should be treated as settled by the lean. Rulings recorded against this doc are logged in §7.1; the lean column below states, per fork, whether R1 or R2 moved it.
F9–F12 are new, minted by R2 to carry W2's shape. R2 ruled that band-limiting happens; it deliberately did not rule how. Four independent axes, so the recommended package can be taken in one word or overridden fork by fork:
Recommended package: F6(b) + F9(a) + F10(c) + F11(c) + F12(a) — a declared mode, gating same-rate spectrally-truncated levels, reselected per block, with the cubic read on the same lever, Varispeed only for now. Costs:
L×Nmemory on a gated set of captures, a permanent two-state parameter id, a deck toggle that may be free, a small spectral step at a level change, and a measurement obligation on the per-sample read.
| Fork | Question | Options | Standing / lean |
|---|---|---|---|
| F1 | Is there a "micro" classification at all, and where does it come from? | (a) derived-only, governing defaults and snap grid only, never the sound; (b) derived + a user override toggle; (c) no classification — the period grid is always available and a bad default on micro material is accepted | OPEN. Lean unmoved: (a), with (c) as the serious conservative alternative. R1 amends the test inside (a) — the frame-count band now classifies and detection supplies the period. R2 adds a job (a) must NOT take: gating band-limiting on a derived class would let a heuristic decide the sound. §3 recommends splitting the gates (mode decides sound, class decides eager build) so (a)'s constraint survives |
| F2 | What grid does a loop-mark drag snap to? | (a) zero-crossing always (today); (b) period when a period is known, zero-crossing otherwise; (c) both, modifier-selected | OPEN. Lean unmoved: (b) — it makes the octave error unreachable rather than unlikely. Simplified by R1: on a true one-cycle file the period grid admits exactly ONE loop (start 0, whole file). Narrowed by R3: (c)'s modifier vocabulary is now nearly empty — Ω-W2-T5 takes Ctrl for defeat-snap on a marker drag, and instrument-control-surface.md §4.1 already excludes Shift on marks. (c) must now name a modifier it can actually have. R2 raises (b)'s stakes — a non-whole-period seam step scales with the source's high-frequency content, which is what a complex waveform has more of |
| F3 | Does the instrument correct tuning from the loop length? | (a) never — the user dials Pitch (today); (b) a one-shot "Tune to loop" that writes pitchOffsetSemitones; (c) automatic and continuous, derived and unstored |
OPEN. Lean (b) FIRMS. (c)'s only real motivation was keeping tuning correct while a scrub moved the loop; R1 removes the scrub, so with a static loop length a one-shot write is unambiguous and (c) is now pure hidden-pitch-modifier with nothing bought |
| F4 | Fractional loop bounds? | (a) no — integer forever; buy resolution by capturing low, and document that; (b) yes — fractional loop end, which changes a bank intrinsic on Sample and takes a payload rung |
OPEN. Lean (a) HOLDS, but R2 puts it under real pressure and that should be visible. The tuning arithmetic is unchanged (~1 ¢ at 30 Hz), but a one-frame phase error at the seam produces a step whose size scales with the waveform's slope — small on a sine, larger on a harmonically rich capture. The existing mitigation still carries it: a one-cycle file is one period by construction (§1.3), so the error is a bad-capture case, not a routine one. Note that F9(c) would dissolve this fork entirely by taking pitch off L — one more reason it is the option that deserves an explicit refusal rather than silence |
| F5 | Does W1 ship, and does it morph? | (a) not now; (b) scrub only — whole periods, no interpolation; (c) scrub + interpolate between adjacent period-frames | SETTLED (a), Daniel 2026-08-03 (R1). This inverts the lean, which was (b) — the lean assumed multi-cycle evolving material, and §8's own test said so. "Yet": revisit if the material changes, per §2's W1 section |
| F6 | Band-limiting (W2) — conditional or unconditional? | OPEN, narrowed to a binary by R2 (2026-08-03). R2 answered the whether: "complex waveforms must be supported purely." Lean unchanged: (b) — and it is cheap, because at root and at unity ratio the two paths are bit-identical (§6.5), so (b)'s off state is a genuine no-op rather than a maintained second engine. (c) buys one less parameter at the cost of every saved instance that plays above its root | |
| F7 | Preserve, and the XFADE mark, at one-period loops | (a) leave both live and let the user discover they do nothing; (b) refuse/grey both when the loop is one period; (c) auto-switch the engine to Varispeed | OPEN. Lean (b) FIRMS and simplifies. R1 makes the one-period loop the typical shape rather than one of several, so the affordance is wrong more of the time; and with a static loop the condition is evaluated once rather than tracked across a moving one. (c) still changes the sound of a saved instance and should still be rejected |
| F8 | Does this become a Phase, and when? | (a) one phase, two waves — W0 then W2 — dispatched after Ω-W1-T5 lands; |
OPEN. Lean: (a), with (d) the serious alternative R2 creates. R1 had reduced this to a binary; R2 re-opens it, because W0 and W2 now differ in kind — W0 is migration-clean, parameter-free, guardrail-free defect work, and W2 is a measured DSP build with a frozen parameter and five unruled shape forks. (a) keeps the dependency (W2 consumes W0's declared period) inside one plan; (d) stops W0 waiting on W2's forks. If F9–F12 are not ruled when W0 is ready, (d) is the honest answer |
| F9 | (new, R2) What SHAPE do the band-limited tables take? | (a) same-rate filtered copies — L full-length levels, zero index-domain change; (b) decimated mip pyramid — memory bounded at ~2× regardless of depth, but every frame-domain quantity (loop wrap, crossfade tap pos − length, Trigger span, spline phase, sourcePeriodFrames) becomes level-relative; (c) resynthesize to a fixed power-of-two table from the declared period |
OPEN. Lean: (a), gated on capture length. It keeps the change inside a buffer bind rather than spreading a second frame domain through voice.h — already a documented ceiling exception. (b) is the right answer if long captures must be covered without gating. (c) is recommended AGAINST and the reason is identity, not cost: it dissolves F4's quantizer and makes every level exact, but the instrument would stop playing the captured bytes — this tool's trust anchor is that what you captured is what you hear, and (c) is also the option that most resembles what R1 put out of scope |
| F10 | (new, R2) WHEN is a voice's level chosen? | (a) note-on latch from baseRatio_; (b) note-on latch from the worst-case ratio the note can reach (pitch-env peak + velocity throw); (c) per-block reselection; (d) per-block with a crossfade between adjacent levels |
OPEN. Lean: (c) — it matches the standing "observation is at block boundaries, never per frame" discipline and adds no per-sample branch. (a) is wrong under a pitch envelope: ratio_ = baseRatio_ * envFactor (voice.h:652) and the Pitch knob is LIVE, so a latched level is under-filtered at the peak — the artifact R2 forbids. (b) is the fallback if (c)'s step is audible: no step by construction, at the cost of a dull note start. (d) removes the step and costs a second read tap per frame — the same cost this doc refused to promise for W1's morph without a measurement |
| F11 | (new, R2) Does the interpolator upgrade ride the same lever? | (a) linear stays; (b) cubic Hermite unconditionally; (c) cubic under the same declared mode as band-limiting | OPEN. Lean: (c) — one lever, one migration event, and the two are the same complaint about reconstruction quality on a repitched read. A mip fixes folding but not linear interpolation's sinc² sag and imaging, so (a) leaves half of purely undelivered. (b) is defensible on the grounds that it is bit-identical at frac == 0 — root playback is unchanged — but it still moves every repitched note in every saved instance with no way to decline |
| F12 | (new, R2) Does Preserve get covered? | (a) Varispeed only, for now; (b) also feed the SOLA ring from a level chosen by the SHIFT ratio | OPEN. Lean: (a). Under Preserve the voice reads at stretchRate_ (voice.h:616) and the transposition is the shifter's own fractional ring read (pitch_shift.h:184), so a voice-side level does nothing there — (b) is a second build inside a module whose artifact budget is already dominated by its splice. (a) is defensible at micro scale, where Preserve is definitionally the wrong engine anyway (§1.7). The cost of (a) is a claim, and it must be qualified rather than dropped: "improves every ordinary sample played above its root" is Varispeed-only under (a) |
What is NOT a fork, and should not be re-opened as one:
- The pre-seam crossfade cannot deliver micro-scale smoothness (§1.2). Structural — and
untouched by R1 and by R2 alike. The identity-map result is arithmetic on any
P-periodic source, not a property of sines: atlength == Pthe tappos − lengthequals the head for a saw exactly as it does for a sine. A complex waveform does not create a job for the crossfade that it did not have before. period_detectcannot see a single cycle (§1.5). Structural — and untouched by R1 and R2. The declare-vs-detect split survives intact; R2 gives the DECLARED arm a second and more demanding consumer (§4) without changing what either arm can do.- W3 breaks "one capture, one parameter set" (§2), and is now out by Daniel's ruling as well as by that argument. If it returns, it returns as its own product question.
- Sub-50 Hz is first-class. Nothing here narrows a range to avoid it, and R1 does not change 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·Pis 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 did NOT settle (both items have since been ruled — see R2 and R3):
- F6 / band-limiting. Left outstanding by R1 and answered by R2 below.
- The Phase Ω conflict in §6.7. Left open by R1 and dissolved by R3 below.
- F1, F2, F3, F4, F7, F8 — all still Daniel's. R1 shifts what some of them are choosing between (the table above says which) without choosing.
R2 — Daniel, 2026-08-03. Verbatim, answering this doc's own second outstanding question (is the material ever anything but sine?):
"yes, complex waveforms must be supported purely."
What it settles:
- F6's option (a) — "out for now" — is eliminated. W2 is in scope, and the fork narrows to conditional (b) vs unconditional (c).
- The near-term scope becomes W0 AND W2, in that order, because the recommended W2 build consumes W0's declared period (§1.8.2).
- §8's kill test for W2 is met by evidence rather than preference, the same way R1 met W1's. The condition was written before the answer existed.
What it costs, stated because a scope ruling that hides its price is not decision-grade — all three of R1's reliefs are spent:
- It reaches
process(). The read binds a level-indexed buffer (voice.h:448-449). No branch, no dispatch, no virtual — but a measurement obligation, not an argument (§6.6). - It opens a one-way door: a permanent VST3 parameter id whose semantics (two-state vs a quality selector) must be right first time (§6.8).
- It contends the 82 px deck headroom again — possibly at zero cost via a caption-row slot, which is a measurement and not an assumption (§6.9).
- It breaks the migration bar for repitched playback, which is why the lean is F6(b); root playback and unity-ratio playback stay bit-identical either way (§6.5).
What it explicitly does NOT settle: the shape. R2 rules that band-limiting happens, not how. F9 (table shape), F10 (level selection), F11 (interpolator), F12 (Preserve coverage) are minted open, each with a lean and a cost, and the recommended package at the head of §7 is a recommendation and not a ruling. F1, F2, F3, F4, F7, F8 remain Daniel's; R2 moves what F1, F2, F4 and F8 are choosing between (the table says how) without choosing.
R3 — Daniel, 2026-08-03. Verbatim, on the Phase Ω conflict recorded at §6.7:
"call it part of omega, that was a fence you built not me"
What it settles:
- The snap-radius work is a Phase Ω track — Ω-W2-T5 (branch
omega-w2-t5-snap-radius, commitb7b7e88): the snap is bounded tokMarkerGrabWidth(5 px) and a marker drag gains Ctrl-defeats-snap. - Ω's "the zero-crossing snap comes out identical" criterion was a scope fence, not a
product ruling, and is superseded by Daniel's DAW evidence.
PLAN.mdrecords the supersession at the phase criterion and at Ω-W1-T5's two criterion sites. - Ω-W1-T5's work is not invalidated. It was required to preserve the snap exactly and it did; its tie-rule fixtures pass unmodified under the bounded entry. The change came later and from a different track.
- §6.7 becomes a record rather than an open item. What survives from it is a file-ownership sequencing constraint, unchanged in form.
Two consequences R3 creates, both recorded rather than resolved here:
- Ctrl-defeats-snap is a new user-visible gesture that
instrument-control-surface.md§4.1 contradicts. Flagged for doc-keeper; this doc does not write that content. - F2's option (c) loses Ctrl, and Shift was already excluded on marks. (c) must name a modifier it can have.
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 neither ruling touches that argument. Daniel cannot currently set a correct loop on his own routine material, and Ω-W2-T5's radius bound alone leaves a one-pixel drag able to produce a 0.997-period loop. That is defect-class. R2 adds a second reason rather than replacing this one: a non-whole-period seam step scales with the source's high-frequency content, so complex material makes the same defect louder.
- W1 is worth doing if Daniel's captures are ever multi-cycle and evolving. ANSWERED 2026-08-03: they are not — "just static captures right now." The position knob has nothing to scrub, and the test written here before the answer arrived is the reason F5 closes cleanly rather than by preference. The condition is unchanged and still live as a trigger: if the practice changes, this bullet is what re-opens F5.
- W2 is worth doing if the material is ever anything but sine. ANSWERED 2026-08-03: it is — "yes, complex waveforms must be supported purely." A sine played up four octaves is fine; a captured saw is not, and purely forbids the difference. W2 stops being optional and becomes the actual answer to "turn this into a wavetable synth." Both of this section's tests have now been met by evidence rather than by preference, which is the whole reason for writing them before the answers existed — R1 killed W1 on the first, R2 committed W2 on the second, and neither turned on anyone's taste.
- The test that remains, and it now sits under W2 rather than beside it: is a DECLARED quality mode acceptable, or must the instrument simply sound right with no switch? F6(b) buys the migration bar with a control; F6(c) buys one less control with a behaviour change in every saved instance that plays above its root. If Daniel's answer is "it should just be right", F6 goes to (c) and the parameter and deck-slot costs in §6.8/§6.9 vanish with it.
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
neither R1 nor R2 changes that — PLAN.md is the active on-deck specification list, and
putting a phase in it while F8 is unruled would both put an unbriefable phase in that list and
falsify the plan's standing claim that Λ is the only phase carrying unanswered [Daniel]-class
questions.
What the two rulings change is what a proposable phase would contain. After R1 it was W0-only. After R2 it is two waves — W0 then W2 — with the order forced by W2 consuming W0's declared period:
- Wave 1 (W0) is briefable the moment F1, F2, F3, F4 and F7 are ruled. It is defect-class, migration-clean, parameter-free and guardrail-free.
- Wave 2 (W2) needs F6 plus the four new shape forks (F9–F12), and carries a measurement obligation and a permanent parameter id.
That asymmetry is what re-opens F8 and makes (d) — two phases rather than one — a serious option R1 had closed off: W0 should not wait on W2's forks. When F8 is ruled, this doc becomes the backing product doc in the ordinary way — the Γ / Ε / Ρ / Λ pattern, not the doc-less Ψ / Ω one.
One documentation obligation this doc creates but does not own, restated here so it is not
lost: Ω-W2-T5's Ctrl-defeats-snap gesture needs a home in
docs/product/instrument-control-surface.md, whose §4.1 currently reads as though waveform
markers carry no modifier at all. Flagged for doc-keeper.