@@ -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