@@ -136,25 +136,127 @@ describe("the worklet processors themselves", () => {
136136 return crossings / ( ( s . length - start ) / SR ) ;
137137 }
138138
139- it ( "at semitones: 0, mix: 1 reproduces the input, delayed by exactly one grain/2" , async ( ) => {
139+ // This assertion used to be that the output equalled the input DELAYED by
140+ // grain/2 — the measurement was right and was written down as the contract.
141+ // But the grain delay is there to shift pitch, and at semitones: 0 nothing
142+ // is being shifted: the node degenerated into a pure 50 ms delay of the
143+ // signal, plus a head of silence while the ring filled, under a label that
144+ // reads "Unchanged pitch".
145+ it ( "at semitones: 0, mix: 1 passes the input through untouched" , async ( ) => {
140146 const HfPitchshift = ( await loadProcessors ( ) ) . get ( "hf-pitchshift" ) ;
141147 if ( ! HfPitchshift ) throw new Error ( "hf-pitchshift not registered" ) ;
142148 const p = new HfPitchshift ( { processorOptions : { semitones : 0 , mix : 1 } } ) ;
143149 const input = sine ( 440 , 0.5 ) ;
144150 const output = run ( p , input ) ;
145- const grain = Math . round ( SR * 0.1 ) ;
146- // readTap reads from `write - 1`, i.e. one sample behind the one just
147- // written in this same iteration — so the effective delay is one sample
148- // more than the nominal grain/2.
149- const delay = grain / 2 + 1 ;
150- // Skip the first grain while the ring buffer is still filling.
151151 let maxErr = 0 ;
152- for ( let i = grain * 2 ; i < input . length ; i ++ ) {
153- maxErr = Math . max ( maxErr , Math . abs ( ( output [ i ] ?? 0 ) - ( input [ i - delay ] ?? 0 ) ) ) ;
152+ for ( let i = 0 ; i < input . length ; i ++ ) {
153+ maxErr = Math . max ( maxErr , Math . abs ( ( output [ i ] ?? 0 ) - ( input [ i ] ?? 0 ) ) ) ;
154154 }
155155 expect ( maxErr ) . toBeLessThan ( 1e-6 ) ;
156156 } ) ;
157157
158+ it ( "mix: 0 passes the input through untouched too" , async ( ) => {
159+ const HfPitchshift = ( await loadProcessors ( ) ) . get ( "hf-pitchshift" ) ;
160+ if ( ! HfPitchshift ) throw new Error ( "hf-pitchshift not registered" ) ;
161+ const p = new HfPitchshift ( { processorOptions : { semitones : 7 , mix : 0 } } ) ;
162+ const input = sine ( 440 , 0.25 ) ;
163+ const output = run ( p , input ) ;
164+ let maxErr = 0 ;
165+ for ( let i = 0 ; i < input . length ; i ++ ) {
166+ maxErr = Math . max ( maxErr , Math . abs ( ( output [ i ] ?? 0 ) - ( input [ i ] ?? 0 ) ) ) ;
167+ }
168+ expect ( maxErr ) . toBeLessThan ( 1e-6 ) ;
169+ } ) ;
170+
171+ /** Largest sample-to-sample step — a splice between the dry and the
172+ * ~50 ms-delayed wet path shows up here as a discontinuity. */
173+ function maxStep ( s : Float32Array , from : number , to : number ) : number {
174+ let worst = 0 ;
175+ for ( let i = from + 1 ; i < to ; i ++ ) {
176+ worst = Math . max ( worst , Math . abs ( ( s [ i ] ?? 0 ) - ( s [ i - 1 ] ?? 0 ) ) ) ;
177+ }
178+ return worst ;
179+ }
180+
181+ // Dragging the semitones slider off zero mid-playback swaps the output from
182+ // x[t] to x[t-50ms]. Switched hard that is an audible click; the wet amount
183+ // is ramped instead. A 440 Hz sine steps ~0.057 per sample at its steepest,
184+ // so anything near the signal's own peak is a splice, not the waveform.
185+ it ( "does not click when the shift moves off zero mid-signal" , async ( ) => {
186+ const HfPitchshift = ( await loadProcessors ( ) ) . get ( "hf-pitchshift" ) ;
187+ if ( ! HfPitchshift ) throw new Error ( "hf-pitchshift not registered" ) ;
188+ const p = new HfPitchshift ( { processorOptions : { semitones : 0 , mix : 1 } } ) ;
189+ run ( p , sine ( 440 , 0.3 ) ) ; // settled dry, ring warm
190+ p . p = { ...p . p , semitones : 7 } ;
191+ const output = run ( p , sine ( 440 , 0.3 ) ) ;
192+ expect ( maxStep ( output , 0 , output . length ) ) . toBeLessThan ( 0.2 ) ;
193+ } ) ;
194+
195+ it ( "does not click on the way back to zero either" , async ( ) => {
196+ const HfPitchshift = ( await loadProcessors ( ) ) . get ( "hf-pitchshift" ) ;
197+ if ( ! HfPitchshift ) throw new Error ( "hf-pitchshift not registered" ) ;
198+ const p = new HfPitchshift ( { processorOptions : { semitones : 7 , mix : 1 } } ) ;
199+ run ( p , sine ( 440 , 0.3 ) ) ;
200+ p . p = { ...p . p , semitones : 0 } ;
201+ const output = run ( p , sine ( 440 , 0.3 ) ) ;
202+ expect ( maxStep ( output , 0 , output . length ) ) . toBeLessThan ( 0.2 ) ;
203+ } ) ;
204+
205+ // ...and having ramped back down it must reach TRUE bypass, not sit on a
206+ // permanently latched wet path. The render builds a fresh node from the
207+ // saved attribute and bypasses at semitones 0; a preview that stayed wet
208+ // would carry a 50 ms delay the export does not have.
209+ it ( "returns to true bypass after being shifted and set back to zero" , async ( ) => {
210+ const HfPitchshift = ( await loadProcessors ( ) ) . get ( "hf-pitchshift" ) ;
211+ if ( ! HfPitchshift ) throw new Error ( "hf-pitchshift not registered" ) ;
212+ const p = new HfPitchshift ( { processorOptions : { semitones : 7 , mix : 1 } } ) ;
213+ run ( p , sine ( 440 , 0.3 ) ) ;
214+ p . p = { ...p . p , semitones : 0 } ;
215+ run ( p , sine ( 440 , 0.3 ) ) ; // ramp down settles here
216+
217+ const input = sine ( 440 , 0.3 ) ;
218+ const output = run ( p , input ) ;
219+ let maxErr = 0 ;
220+ for ( let i = 0 ; i < input . length ; i ++ ) {
221+ maxErr = Math . max ( maxErr , Math . abs ( ( output [ i ] ?? 0 ) - ( input [ i ] ?? 0 ) ) ) ;
222+ }
223+ expect ( maxErr ) . toBeLessThan ( 1e-6 ) ;
224+ } ) ;
225+
226+ // A node parked at mix 0 has shifted nothing, so it must not have spent
227+ // anything that stops the zero-shift bypass engaging later.
228+ it ( "is transparent at zero after sitting mixed fully out" , async ( ) => {
229+ const HfPitchshift = ( await loadProcessors ( ) ) . get ( "hf-pitchshift" ) ;
230+ if ( ! HfPitchshift ) throw new Error ( "hf-pitchshift not registered" ) ;
231+ const p = new HfPitchshift ( { processorOptions : { semitones : 7 , mix : 0 } } ) ;
232+ run ( p , sine ( 440 , 0.3 ) ) ;
233+
234+ p . p = { ...p . p , semitones : 0 , mix : 1 } ;
235+ const input = sine ( 440 , 0.3 ) ;
236+ const output = run ( p , input ) ;
237+ let maxErr = 0 ;
238+ for ( let i = 0 ; i < input . length ; i ++ ) {
239+ maxErr = Math . max ( maxErr , Math . abs ( ( output [ i ] ?? 0 ) - ( input [ i ] ?? 0 ) ) ) ;
240+ }
241+ expect ( maxErr ) . toBeLessThan ( 1e-6 ) ;
242+ } ) ;
243+
244+ // The ring starts empty, so the taps read zeros for the first grain. That
245+ // used to come out of the head of every clip as silence; it ramps the wet
246+ // path in instead, which is unshifted audio rather than no audio.
247+ it ( "does not open with silence while the grain buffer fills" , async ( ) => {
248+ const HfPitchshift = ( await loadProcessors ( ) ) . get ( "hf-pitchshift" ) ;
249+ if ( ! HfPitchshift ) throw new Error ( "hf-pitchshift not registered" ) ;
250+ const p = new HfPitchshift ( { processorOptions : { semitones : 7 , mix : 1 } } ) ;
251+ const input = sine ( 440 , 0.5 ) ;
252+ const output = run ( p , input ) ;
253+ // Peak over the first 20 ms — well inside the old dead zone.
254+ let peak = 0 ;
255+ for ( let i = 0 ; i < Math . round ( SR * 0.02 ) ; i ++ )
256+ peak = Math . max ( peak , Math . abs ( output [ i ] ?? 0 ) ) ;
257+ expect ( peak ) . toBeGreaterThan ( 0.5 ) ;
258+ } ) ;
259+
158260 it ( "at semitones: 12, doubles the fundamental (one octave up)" , async ( ) => {
159261 const HfPitchshift = ( await loadProcessors ( ) ) . get ( "hf-pitchshift" ) ;
160262 if ( ! HfPitchshift ) throw new Error ( "hf-pitchshift not registered" ) ;
0 commit comments