smplr 0.23.0 → 0.24.0

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
@@ -25,7 +25,7 @@ const context = new AudioContext();
25
25
  const piano = SplendidGrandPiano(context);
26
26
  const drums = DrumMachine(context, { instrument: "TR-808" });
27
27
 
28
- const seq = new Sequencer(context, { bpm: 110, loop: true });
28
+ const seq = Sequencer(context, { bpm: 110, loop: true });
29
29
  seq.addTrack(piano, [
30
30
  { note: "C4", at: "1:1", duration: "4n" },
31
31
  { note: "E4", at: "1:2", duration: "4n" },
@@ -47,7 +47,7 @@ import { SplendidGrandPiano, Reverb, renderOffline } from "smplr";
47
47
 
48
48
  const wav = await renderOffline(async (context) => {
49
49
  const piano = await SplendidGrandPiano(context).load;
50
- piano.output.addEffect("reverb", new Reverb(context), 0.3);
50
+ piano.output.addEffect("reverb", Reverb(context), 0.3);
51
51
  ["C4", "E4", "G4", "C5"].forEach((note, i) => {
52
52
  piano.start({ note, time: i * 0.4, duration: 0.4 });
53
53
  });
@@ -111,7 +111,7 @@ The package needs to be served as a URL from a service like [unpkg](https://unpk
111
111
 
112
112
  ### Defining an instrument
113
113
 
114
- `smplr` ships ten instruments out of the box — `SplendidGrandPiano`, `Soundfont`, `DrumMachine`, `ElectricPiano`, `Mallet`, `Mellotron`, `Smolken`, `Versilian`, `Sampler`, `Soundfont2Sampler`. If none of them fit your use case, you can author your own with the `Instrument` builder and the `Smplr` interface.
114
+ `smplr` ships ten instruments out of the box — `SplendidGrandPiano`, `Soundfont`, `DrumMachine`, `ElectricPiano`, `Mallet`, `Mellotron`, `Smolken`, `Versilian`, `Sampler`, `Soundfont2`. If none of them fit your use case, you can author your own with the `Instrument` builder and the `Smplr` interface.
115
115
 
116
116
  See **[Defining an instrument](./AUTHORING.md)** for the full authoring guide — sync and async examples, third-party package layout, and how to use `Smplr` as a TypeScript type for generic helpers.
117
117
 
@@ -179,7 +179,7 @@ All instruments share some configuration options, passed as the second argument
179
179
  - `volumeToGain`: a function to map MIDI volume to a linear gain. Uses the MIDI standard curve by default.
180
180
  - `storage`: a [storage backend](#cache-requests) used to fetch sample buffers. `HttpStorage` by default.
181
181
  - `loader`: a shared `SampleLoader` instance. Pass the same loader to multiple instruments to cache buffers across them (see [Buffer reuse](#buffer-reuse)).
182
- - `scheduler`: a shared `Scheduler` instance. Construct your own to tune scheduling — for example, `new Scheduler(context, { lookaheadMs: 100, intervalMs: 25 })` — or omit to get a per-instrument default.
182
+ - `scheduler`: a shared `Scheduler` instance. Construct your own to tune scheduling — for example, `Scheduler(context, { lookaheadMs: 100, intervalMs: 25 })` — or omit to get a per-instrument default.
183
183
  - `onLoadProgress`: a function called after each sample buffer is decoded. Receives `{ loaded, total }` where `total` is the full count known before loading starts.
184
184
  - `onStart`: called when a note is dispatched to the audio engine. Receives the started note. See ⚠️ note under [Events](#events) on timing precision.
185
185
  - `onEnded`: called when each voice's audio node ends. Receives the started note.
@@ -302,6 +302,22 @@ piano.output.volume; // => 80
302
302
 
303
303
  ⚠️ `volume` is global to the instrument, but `velocity` is specific for each note.
304
304
 
305
+ #### Pan, detune, and reverse
306
+
307
+ Every instrument accepts a `pan` option at construction (`-1` = full left, `+1` = full right):
308
+
309
+ ```js
310
+ const drums = DrumMachine(context, { instrument: "TR-808", pan: -0.5 });
311
+ ```
312
+
313
+ Two universal setters mutate the playback defaults in place. They apply to notes scheduled **after** the call; in-flight notes are unaffected.
314
+
315
+ ```js
316
+ sampler.setDetune(100); // semitone up (100 cents) for all future notes
317
+ sampler.setReverse(true); // play samples reversed for all future notes
318
+ sampler.setReverse(false); // back to forward playback
319
+ ```
320
+
305
321
  #### MIDI CC
306
322
 
307
323
  Set and read MIDI Control Change values on the instrument:
@@ -368,7 +384,7 @@ Use `output.addEffect(name, effect, mix)` to connect an effect using a send bus:
368
384
 
369
385
  ```js
370
386
  import { Reverb, SplendidGrandPiano } from "smplr";
371
- const reverb = new Reverb(context);
387
+ const reverb = Reverb(context);
372
388
  const piano = SplendidGrandPiano(context, { volume });
373
389
  piano.output.addEffect("reverb", reverb, 0.2);
374
390
  ```
@@ -391,7 +407,7 @@ To cache samples in the browser, use a `CacheStorage` object:
391
407
  import { SplendidGrandPiano, CacheStorage } from "smplr";
392
408
 
393
409
  const context = new AudioContext();
394
- const storage = new CacheStorage();
410
+ const storage = CacheStorage();
395
411
  // First time the instrument loads, will fetch the samples from http. Subsequent times from cache.
396
412
  const piano = SplendidGrandPiano(context, { storage });
397
413
  ```
@@ -400,7 +416,7 @@ const piano = SplendidGrandPiano(context, { storage });
400
416
 
401
417
  ## Sequencer
402
418
 
403
- `Sequencer` schedules notes from one or more tracks against any smplr instrument with sample-accurate timing. Unlike instruments, it's a regular class always constructed with `new Sequencer(context, opts)`.
419
+ `Sequencer` schedules notes from one or more tracks against any smplr instrument with sample-accurate timing. Constructed as `Sequencer(context, opts)` (the `new Sequencer(...)` form also still works as a deprecated alias).
404
420
 
405
421
  ```js
406
422
  import { Sequencer, SplendidGrandPiano, DrumMachine } from "smplr";
@@ -409,7 +425,7 @@ const context = new AudioContext();
409
425
  const piano = SplendidGrandPiano(context);
410
426
  const drums = DrumMachine(context, { instrument: "TR-808" });
411
427
 
412
- const seq = new Sequencer(context, { bpm: 120, loop: true });
428
+ const seq = Sequencer(context, { bpm: 120, loop: true });
413
429
 
414
430
  seq.addTrack(piano, [
415
431
  { note: "C4", at: "1:1", duration: "4n" },
@@ -446,19 +462,55 @@ Note positions and durations accept several formats:
446
462
  #### Constructor options
447
463
 
448
464
  ```js
449
- const seq = new Sequencer(context, {
465
+ const seq = Sequencer(context, {
450
466
  bpm: 120, // default 120
451
467
  ppq: 480, // pulses per quarter note, default 480
452
- timeSignature: 4, // beats per bar, default 4
468
+ timeSignature: 4, // accepts `4` (→ 4/4) or `{ numerator, denominator }`
453
469
  loop: false, // default false
454
470
  loopStart: 0, // loop start position (ticks or string)
455
471
  loopEnd: "2:1", // loop end position; defaults to end of longest track
456
472
  lookaheadMs: 200, // scheduling lookahead, default 200
457
473
  intervalMs: 50, // flush interval, default 50
458
474
  humanize: { timingMs: 10, velocity: 8 }, // optional randomisation
475
+ stepSize: "16n", // optional: emit "step" events at this interval
459
476
  });
460
477
  ```
461
478
 
479
+ `timeSignature` accepts a plain number (interpreted as `{ numerator: n, denominator: 4 }`) or a full object such as `{ numerator: 7, denominator: 8 }` for 7/8 time. The `seq.timeSignature` getter always returns the `{ numerator, denominator }` form.
480
+
481
+ #### Tracks
482
+
483
+ ```js
484
+ seq.addTrack(piano, notes); // append a track
485
+ seq.addTrack(drums, notes, { id: "drums", volume: 0.8 }); // with options
486
+ seq.removeTrack(piano); // remove by instrument reference
487
+ seq.clearTracks(); // remove every track
488
+ ```
489
+
490
+ `addTrack`'s third argument accepts:
491
+
492
+ | Field | Type | Description |
493
+ | ---------- | ------------------------------------------- | --------------------------------------------------------------------- |
494
+ | `id` | `string` | Stable id for `setTrackVolume` / `muteTrack` / `soloTrack`. |
495
+ | `humanize` | `{ timingMs?: number; velocity?: number }` | Per-track humanize. Overrides the sequencer-level setting when set. |
496
+ | `volume` | `number` | Multiplicative velocity scalar (default 1). `0.5` halves velocities. |
497
+ | `muted` | `boolean` | When true, this track does not dispatch notes. |
498
+ | `solo` | `boolean` | When true, only soloed tracks play. |
499
+
500
+ After `setPatterns` is called (see [Pattern chain](#pattern-chain-song-mode)), `addTrack` / `removeTrack` / `clearTracks` throw — the chain is owned by the patterns array.
501
+
502
+ #### Track mixer
503
+
504
+ ```js
505
+ seq.setTrackVolume("drums", 0.6);
506
+ seq.muteTrack("drums");
507
+ seq.unmuteTrack("drums");
508
+ seq.soloTrack("lead");
509
+ seq.unsoloTrack("lead");
510
+ ```
511
+
512
+ Mixer methods operate on the **currently-playing pattern** (so per-pattern mute/solo state is automatic when using a pattern chain). Calls with an unknown id are no-ops.
513
+
462
514
  #### Playback
463
515
 
464
516
  ```js
@@ -481,12 +533,17 @@ seq.stopNote("intro-c", time); // stop at a scheduled time
481
533
 
482
534
  ```js
483
535
  seq.bpm = 140; // change BPM live, no glitch
484
- seq.timeSignature = 3; // change time signature
536
+ seq.timeSignature = 3; // 3/4 (number → { numerator: 3, denominator: 4 })
537
+ seq.timeSignature = { numerator: 7, denominator: 8 }; // 7/8
538
+
539
+ seq.timeSignature; // → { numerator: 7, denominator: 8 }
485
540
 
486
541
  seq.position; // current position as "bar:beat:tick" string
487
542
  seq.position = "3:1"; // seek while playing or stopped
488
543
  ```
489
544
 
545
+ The `"beat"` event fires once per denominator-defined note: 4/4 → 4 beats per bar, 6/8 → 6 beats per bar, etc.
546
+
490
547
  #### Loop
491
548
 
492
549
  ```js
@@ -531,12 +588,18 @@ seq.on("beat", (beat, time) => {
531
588
  seq.on("bar", (bar, time) => {
532
589
  ui.updateBar(bar);
533
590
  });
591
+ seq.on("step", (stepIndex, time) => {
592
+ ui.flashStep(stepIndex); // only fires when `stepSize` is set in options
593
+ });
534
594
  seq.on("loop", () => {
535
595
  console.log("looped");
536
596
  });
537
597
  seq.on("end", () => {
538
598
  console.log("done");
539
599
  });
600
+ seq.on("patternChange", (patternIndex, time) => {
601
+ ui.highlightPattern(patternIndex); // fires when the chain advances
602
+ });
540
603
  seq.on("start", () => {});
541
604
  seq.on("stop", () => {});
542
605
  seq.on("pause", () => {});
@@ -544,6 +607,9 @@ seq.on("pause", () => {});
544
607
  seq.off("beat", handler); // remove a listener
545
608
  ```
546
609
 
610
+ The `"step"` event only fires when the sequencer was constructed with `stepSize` (e.g. `"16n"`).
611
+ The `"patternChange"` event only fires when more than one pattern is in the chain.
612
+
547
613
  #### Note events
548
614
 
549
615
  `noteOn` and `noteOff` events fire when the instrument's `onStart` / `onEnded` callbacks are called, so they are driven by the actual audio playback — not by the scheduling lookahead.
@@ -581,7 +647,7 @@ seq.addTrack(piano, [
581
647
  Add subtle randomisation to timing and velocity for a more natural feel:
582
648
 
583
649
  ```js
584
- const seq = new Sequencer(context, {
650
+ const seq = Sequencer(context, {
585
651
  bpm: 90,
586
652
  humanize: { timingMs: 12, velocity: 8 },
587
653
  });
@@ -590,6 +656,60 @@ const seq = new Sequencer(context, {
590
656
  - `timingMs`: maximum random offset in milliseconds (±). Default 0.
591
657
  - `velocity`: maximum random offset in MIDI velocity units (±). Default 0.
592
658
 
659
+ Per-track humanize (passed to `addTrack`) overrides the global setting:
660
+
661
+ ```js
662
+ seq.addTrack(piano, notes, { humanize: { timingMs: 0, velocity: 0 } });
663
+ ```
664
+
665
+ #### SequencerNote fields
666
+
667
+ | Field | Type | Description |
668
+ | ---------------------- | ------------------- | ---------------------------------------------------------------------------- |
669
+ | `note` | `string \| number` | Note name or MIDI number. |
670
+ | `at` | `string \| number` | Musical position (ticks or `"bar:beat[.frac][:ticks]"` / `"4n"` / `"1m"`). |
671
+ | `duration` | `string \| number?` | Duration; omit for a one-shot trigger. |
672
+ | `velocity` | `number?` | Velocity 0–127. Default 100. |
673
+ | `id` | `string \| number?` | Used as `noteId` in `noteOn` / `noteOff` events. Default: array index. |
674
+ | `chance` | `number?` | Probability 0–100 that this note fires on each pass. Re-rolled on every loop. |
675
+ | `ratchet` | `number?` | Expand into N sub-notes over `duration` (requires `duration`). |
676
+ | `ratchetVelocityDecay` | `number?` | Per-step velocity decay; each sub-note scaled by `(1 - decay)^i`. |
677
+
678
+ Example:
679
+
680
+ ```js
681
+ seq.addTrack(drums, [
682
+ { note: "hat", at: "1:4", duration: "8n", ratchet: 4, ratchetVelocityDecay: 0.2 },
683
+ { note: "snare", at: "1:2", chance: 50 }, // fires 50% of the time
684
+ ]);
685
+ ```
686
+
687
+ When `ratchet > 1`, each sub-note's `noteId` is suffixed with `#0`, `#1`, etc., so you can stop an individual sub-voice via `seq.stopNote("id#0")`.
688
+
689
+ #### Pattern chain (song mode)
690
+
691
+ For multi-pattern arrangements (intro → verse → chorus), use `setPatterns`:
692
+
693
+ ```js
694
+ seq.setPatterns([
695
+ { tracks: [{ instrument: drums, notes: introNotes }], loopEnd: "1m" },
696
+ { tracks: [{ instrument: drums, notes: verseNotes }], loopEnd: "2m" },
697
+ { tracks: [{ instrument: drums, notes: chorusNotes }], loopEnd: "2m" },
698
+ ]);
699
+
700
+ seq.chainOrder = [0, 1, 2, 1, 2]; // intro, verse, chorus, verse, chorus
701
+ seq.loop = true; // loop the whole chain
702
+ seq.start();
703
+
704
+ seq.on("patternChange", (idx) => ui.highlightPattern(idx));
705
+ ```
706
+
707
+ - Each pattern's `tracks` entries accept the same `AddTrackOptions` (`id`, `humanize`, `volume`, `muted`, `solo`) as `addTrack`.
708
+ - `loopEnd` is per-pattern and defaults to the longest track in that pattern.
709
+ - `chainOrder` defaults to `[0, 1, …, n-1]`. Setting it lets you repeat or reorder patterns without duplicating data.
710
+ - With `loop: false` the chain plays once and emits `"end"`; with `loop: true` it cycles indefinitely and emits `"loop"` each time it wraps.
711
+ - Track mixer methods (`muteTrack`, `setTrackVolume`, etc.) operate on the currently-playing pattern — `muteTrack("lead")` only affects the pattern that owns the `"lead"` track.
712
+
593
713
  ---
594
714
 
595
715
  ## Export Audio
@@ -641,7 +761,7 @@ If you already have an instrument loaded, pass the same `SampleLoader` to avoid
641
761
  ```js
642
762
  import { SplendidGrandPiano, SampleLoader, renderOffline } from "smplr";
643
763
 
644
- const loader = new SampleLoader(audioContext);
764
+ const loader = SampleLoader(audioContext);
645
765
  const piano = SplendidGrandPiano(audioContext, { loader });
646
766
  await piano.load;
647
767
 
@@ -933,16 +1053,16 @@ const context = new AudioContext();
933
1053
  const versilian = Versilian(context, { instrument: instrumentNames[0] });
934
1054
  ```
935
1055
 
936
- ### Soundfont2Sampler
1056
+ ### Soundfont2
937
1057
 
938
- Sampler capable of reading .sf2 files directly:
1058
+ Sampler capable of reading .sf2 files directly. Previously named `Soundfont2Sampler`; the old name remains as a deprecated alias.
939
1059
 
940
1060
  ```ts
941
- import { Soundfont2Sampler } from "smplr";
1061
+ import { Soundfont2 } from "smplr";
942
1062
  import { SoundFont2 } from "soundfont2";
943
1063
 
944
1064
  const context = new AudioContext();
945
- const sampler = Soundfont2Sampler(context, {
1065
+ const sampler = Soundfont2(context, {
946
1066
  url: "https://smpldsnds.github.io/soundfonts/soundfonts/galaxy-electric-pianos.sf2",
947
1067
  createSoundfont: (data) => new SoundFont2(data),
948
1068
  });