@jpdutoit/squelchy 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ import { preload, Vco, Lfo, Delay, semitones, sec, hz } from '@jpdutoit/squelchy
18
18
  const ctx = new AudioContext();
19
19
  await preload(ctx); // registers the worklets of the modules you import
20
20
 
21
- const vco = Vco.create(ctx, { offset: semitones(57) }); // unpatched, offset is the note: 57 = A3
21
+ const vco = Vco.create(ctx, { offset: semitones(57) }); // nothing in voct = note 0 (C-1); 57 semitones up = A3
22
22
  const lfo = Lfo.create(ctx, { frequency: hz(5), amount: semitones(0.3) });
23
23
  const delay = Delay.create(ctx, { time: sec(0.3), feedback: 0.4, mix: 0.35 });
24
24
 
@@ -32,6 +32,8 @@ delay.params.mix.linearRampToValueAtTime(0.8, ctx.currentTime + 2);
32
32
 
33
33
  Browsers only start audio after a user gesture, so create or `resume()` the `AudioContext` inside a click handler.
34
34
 
35
+ Importing the package is safe on the server (SSR, Node tests): nothing touches Web Audio until you call `preload()` or `create()`, so a static `import` in shared code is fine.
36
+
35
37
  ## Patterns and time
36
38
 
37
39
  Musical positions are plain numbers in beats, and patterns are plain arrays.
package/docs/GUIDE.md CHANGED
@@ -15,7 +15,7 @@ import { preload, Vco, Lfo, Vcf, semitones, hz, note } from '@jpdutoit/squelchy'
15
15
  const ctx = new AudioContext();
16
16
  await preload(ctx); // once per context, before any create()
17
17
 
18
- const vco = Vco.create(ctx, { offset: semitones(45) }); // unpatched, offset is the note: 45 = A2
18
+ const vco = Vco.create(ctx, { offset: semitones(45) }); // nothing in voct = note 0 (C-1); 45 semitones up = A2
19
19
  const lfo = Lfo.create(ctx, { frequency: hz(0.2), amount: semitones(18) });
20
20
  const filter = Vcf.create(ctx, { note: note('C6'), resonance: 4 });
21
21
 
@@ -27,6 +27,8 @@ filter.out.connect(ctx.destination);
27
27
  lfo.output('out').connect(filter.input('modFreq'));
28
28
  ```
29
29
 
30
+ **Pitch is V/OCT plus `offset`.** A VCO plays the note on its `voct` input plus `offset` semitones, and it has no hidden "note" setting: Web Audio inputs can only add, so nothing can switch off when a cable arrives. With `voct` empty, the input is note 0 (C-1), so `offset` counts up from there: `semitones(69)` plays A4. To write the note by name, measure from note 0: `interval(note(0), note('A4'))`. With a sequencer or keyboard in `voct`, set `offset` to 0 to play the notes as written, or ±12 for an octave.
31
+
30
32
  ### Changing parameters: `set()` vs `params`
31
33
 
32
34
  - **`params.<key>`** is a native `AudioParam` for every parameter that drives the DSP 1:1. Use it for anything timed: ramps, curves and `setValueAtTime`.
@@ -49,6 +51,21 @@ filter.params.resonance;
49
51
  filter.dispose(); // disconnect and release when you're done
50
52
  ```
51
53
 
54
+ **Ramping pitch and cutoff.** Pitch and cutoff parameters are notes or semitones (a VCO's `offset`, filter cutoffs), so they are already logarithmic: every 12 is an octave. A `linearRampToValueAtTime` between two values sweeps evenly in pitch, which is usually what you want. `exponentialRampToValueAtTime` is the Hz habit: on a note parameter it curves the sweep a second time, so it crawls through the low end and rushes the top.
55
+
56
+ ```ts
57
+ import { preload, Vcf, note } from '@jpdutoit/squelchy';
58
+
59
+ const ctx = new AudioContext();
60
+ await preload(ctx);
61
+ const filter = Vcf.create(ctx, { filterType: 'bandpass', note: note('C4') });
62
+ const t = ctx.currentTime;
63
+
64
+ // A riser: 5 octaves, evenly in pitch over 4 seconds.
65
+ filter.params.note.setValueAtTime(note('C4'), t);
66
+ filter.params.note.linearRampToValueAtTime(note('C9'), t + 4);
67
+ ```
68
+
52
69
  ## Units
53
70
 
54
71
  Notes, semitones, Hz, seconds and beats are distinct types, so passing seconds where a note is expected won't compile. At runtime they are plain numbers with zero overhead. Dimensionless values (mix, feedback, drive) are plain `number`.
@@ -103,7 +120,7 @@ playOn(asPitch(lfo.out)); // deliberate: you said so
103
120
 
104
121
  Musical positions are plain numbers in **beats** (quarter notes): bar 2 of a 4/4 song starts at beat 4, and a 16th note is 0.25. Audio times are `ctx.currentTime` seconds, as everywhere in Web Audio. Events are written ahead of time with exact start times, so timing doesn't depend on the main thread.
105
122
 
106
- Bars and beats count from 0, as on a timeline: `{ bar: 0 }` is the first bar and `{ bar: 1 }` starts at beat 4.
123
+ Bars and beats count from 0, as on a timeline: `{ bar: 0 }` is the first bar, `{ bar: 1 }` starts at beat 4, and `{ bar: 1, beat: beats(2) }` is the third beat of the second bar (beat 6).
107
124
 
108
125
  A **pattern** is a plain array, one element per step. A **voice** is anything with `trigger(time, hit)`, or a plain function `(time, hit) => void`.
109
126
 
@@ -122,7 +139,7 @@ transport.play(steps('..x. ..x. ..x. ..x.'), hat, {
122
139
  every: beats(0.25), // step length in beats (default: 16ths)
123
140
  swing: 0.3, // 0..1, delays every odd step
124
141
  gate: 0.5, // note length as a fraction of a step
125
- start: 'bar', // 'now' | 'beat' | 'bar' | { bar: n } | { beat: n }; default 'bar'
142
+ start: 'bar', // 'now' | 'beat' | 'bar' | { bar: n } | { bar: n, beat: n } | { beat: n }; default 'bar'
126
143
  loop: true, // true = forever, false = once, n = n times
127
144
  });
128
145
  transport.start();
@@ -214,6 +231,8 @@ transport.stop(); // stop everything
214
231
 
215
232
  `transport.at(position, fn)` calls `fn(time)` just ahead of that musical position, with its exact audio time, so you can schedule any `AudioParam` in musical time. It returns a cancel function.
216
233
 
234
+ `fn` runs a lookahead early, so it's the wrong place for UI. Pass `onAudible` for that: it fires when the position is actually heard, output latency included, just like `onHit`. Cancelling cancels both.
235
+
217
236
  ```ts
218
237
  import { createTransport, preload, Vcf, note, sec } from '@jpdutoit/squelchy';
219
238
 
@@ -225,11 +244,12 @@ filter.out.connect(ctx.destination);
225
244
 
226
245
  const bar = sec((transport.beatsPerBar * 60) / transport.bpm);
227
246
 
228
- // Open the filter over two bars, starting at bar 3.
247
+ // Open the filter over two bars, starting at bar 3; tell the UI when the sweep is heard.
248
+ const showDrop = () => { /* update the screen */ };
229
249
  const cancel = transport.at({ bar: 2 }, (t) => {
230
250
  filter.params.note.setValueAtTime(note('C4'), t);
231
251
  filter.params.note.linearRampToValueAtTime(note('C7'), t + 2 * bar);
232
- });
252
+ }, { onAudible: showDrop });
233
253
  transport.start();
234
254
  // cancel(); // if you change your mind before it fires
235
255
  ```
@@ -249,6 +269,7 @@ const highlight = (step: number) => { /* light up step `step` */ };
249
269
 
250
270
  transport.play(steps('x...x...'), kick, { onHit: (hit) => highlight(hit.step) }); // one playback
251
271
  const unsubscribe = transport.onHit((hit) => console.log(hit.beat, hit.value)); // every playback
272
+ transport.at({ bar: 8 }, () => {}, { onAudible: () => highlight(-1) }); // a moment, not a hit
252
273
  transport.start();
253
274
  ```
254
275
 
@@ -279,6 +300,21 @@ transport.play(notes('C2 C2 . C3 . C2 Eb2 . G2 . C2 . Bb1 . C2 .'), cv, { gate:
279
300
  transport.start();
280
301
  ```
281
302
 
303
+ To change the notes on their way to the voice, wrap it in a function. `transpose()` keeps the result typed as a note:
304
+
305
+ ```ts
306
+ import { createTransport, notes, transpose, semitones, NoteCv, type Hit, type Note, type Seconds } from '@jpdutoit/squelchy';
307
+
308
+ const ctx = new AudioContext();
309
+ const transport = createTransport(ctx);
310
+ const cv = NoteCv.create(ctx);
311
+
312
+ let key = semitones(0);
313
+ const inKey = (time: Seconds, hit: Hit<Note>) => cv.trigger(time, { ...hit, value: transpose(hit.value, key) });
314
+ transport.play(notes('C2 . Eb2 G2'), inKey);
315
+ key = semitones(5); // later hits play a fourth up
316
+ ```
317
+
282
318
  ## One-shot actions with `pulse()`
283
319
 
284
320
  Actions like a tape stop or kicking the spring tank are **gate inputs**. They react to a rising edge, like a trigger cable on hardware. `pulse(input, time)` fires one at an exact time. It works the same whether it's called from a button, a pattern or `transport.at`, and it sums with anything else patched into that input.
@@ -354,6 +390,25 @@ transport.start();
354
390
 
355
391
  A sample voice is one-shot, like a drum machine: every hit plays the whole sample, whatever the step's gate length. Its step value is a note, so the same `notes()` melody plays on a sampler and on a synth voice.
356
392
 
393
+ **Swapping kits while playing.** `voice.setKit(kit)` plays later hits from another kit. Patterns, connections and sidechains stay as they are, and samples already ringing (or scheduled within the lookahead) finish on the old kit, so nothing gets cut off.
394
+
395
+ ```ts
396
+ import { createTransport, steps, loadSampleSet, SampleVoice } from '@jpdutoit/squelchy';
397
+ import { Tr909 } from '@jpdutoit/squelchy/kits/tr-909';
398
+ import { Roland808 } from '@jpdutoit/squelchy/kits/roland-808';
399
+
400
+ const ctx = new AudioContext();
401
+ const transport = createTransport(ctx);
402
+ const bd = SampleVoice.create(ctx, await loadSampleSet(ctx, Tr909), 'bd');
403
+ bd.connect(ctx.destination);
404
+ transport.play(steps('x...x...x...x...'), bd);
405
+ transport.start();
406
+
407
+ const tr808 = await loadSampleSet(ctx, Roland808);
408
+ bd.setKit(tr808); // hits scheduled from now on (about a lookahead ahead) play the 808
409
+ console.log(bd.kit.set.name);
410
+ ```
411
+
357
412
  **How the audio gets into your build.** A kit's voice URLs are written as `new URL('./tr-909/bd.flac', import.meta.url)`. Vite (8.3 or newer, in dev and build), webpack 5 and Parcel all recognise that pattern: they copy each file of the kits you import into your output with a content hash and rewrite the URL. Loading the package unbundled, e.g. from a CDN like jsDelivr, resolves the files next to the kit module.
358
413
 
359
414
  **Your own kit.** `loadSampleSet` takes any `SampleSetInfo`, so you can point voices at your own files:
package/docs/MODULES.md CHANGED
@@ -1190,7 +1190,7 @@ Categories: Sources · Worklets: map-voct-to-freq, waveshaper
1190
1190
  |---|---|---|---|
1191
1191
  | `skew` | `1` | -1 – 1 | **Waveshape Skew** — Symmetry of the waveform: −1 reversed sawtooth, 0 symmetric, +1 sawtooth. Automatable: `params.skew` is a native AudioParam. |
1192
1192
  | `shape` | `0` | -1 – 1 | **Waveshape Curvature** — Curvature of the waveform: −1 square, 0 triangle, +1 sine. Automatable: `params.shape` is a native AudioParam. |
1193
- | `offset` | `69` | -48 – 108 semitones | **Pitch Offset** — Semitones added to the V/OCT input (12 = one octave). Unpatched the input is silent, so this is the note itself: 69 = A4. Driven by an absolute pitch CV (keyboard, sequencer), set 0 to play as-is or ±12 to shift an octave. Automatable: `params.offset` is a native AudioParam. |
1193
+ | `offset` | `69` | -48 – 108 semitones | **Pitch Offset** — Semitones added to the V/OCT input (12 = one octave). Unpatched, the input is silent (note 0, C−1), so the offset counts up from there: 69 semitones above C−1 = A4. Driven by an absolute pitch CV (keyboard, sequencer), set 0 to play as-is or ±12 to shift an octave. Automatable: `params.offset` is a native AudioParam. |
1194
1194
  | `detune` | `0` | -1 – 1 semitones | **Detune** — Fine pitch offset in semitones; ±1 = ±100 cents. Use for detuning oscillators against each other. Automatable: `params.detune` is a native AudioParam. |
1195
1195
  | `gain` | `0.5` | 0 – 1 | **Output Level** — Amplitude of the oscillator output signal. Automatable: `params.gain` is a native AudioParam. |
1196
1196
 
package/docs/modules.json CHANGED
@@ -3164,7 +3164,7 @@
3164
3164
  },
3165
3165
  "offset": {
3166
3166
  "name": "Pitch Offset",
3167
- "description": "Semitones added to the V/OCT input (12 = one octave). Unpatched the input is silent, so this is the note itself: 69 = A4. Driven by an absolute pitch CV (keyboard, sequencer), set 0 to play as-is or ±12 to shift an octave.",
3167
+ "description": "Semitones added to the V/OCT input (12 = one octave). Unpatched, the input is silent (note 0, C−1), so the offset counts up from there: 69 semitones above C−1 = A4. Driven by an absolute pitch CV (keyboard, sequencer), set 0 to play as-is or ±12 to shift an octave.",
3168
3168
  "type": "Semitones",
3169
3169
  "unit": "semitone",
3170
3170
  "default": 69,
package/index.d.ts CHANGED
@@ -125,12 +125,20 @@ export type Signal<Q> = AudioNode & {
125
125
  /** Reinterpret a node as a pitch signal — the greppable escape hatch for
126
126
  * deliberate cross-quantity patches. */
127
127
  export declare const asPitch: (n: AudioNode) => Signal<Note>;
128
- /** Where a playback starts. Default 'bar' = next bar line (beat 0 if stopped). */
128
+ /** A musical position. 'now' | 'beat' | 'bar' are the next of each from the
129
+ * current position (beat 0 if stopped). `{ bar: 3, beat: 2 }` is beat 2 of
130
+ * bar 3 (both count from 0); `{ beat: n }` is an absolute beat. */
129
131
  export type StartAt = "now" | "beat" | "bar" | {
130
132
  bar: number;
133
+ beat?: Beats;
131
134
  } | {
132
135
  beat: Beats;
133
136
  };
137
+ interface AtOptions {
138
+ /** Fires when the position is actually heard (output latency included),
139
+ * like `onHit`. Use it for UI; `fn` runs a lookahead early, for audio. */
140
+ onAudible?: (time: Seconds) => void;
141
+ }
134
142
  /** Rich step form, for when a plain value isn't enough. */
135
143
  export interface StepObject<V> {
136
144
  value: V;
@@ -211,8 +219,10 @@ export interface Transport {
211
219
  resolve(at: StartAt): Beats;
212
220
  /** Play a pattern array through a voice. */
213
221
  play<V>(pattern: readonly Step<V>[], voice: Voice<V>, opts?: PlayOptions<V>): Playback<V>;
214
- /** Run `fn(time)` once at a musical position — for automation, e.g. `vca.set('gain', 0, time)`. */
215
- at(when: StartAt | Beats, fn: (time: Seconds) => void): () => void;
222
+ /** Run `fn(time)` once, a lookahead before a musical position, with its exact
223
+ * audio time — for automation. `onAudible` fires when that moment is heard.
224
+ * Returns a cancel function (cancels both). */
225
+ at(when: StartAt | Beats, fn: (time: Seconds) => void, opts?: AtOptions): () => void;
216
226
  /** Global visual tap: every hit of every playback, fired when audible. Returns unsubscribe. */
217
227
  onHit(cb: (hit: Hit) => void): () => void;
218
228
  /** Schedule everything due inside the lookahead window. Auto-called in 'auto' mode. */
@@ -322,13 +332,22 @@ export interface SampleVoiceOptions {
322
332
  /** The note that plays the sample unshifted. Default 60 (C4). */
323
333
  root?: Note;
324
334
  }
335
+ /** A sample voice: a VoiceInstance whose kit can be swapped while playing. */
336
+ export interface SampleVoiceInstance extends VoiceInstance<Note | boolean> {
337
+ /** The kit hits are played from. */
338
+ readonly kit: LoadedSampleSet;
339
+ /** Play later hits from another kit (same voice key, same fallback chain).
340
+ * Hits already scheduled (up to the transport's lookahead) and samples
341
+ * still ringing keep the old kit, so the swap never cuts a tail. */
342
+ setKit(kit: LoadedSampleSet): void;
343
+ }
325
344
  /** A one-shot sample voice, like a drum machine: every hit plays the whole
326
345
  * sample, whatever the step's gate length. velocity 0..1 scales level. A
327
346
  * `Note` value plays the sample at that note (`root` = unshifted), the same
328
347
  * numbers `NoteCv` plays, so `notes()` melodies work on both; anything else
329
348
  * (e.g. `true` from steps()) plays it unshifted. */
330
349
  export declare const SampleVoice: {
331
- create(ctx: BaseAudioContext, kit: LoadedSampleSet, key: VoiceKey, opts?: SampleVoiceOptions): VoiceInstance<Note | boolean>;
350
+ create(ctx: BaseAudioContext, kit: LoadedSampleSet, key: VoiceKey, opts?: SampleVoiceOptions): SampleVoiceInstance;
332
351
  };
333
352
  export interface AcidFilterParams {
334
353
  /** Cutoff Note. Base cutoff as a note (67 ≈ 400 Hz, 69 = 440 Hz) for the iconic squelchy acid sound. Drives the V/OCT worklet 1:1 — the panel knob shows the equivalent Hz. Range 15–135 notes. Default 67. */
@@ -1647,7 +1666,7 @@ export interface VcoParams {
1647
1666
  skew?: number;
1648
1667
  /** Waveshape Curvature. Curvature of the waveform: −1 square, 0 triangle, +1 sine. Range -1–1. Default 0. */
1649
1668
  shape?: number;
1650
- /** Pitch Offset. Semitones added to the V/OCT input (12 = one octave). Unpatched the input is silent, so this is the note itself: 69 = A4. Driven by an absolute pitch CV (keyboard, sequencer), set 0 to play as-is or ±12 to shift an octave. Range -48–108 semitones. Default 69. */
1669
+ /** Pitch Offset. Semitones added to the V/OCT input (12 = one octave). Unpatched, the input is silent (note 0, C−1), so the offset counts up from there: 69 semitones above C−1 = A4. Driven by an absolute pitch CV (keyboard, sequencer), set 0 to play as-is or ±12 to shift an octave. Range -48–108 semitones. Default 69. */
1651
1670
  offset?: Semitones;
1652
1671
  /** Detune. Fine pitch offset in semitones; ±1 = ±100 cents. Use for detuning oscillators against each other. Range -1–1 semitones. Default 0. */
1653
1672
  detune?: Semitones;
package/index.js CHANGED
@@ -180,26 +180,29 @@ function createTransport(ctx, opts = {}) {
180
180
  if (at === "now") return now2;
181
181
  if (at === "beat") return Math.ceil(now2 - EPS);
182
182
  if (at === "bar") return Math.ceil(now2 / beatsPerBar - EPS) * beatsPerBar;
183
- if ("bar" in at) return at.bar * beatsPerBar;
183
+ if ("bar" in at) return at.bar * beatsPerBar + (at.beat ?? 0);
184
184
  return at.beat;
185
185
  }
186
186
  const latency = () => ctx.outputLatency || ctx.baseLatency || 0;
187
- function emitTap(hit, local) {
188
- if (!local && taps.size === 0) return;
189
- const fire = () => {
190
- local?.(hit);
191
- for (const cb of taps) cb(hit);
192
- };
187
+ function whenAudible(time, fire) {
193
188
  if (offline) {
194
189
  fire();
195
- return;
190
+ return void 0;
196
191
  }
197
- const delay = Math.max(0, (hit.time - ctx.currentTime + latency()) * 1e3);
192
+ const delay = Math.max(0, (time - ctx.currentTime + latency()) * 1e3);
198
193
  const id = setTimeout(() => {
199
194
  tapTimers.delete(id);
200
195
  fire();
201
196
  }, delay);
202
197
  tapTimers.add(id);
198
+ return id;
199
+ }
200
+ function emitTap(hit, local) {
201
+ if (!local && taps.size === 0) return;
202
+ whenAudible(hit.time, () => {
203
+ local?.(hit);
204
+ for (const cb of taps) cb(hit);
205
+ });
203
206
  }
204
207
  function horizonBeat() {
205
208
  if (offline) return beatAt(ctx.length / ctx.sampleRate);
@@ -320,16 +323,20 @@ function createTransport(ctx, opts = {}) {
320
323
  }
321
324
  };
322
325
  },
323
- at(when, fn) {
326
+ at(when, fn, o = {}) {
324
327
  const b = typeof when === "number" ? when : resolve(when);
325
328
  let fired = false;
329
+ let audibleTimer;
326
330
  const job = {
327
331
  nextBeat: () => b,
328
332
  run(until) {
329
333
  if (fired) return false;
330
334
  if (b < until) {
331
335
  fired = true;
332
- fn(timeAt(b));
336
+ const time = timeAt(b);
337
+ fn(time);
338
+ const onAudible = o.onAudible;
339
+ if (onAudible) audibleTimer = whenAudible(time, () => onAudible(time));
333
340
  return false;
334
341
  }
335
342
  return true;
@@ -340,6 +347,10 @@ function createTransport(ctx, opts = {}) {
340
347
  return () => {
341
348
  fired = true;
342
349
  jobs.delete(job);
350
+ if (audibleTimer !== void 0) {
351
+ clearTimeout(audibleTimer);
352
+ tapTimers.delete(audibleTimer);
353
+ }
343
354
  };
344
355
  },
345
356
  onHit(cb) {
@@ -604,8 +615,9 @@ async function loadSampleSet(ctx, set, opts = {}) {
604
615
  var SampleVoice = {
605
616
  create(ctx, kit, key, opts = {}) {
606
617
  const out = ctx.createGain();
607
- const buf = kit.buffer(key);
608
618
  const root = opts.root ?? 60;
619
+ let current = kit;
620
+ let buf = kit.buffer(key);
609
621
  const trigger = (when, h) => {
610
622
  if (!buf) return;
611
623
  const src = ctx.createBufferSource();
@@ -619,6 +631,13 @@ var SampleVoice = {
619
631
  return {
620
632
  output: out,
621
633
  trigger,
634
+ get kit() {
635
+ return current;
636
+ },
637
+ setKit(next) {
638
+ current = next;
639
+ buf = next.buffer(key);
640
+ },
622
641
  connect: (d) => {
623
642
  out.connect(d);
624
643
  return d;