Skip to content

Commit ca9d108

Browse files
kieranhjclaude
andcommitted
Tick envelope on cs 0 instead of after T cs of silence
expand() previously emitted a sample at amp=0 on cs 0 before running the first envelope tick, giving every note a 10 ms silent prefix. Real BBC writes the attack target to the chip immediately on SOUND. Fixed by starting csUntilNextStep at 0 and reordering the loop so the tick fires before the cs sample is pushed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent eb360a6 commit ca9d108

1 file changed

Lines changed: 25 additions & 24 deletions

File tree

src/envelope.ts

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ export function expand(env: Envelope, soundAmplitude: number, soundDuration: num
109109
let pitchOffset = 0;
110110
let sectionIdx = 0;
111111
let stepInSection = 0;
112-
let csUntilNextStep = Math.max(1, tStep);
112+
// Start at 0 so the very first envelope tick fires on cs 0 — the BBC
113+
// writes the initial volume immediately when SOUND fires, not after
114+
// T cs of silence.
115+
let csUntilNextStep = 0;
113116

114117
// BBC-accurate pitch envelope advance. Subtleties:
115118
// - When a section's PN is exhausted but the next section has PN > 0, the
@@ -175,29 +178,9 @@ export function expand(env: Envelope, soundAmplitude: number, soundDuration: num
175178
let zeroAmpCount = 0;
176179

177180
while (csElapsed < MAX_CS) {
178-
samples.push({ pitchOffset, amplitude: clamp(amplitude, 0, 126), phase });
179-
180-
// SOUND duration end forces release from any non-release phase. The check
181-
// is per-cs so release starts at the right cs even if it's between
182-
// envelope ticks; the actual release amp change happens on the next tick.
183-
// In hold mode (BBC SOUND duration -1) the envelope keeps running its
184-
// attack/decay/sustain phases without ever transitioning to release —
185-
// we just stop emitting samples at the bound.
186-
if (useEnvelope && csElapsed + 1 >= noteCentiseconds) {
187-
if (hold) {
188-
samples.push({ pitchOffset, amplitude: clamp(amplitude, 0, 126), phase });
189-
return samples;
190-
}
191-
if (phase !== "release") {
192-
phase = "release";
193-
if (releaseStartedAt < 0) releaseStartedAt = csElapsed;
194-
}
195-
}
196-
197-
// Envelope tick: pitch step *and* amplitude envelope advance every T cs.
198-
// The trace from a real BBC OS shows amp updates land at exactly the same
199-
// cadence as the pitch envelope — AA, AD, AS, AR are per envelope tick,
200-
// not per centisecond. (Verified against jsbeeb headless trace.)
181+
// Envelope tick — runs BEFORE emitting the cs sample so cs 0 reflects
182+
// the post-attack state. (Real BBC writes the initial volume at SOUND
183+
// start, not after a T-cs delay.)
201184
csUntilNextStep -= 1;
202185
if (csUntilNextStep <= 0) {
203186
stepPitch();
@@ -242,6 +225,24 @@ export function expand(env: Envelope, soundAmplitude: number, soundDuration: num
242225
// Static amplitude: -15..-1 maps linearly to BBC level 0..126.
243226
const staticLevel = Math.round((-soundAmplitude / 15) * 126);
244227
amplitude = clamp(staticLevel, 0, 126);
228+
}
229+
230+
samples.push({ pitchOffset, amplitude: clamp(amplitude, 0, 126), phase });
231+
232+
// SOUND duration end forces release from any non-release phase. The
233+
// actual release amp change happens on the next tick.
234+
if (useEnvelope && csElapsed + 1 >= noteCentiseconds) {
235+
if (hold) {
236+
samples.push({ pitchOffset, amplitude: clamp(amplitude, 0, 126), phase });
237+
return samples;
238+
}
239+
if (phase !== "release") {
240+
phase = "release";
241+
if (releaseStartedAt < 0) releaseStartedAt = csElapsed;
242+
}
243+
}
244+
245+
if (!useEnvelope) {
245246
if (csElapsed + 1 >= noteCentiseconds) {
246247
samples.push({ pitchOffset, amplitude: 0, phase: "release" });
247248
return samples;

0 commit comments

Comments
 (0)