@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 +3 -1
- package/docs/GUIDE.md +60 -5
- package/docs/MODULES.md +1 -1
- package/docs/modules.json +1 -1
- package/index.d.ts +24 -5
- package/index.js +31 -12
- package/index.js.map +2 -2
- package/package.json +1 -1
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) }); //
|
|
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) }); //
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
215
|
-
|
|
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):
|
|
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
|
|
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
|
|
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, (
|
|
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
|
-
|
|
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;
|