@hraness/dawg 0.6.0 → 0.6.1

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/DAWG.md +338 -51
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +5 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +258 -6
  8. package/core/granular.ts +91 -0
  9. package/core/instruments.ts +161 -0
  10. package/core/keys.ts +573 -4
  11. package/core/resonators.ts +291 -3
  12. package/core/score.ts +349 -3
  13. package/core/sdk/print.ts +78 -3
  14. package/core/sdk/v1.ts +1320 -36
  15. package/core/sections.ts +12 -4
  16. package/core/strings.ts +237 -2
  17. package/core/winds.ts +652 -0
  18. package/guides/automation.md +1 -0
  19. package/guides/chords.md +3 -1
  20. package/guides/effects.md +3 -2
  21. package/guides/media.md +1 -1
  22. package/guides/performance.md +3 -0
  23. package/guides/resample.md +26 -0
  24. package/guides/sounds.md +7 -6
  25. package/package.json +1 -1
  26. package/src/agent/agent.ts +8 -2
  27. package/src/agent/chord-tools.ts +153 -1
  28. package/src/agent/expression-tools.ts +91 -0
  29. package/src/agent/ops.ts +4 -1
  30. package/src/agent/preview-tool.ts +2 -1
  31. package/src/agent/resample-tool.ts +131 -0
  32. package/src/agent/tools.ts +200 -15
  33. package/src/agent/xcb-agent.ts +8 -2
  34. package/src/audio/arrange.ts +21 -0
  35. package/src/audio/dsp/envelope.ts +161 -0
  36. package/src/audio/dsp/shift.ts +256 -0
  37. package/src/audio/effects/chain.ts +18 -3
  38. package/src/audio/effects/common.ts +14 -0
  39. package/src/audio/effects/convolution.ts +106 -1
  40. package/src/audio/effects/gaze.ts +354 -0
  41. package/src/audio/engine.ts +46 -5
  42. package/src/audio/fit.ts +45 -0
  43. package/src/audio/granular.ts +352 -21
  44. package/src/audio/instrument-check.ts +9 -5
  45. package/src/audio/instruments.ts +32 -3
  46. package/src/audio/keys/electric.ts +420 -0
  47. package/src/audio/keys/engine.ts +143 -19
  48. package/src/audio/keys/organ.ts +1335 -0
  49. package/src/audio/keys/piano.ts +50 -6
  50. package/src/audio/keys/sympathetic.ts +127 -0
  51. package/src/audio/live-worker.ts +3 -1
  52. package/src/audio/live.ts +136 -9
  53. package/src/audio/preview.ts +15 -1
  54. package/src/audio/resample.ts +285 -0
  55. package/src/audio/resonators.ts +10 -2
  56. package/src/audio/sampler.ts +168 -21
  57. package/src/audio/samples.ts +26 -1
  58. package/src/audio/strings/body.ts +29 -16
  59. package/src/audio/strings/bow.ts +656 -0
  60. package/src/audio/strings/engine.ts +266 -20
  61. package/src/audio/strings/measure.test-helpers.ts +2 -0
  62. package/src/audio/wav.ts +119 -19
  63. package/src/audio/winds/engine.ts +302 -0
  64. package/src/audio/winds/filters.ts +153 -0
  65. package/src/audio/winds/pitch.ts +104 -0
  66. package/src/audio/winds/trim.ts +80 -0
  67. package/src/audio/winds/trims.ts +917 -0
  68. package/src/audio/winds/voice.ts +479 -0
  69. package/src/commands/expression.ts +103 -22
  70. package/src/commands/fx.ts +4 -1
  71. package/src/commands/granular.ts +7 -2
  72. package/src/commands/help.ts +91 -8
  73. package/src/commands/keys.ts +287 -11
  74. package/src/commands/modal.ts +67 -4
  75. package/src/commands/resample.ts +281 -0
  76. package/src/commands/rig.ts +5 -1
  77. package/src/commands/sample.ts +49 -2
  78. package/src/commands/shift.ts +119 -0
  79. package/src/commands/string.ts +28 -2
  80. package/src/commands/strum.ts +473 -0
  81. package/src/commands/wind.ts +244 -0
  82. package/src/main.ts +129 -7
  83. package/src/project/check.ts +5 -1
  84. package/src/render.ts +16 -0
  85. package/src/session/presence.ts +16 -3
  86. package/src/tui/audition.ts +1 -1
  87. package/src/tui/menu.ts +637 -35
  88. package/src/tui/modal-menu.ts +34 -7
  89. package/src/tui/performance-menu.ts +42 -0
  90. package/src/tui/play-chords.ts +104 -3
  91. package/src/tui/play-mode.ts +1 -0
  92. package/src/tui/play-session.ts +34 -9
  93. package/src/tui/wind-menu.ts +144 -0
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Resample (0.6.1): render one track, one orbit or the whole mix, over the
3
+ * song, a section or a bar range, to a 16-bit WAV a new sampler or granular
4
+ * track plays. The render is the same offline path `dawg render` writes
5
+ * (`renderArrangedPcm`), with the source soloed and, unless `master` is
6
+ * asked for, the song master left out (a pre-master stem), so the same score
7
+ * always gives the same bytes and the same sha256.
8
+ *
9
+ * Level: the sampler plays a resampled stereo file (a ref with `from`) as
10
+ * stereo, each channel scaled by SAMPLE_LEVEL and the centre pan's
11
+ * equal-power gain, so the file is written RESAMPLE_FILE_SCALE louder than
12
+ * the stem (about +0.09 dB) for the new track, at voice gain RESAMPLE_GAIN
13
+ * and pan 0, to match the source in both channels (panned sources and
14
+ * stereo effects keep their image). A granular track reads the mono
15
+ * mixdown. Peaks past full scale clamp.
16
+ *
17
+ * A section or bar range renders from PREROLL_BARS before it (or from the
18
+ * earliest source note still sounding at its start) and drops those
19
+ * frames, so held notes and effect tails cross into the range as in the
20
+ * song.
21
+ */
22
+ import { createHash } from "node:crypto";
23
+ import {
24
+ SCORE_LIMITS,
25
+ TrackScore,
26
+ ScoreValidationError,
27
+ type SampleProvenance,
28
+ type Track,
29
+ } from "../../core/score.ts";
30
+ import { barTicks, findSection, sectionScore } from "../../core/sections.ts";
31
+ import { secondsAtTick } from "../../core/tempo.ts";
32
+ import { renderArrangedPcm } from "./arrange.ts";
33
+ import { orbitOf } from "./effects/duck.ts";
34
+ import { exportSampleRate } from "./measure.ts";
35
+ import { SAMPLE_LEVEL } from "./sampler.ts";
36
+ import type { SampleBank } from "./samples.ts";
37
+ import { RENDER_CHANNELS, encodeWav } from "./wav.ts";
38
+
39
+ /** Bars rendered before a section or bar range for effect tails. */
40
+ export const PREROLL_BARS = 2;
41
+
42
+ /** Voice gain the new sampler track plays the file at. */
43
+ export const RESAMPLE_GAIN = 2;
44
+ /** File level over the rendered stem so the voice reproduces it. */
45
+ export const RESAMPLE_FILE_SCALE =
46
+ Math.SQRT1_2 / (SAMPLE_LEVEL * RESAMPLE_GAIN * 0.5);
47
+
48
+ export type ResampleSource =
49
+ | Readonly<{ kind: "track"; trackId: string }>
50
+ | Readonly<{ kind: "orbit"; orbit: number }>
51
+ | Readonly<{ kind: "master" }>;
52
+
53
+ export type ResampleRange =
54
+ | Readonly<{ kind: "song" }>
55
+ | Readonly<{ kind: "section"; name: string }>
56
+ | Readonly<{ kind: "bars"; from: number; to: number }>;
57
+
58
+ export type ResampleRequest = Readonly<{
59
+ source: ResampleSource;
60
+ range: ResampleRange;
61
+ /** Keep the song master on the render (always on for `master`). */
62
+ post?: boolean;
63
+ }>;
64
+
65
+ export type ResampleRender = Readonly<{
66
+ wav: Uint8Array;
67
+ sha256: string;
68
+ sampleRate: number;
69
+ seconds: number;
70
+ /** Score tick the rendered range starts at (where the new note goes). */
71
+ startTick: number;
72
+ /** Ticks the range covers (the new note's length). */
73
+ durationTicks: number;
74
+ from: SampleProvenance;
75
+ }>;
76
+
77
+ /** `track:<id>`, `orbit:<n>` or `master`: the provenance source name. */
78
+ export function resampleSourceName(source: ResampleSource): string {
79
+ if (source.kind === "track") return `track:${source.trackId}`;
80
+ if (source.kind === "orbit") return `orbit:${source.orbit}`;
81
+ return "master";
82
+ }
83
+
84
+ /** sha256 of the score's canonical JSON (the provenance pin). */
85
+ export function scoreSha256(score: TrackScore): string {
86
+ return createHash("sha256")
87
+ .update(JSON.stringify(score.toJSON()))
88
+ .digest("hex");
89
+ }
90
+
91
+ /** The tracks a source plays (throws for an unknown track or empty orbit). */
92
+ export function resampleTracks(
93
+ score: TrackScore,
94
+ source: ResampleSource,
95
+ ): readonly Track[] {
96
+ if (source.kind === "master") return score.tracks;
97
+ const tracks =
98
+ source.kind === "track"
99
+ ? score.tracks.filter((track) => track.id === source.trackId)
100
+ : score.tracks.filter((track) => orbitOf(track) === source.orbit);
101
+ if (tracks.length === 0)
102
+ throw new ScoreValidationError(
103
+ source.kind === "track"
104
+ ? `no track ${source.trackId}`
105
+ : `no track plays on orbit ${source.orbit}`,
106
+ "invalid-track",
107
+ );
108
+ return tracks;
109
+ }
110
+
111
+ /**
112
+ * The score the render plays: the source soloed (every other track muted),
113
+ * the master removed unless `post`, cut to the range. Returns where the
114
+ * range sits in the song.
115
+ */
116
+ export function resampleScore(
117
+ score: TrackScore,
118
+ request: ResampleRequest,
119
+ ): Readonly<{
120
+ score: TrackScore;
121
+ startTick: number;
122
+ durationTicks: number;
123
+ /** Ticks of lead-in the render plays before the range (sliced off). */
124
+ leadTicks: number;
125
+ }> {
126
+ const sources = new Set(
127
+ resampleTracks(score, request.source).map((track) => track.id),
128
+ );
129
+ const post = request.post === true || request.source.kind === "master";
130
+ const json = { ...score.toJSON() } as Record<string, unknown>;
131
+ json.tracks = score.tracks.map((track) => {
132
+ const copy = { ...track } as Record<string, unknown>;
133
+ delete copy.solo;
134
+ // Solo, not mute, the others: a granular `quant chord` source still
135
+ // hears the song's harmony (muted tracks leave the chord).
136
+ if (request.source.kind !== "master" && sources.has(track.id)) {
137
+ delete copy.muted;
138
+ copy.solo = true;
139
+ }
140
+ return copy;
141
+ });
142
+ if (!post) delete json.master;
143
+ let played = new TrackScore(json as never);
144
+ const ticks = barTicks(played);
145
+ const range = request.range;
146
+ if (range.kind === "song")
147
+ return {
148
+ score: played,
149
+ startTick: 0,
150
+ durationTicks: played.bars * ticks,
151
+ leadTicks: 0,
152
+ };
153
+ let section;
154
+ if (range.kind === "section") {
155
+ section = findSection(played, range.name);
156
+ if (!section)
157
+ throw new ScoreValidationError(
158
+ `no section named ${range.name}${played.sections.length > 0 ? ` (sections: ${played.sections.map((s) => s.name).join(", ")})` : ""}`,
159
+ "invalid-track",
160
+ );
161
+ } else {
162
+ if (
163
+ !Number.isInteger(range.from) ||
164
+ !Number.isInteger(range.to) ||
165
+ range.from < 1 ||
166
+ range.to < range.from ||
167
+ range.to > played.bars
168
+ )
169
+ throw new ScoreValidationError(
170
+ `bars must be a-b inside 1-${played.bars}`,
171
+ "invalid-track",
172
+ );
173
+ section = {
174
+ name: "resample",
175
+ startBar: range.from - 1,
176
+ bars: range.to - range.from + 1,
177
+ };
178
+ }
179
+ // Lead-in: notes still sounding at the range start, and the effect tails
180
+ // of the bars before it, play from where they began (PREROLL_BARS back at
181
+ // least); the render then drops the lead-in frames.
182
+ const from = section.startBar * ticks;
183
+ let leadBar = Math.max(0, section.startBar - PREROLL_BARS);
184
+ for (const note of played.notes)
185
+ if (
186
+ sources.has(note.trackId) &&
187
+ note.startTick < from &&
188
+ note.startTick + note.durationTicks > from
189
+ )
190
+ leadBar = Math.min(leadBar, Math.floor(note.startTick / ticks));
191
+ const lead = section.startBar - leadBar;
192
+ played = sectionScore(played, {
193
+ ...section,
194
+ startBar: leadBar,
195
+ bars: section.bars + lead,
196
+ });
197
+ return {
198
+ score: played,
199
+ startTick: from,
200
+ durationTicks: section.bars * ticks,
201
+ leadTicks: lead * ticks,
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Render a resample to WAV bytes. `samples` is the source score's loaded
207
+ * bank (needed when the source plays samples).
208
+ */
209
+ export function renderResample(
210
+ score: TrackScore,
211
+ request: ResampleRequest,
212
+ samples?: SampleBank,
213
+ ): ResampleRender {
214
+ const {
215
+ score: played,
216
+ startTick,
217
+ durationTicks,
218
+ leadTicks,
219
+ } = resampleScore(score, request);
220
+ // The song's export rate (48 kHz once it has a master), so the new
221
+ // track nulls against the source when both play at that rate.
222
+ const sampleRate = exportSampleRate(score);
223
+ const rendered = renderArrangedPcm(played, {
224
+ sampleRate,
225
+ loop: false,
226
+ ...(samples ? { samples } : {}),
227
+ });
228
+ const skip = Math.min(
229
+ rendered.frames,
230
+ Math.round(secondsAtTick(played, leadTicks) * rendered.sampleRate),
231
+ );
232
+ const audio =
233
+ skip === 0
234
+ ? rendered
235
+ : {
236
+ ...rendered,
237
+ frames: rendered.frames - skip,
238
+ pcm: rendered.pcm.subarray(skip * RENDER_CHANNELS),
239
+ };
240
+ const full = new Int16Array(audio.pcm.length);
241
+ for (let index = 0; index < full.length; index += 1)
242
+ full[index] = Math.max(
243
+ -32_768,
244
+ Math.min(32_767, Math.round(audio.pcm[index]! * RESAMPLE_FILE_SCALE)),
245
+ );
246
+ // Trim the tail that rounds to digital silence (within 1 LSB): the new
247
+ // track's note length comes from the range, not from the file.
248
+ let end = full.length;
249
+ while (end > 0 && Math.abs(full[end - 1]!) <= 1) end -= 1;
250
+ if (end === 0)
251
+ throw new ScoreValidationError(
252
+ `${resampleSourceName(request.source)} is silent here · nothing to resample (add notes, unmute it or pick other bars)`,
253
+ );
254
+ // Keep 20 ms of silence so the sampler's end fade lands on nothing.
255
+ const frames = Math.min(
256
+ audio.frames,
257
+ Math.ceil(end / RENDER_CHANNELS) + Math.round(0.02 * audio.sampleRate),
258
+ );
259
+ const pcm = full.subarray(0, frames * RENDER_CHANNELS);
260
+ const seconds = frames / audio.sampleRate;
261
+ if (seconds > SCORE_LIMITS.maxResampleSeconds)
262
+ throw new ScoreValidationError(
263
+ `the resample is ${Math.round(seconds)} s; the limit is ${SCORE_LIMITS.maxResampleSeconds} s · pick a section or bars`,
264
+ "score-limit",
265
+ );
266
+ const wav = encodeWav(pcm, audio.sampleRate, RENDER_CHANNELS);
267
+ const range = request.range;
268
+ const from: SampleProvenance = {
269
+ source: resampleSourceName(request.source),
270
+ ...(range.kind === "section"
271
+ ? { section: findSection(score, range.name)?.name ?? range.name }
272
+ : {}),
273
+ ...(range.kind === "bars" ? { bars: [range.from, range.to] as const } : {}),
274
+ score: scoreSha256(score),
275
+ };
276
+ return {
277
+ wav,
278
+ sha256: createHash("sha256").update(wav).digest("hex"),
279
+ sampleRate: audio.sampleRate,
280
+ seconds,
281
+ startTick,
282
+ durationTicks,
283
+ from,
284
+ };
285
+ }
@@ -158,6 +158,11 @@ export function renderResonatorTrack(
158
158
  : 1);
159
159
  const seedTick = context.seedTick ?? 0;
160
160
  const base = modalSettings(track.modal);
161
+ // An ombak pair (f061-gamelan-winds): the pengisep names its partner in
162
+ // `pair` and sounds `ombak` Hz above the same note, single voice; the
163
+ // partner (pengumbang) carries ombak 0. Each track renders from its own
164
+ // field, so stems key per track.
165
+ const paired = track.modal?.pair !== undefined;
161
166
  const banks: ModalBank[] = [];
162
167
  // Conservative end of each planned voice (its ring cap, or a steal).
163
168
  const ends: number[] = [];
@@ -186,10 +191,11 @@ export function renderResonatorTrack(
186
191
  performance?.accent || note.articulation === "marcato"
187
192
  ? ACCENT_HARDNESS
188
193
  : 0;
194
+ const hz = noteHz(note.pitch, note.cents, context.tuning);
189
195
  const bank = new ModalBank(
190
- settings,
196
+ paired ? { ...settings, ombak: 0 } : settings,
191
197
  {
192
- hz: noteHz(note.pitch, note.cents, context.tuning),
198
+ hz: paired ? hz + settings.ombak : hz,
193
199
  velocity: note.velocity,
194
200
  start: start / sampleRate,
195
201
  duration: length / sampleRate,
@@ -282,6 +288,8 @@ export const MODAL_ENGINE: InstrumentEngine = Object.freeze({
282
288
  },
283
289
  tailSeconds: (track: Track, lowestPitch?: number) =>
284
290
  modalTailSeconds(track.modal, lowestPitch),
291
+ // Undamped bars and gongs ring on after the key lifts, as struck metal does.
292
+ ringOut: (track: Track) => modalSettings(track.modal).damp === 0,
285
293
  stereo: () => false,
286
294
  assetDigests: () => [`resonators:${RESONATOR_TABLE_VERSION}`],
287
295
  });
@@ -32,6 +32,7 @@ import {
32
32
  type TrackScore,
33
33
  } from "../../core/score.ts";
34
34
  import { sampleKey, type DecodedSample, type SampleBank } from "./samples.ts";
35
+ import { seedHash } from "./dsp/rng.ts";
35
36
  import {
36
37
  barAt,
37
38
  loopSecondsOf,
@@ -46,7 +47,7 @@ import {
46
47
  type FitMap,
47
48
  type SampleWarp,
48
49
  } from "./warp.ts";
49
- import { fittedBuffer, windowAt } from "./fit.ts";
50
+ import { fittedBuffer, shiftedBuffer, windowAt } from "./fit.ts";
50
51
  import {
51
52
  performanceTimingFor,
52
53
  tunedTiming,
@@ -106,6 +107,10 @@ export type SamplerVoice = {
106
107
  readonly cents?: NotePerformance["cents"];
107
108
  /** Half pedal: the level fades from `from` seconds. */
108
109
  readonly damp?: NotePerformance["damp"];
110
+ /** `fadeInTime` in output frames (0.6.1); absent is the 1 ms anti-click. */
111
+ readonly attack?: number;
112
+ /** A resampled stereo file (0.6.1): plays its own left and right. */
113
+ readonly stereo?: boolean;
109
114
  };
110
115
 
111
116
  /**
@@ -160,38 +165,111 @@ function noteLengthFrames(note: Note, timing: SamplerTiming): number {
160
165
  function voiceResolver(
161
166
  track: Track,
162
167
  tuning?: TuningTable,
163
- ): (pitch: number) => { voice: string; ratio: number } | undefined {
168
+ ): (
169
+ pitch: number,
170
+ velocity: number,
171
+ ) => { voice: string; ratio: number } | undefined {
164
172
  const sampler = track.sampler!;
173
+ const pick = layerPicker(
174
+ track.id,
175
+ sampler.voices,
176
+ sampler.mode === "oneshot",
177
+ );
165
178
  if (sampler.mode === "oneshot") {
166
179
  const byPitch = new Map<number, string>();
167
180
  for (const [voice, slot] of samplerVoiceSlots(sampler))
168
181
  byPitch.set(slot, voice);
169
- return (pitch) => {
170
- const voice = byPitch.get(pitch);
182
+ return (pitch, velocity) => {
183
+ const slotted = byPitch.get(pitch);
184
+ if (slotted === undefined) return undefined;
185
+ const voice = pick(slotted, velocity);
171
186
  return voice === undefined ? undefined : { voice, ratio: 1 };
172
187
  };
173
188
  }
174
189
  // Keyed: the voice whose root is the highest at or below the pitch (a
175
- // multi-sampled instrument), else the lowest root.
190
+ // multi-sampled instrument), else the lowest root. With velocity layers
191
+ // only the voices whose `vel` holds the note's velocity take part.
176
192
  const keyed = Object.entries(sampler.voices)
177
- .map(([voice, ref]) => ({ voice, root: ref.root ?? 60 }))
193
+ .map(([voice, ref]) => ({ voice, root: ref.root ?? 60, vel: ref.vel }))
178
194
  .sort((a, b) => a.root - b.root || (a.voice < b.voice ? -1 : 1));
179
- return (pitch) => {
180
- let chosen = keyed[0];
181
- for (const candidate of keyed)
182
- if (candidate.root <= pitch) chosen = candidate;
195
+ return (pitch, velocity) => {
196
+ let chosen: (typeof keyed)[number] | undefined;
197
+ for (const candidate of keyed) {
198
+ if (!inLayer(candidate.vel, velocity)) continue;
199
+ if (chosen === undefined || candidate.root <= pitch) chosen = candidate;
200
+ }
183
201
  if (!chosen) return undefined;
184
- if (!tuning)
185
- return { voice: chosen.voice, ratio: 2 ** ((pitch - chosen.root) / 12) };
202
+ const voice = pick(chosen.voice, velocity) ?? chosen.voice;
203
+ if (!tuning) return { voice, ratio: 2 ** ((pitch - chosen.root) / 12) };
186
204
  const hz = tuning.hz[pitch]!;
187
205
  if (!(hz > 0)) return undefined;
188
206
  return {
189
- voice: chosen.voice,
207
+ voice,
190
208
  ratio: hz / (440 * 2 ** ((chosen.root - 69) / 12)),
191
209
  };
192
210
  };
193
211
  }
194
212
 
213
+ /** MIDI velocity (0..127) of a note velocity 0..1. */
214
+ export function midiVelocity(velocity: number): number {
215
+ return Math.max(0, Math.min(127, Math.round(velocity * 127)));
216
+ }
217
+
218
+ function inLayer(
219
+ vel: readonly [number, number] | undefined,
220
+ velocity: number,
221
+ ): boolean {
222
+ if (!vel) return true;
223
+ const v = midiVelocity(velocity);
224
+ return v >= vel[0] && v <= vel[1];
225
+ }
226
+
227
+ /**
228
+ * Velocity layers and round robin (0.6.1, SFZ lovel/hivel and seq_length).
229
+ * A voice with `rr` stands for its whole group: of the group's voices that
230
+ * hold the note's velocity (and, keyed, share its root) the next one in
231
+ * name order plays. When no layer of the group holds the velocity, the
232
+ * nearest layer plays rather than nothing. Each group keeps its own
233
+ * counter in note order, starting at a turn seeded by the track id and
234
+ * the group, so a render is deterministic (A B A B) while two tracks
235
+ * with the same samples need not alternate in lockstep. A keyed voice
236
+ * without `rr` plays itself when its `vel` holds the velocity, else
237
+ * nothing; a one-shot voice without `rr` (its own pad) always plays.
238
+ */
239
+ function layerPicker(
240
+ trackId: string,
241
+ voices: Readonly<Record<string, SampleRef>>,
242
+ oneshot: boolean,
243
+ ): (voice: string, velocity: number) => string | undefined {
244
+ const counters = new Map<string, number>();
245
+ const names = Object.keys(voices).sort();
246
+ return (voice, velocity) => {
247
+ const ref = voices[voice]!;
248
+ if (ref.rr === undefined)
249
+ return oneshot || inLayer(ref.vel, velocity) ? voice : undefined;
250
+ const members = names.filter((name) => {
251
+ const other = voices[name]!;
252
+ return other.rr === ref.rr && (other.root ?? 60) === (ref.root ?? 60);
253
+ });
254
+ let group = members.filter((name) => inLayer(voices[name]!.vel, velocity));
255
+ if (group.length === 0) {
256
+ const v = midiVelocity(velocity);
257
+ const gap = (name: string) => {
258
+ const vel = voices[name]!.vel!;
259
+ return v < vel[0] ? vel[0] - v : v - vel[1];
260
+ };
261
+ const nearest = Math.min(...members.map(gap));
262
+ group = members.filter((name) => gap(name) === nearest);
263
+ }
264
+ if (group.length === 0) return undefined;
265
+ const key = `${ref.rr}:${ref.root ?? 60}:${group.join(",")}`;
266
+ const turn =
267
+ counters.get(key) ?? seedHash(`${trackId}:${key}`) % group.length;
268
+ counters.set(key, turn + 1);
269
+ return group[turn % group.length];
270
+ };
271
+ }
272
+
195
273
  /** Plans every voice a sampler track plays, with choke and polyphony applied. */
196
274
  export function planSamplerVoices(
197
275
  track: Track,
@@ -224,7 +302,7 @@ export function planSamplerVoices(
224
302
  voice.fade = Math.min(fade, end - voice.start);
225
303
  };
226
304
  for (const note of ordered) {
227
- const resolved = resolve(note.pitch);
305
+ const resolved = resolve(note.pitch, note.velocity);
228
306
  if (!resolved) continue;
229
307
  // A note's cents repitch every voice, keyed or one-shot.
230
308
  const target = note.cents
@@ -256,8 +334,19 @@ export function planSamplerVoices(
256
334
  ? fitVoice(ref, decoded, note.startTick, held, timing, reach)
257
335
  : undefined;
258
336
  if (fitted === null) continue;
259
- const sample = fitted?.sample ?? decoded;
260
- const speed = fitted ? 1 : (ref.speed ?? 1);
337
+ // 0.6.1 `shift`/`formant`: a cached constant-length pitch shift of the
338
+ // played buffer (after any fit, so it follows fitmode).
339
+ const shifted = isShifted(ref)
340
+ ? shiftVoice(ref, fitted?.sample ?? decoded, fitted ? "fit" : "raw")
341
+ : undefined;
342
+ const sample = shifted ?? fitted?.sample ?? decoded;
343
+ // Live, until the cached shift is ready: a plain repitch at the shifted
344
+ // pitch (the length follows, like tape).
345
+ const repitch =
346
+ shifted === undefined && isShifted(ref)
347
+ ? 2 ** ((ref.shift ?? 0) / 12)
348
+ : 1;
349
+ const speed = fitted ? repitch : (ref.speed ?? 1) * repitch;
261
350
  const regionStart = fitted ? 0 : (ref.begin ?? 0) * sample.frames;
262
351
  const regionEnd = fitted ? sample.frames : (ref.end ?? 1) * sample.frames;
263
352
  // `fit`, `unit: "c"` and `unit: "s"` give the window a duration; the
@@ -277,7 +366,14 @@ export function planSamplerVoices(
277
366
  ? ((Math.abs(speed) * sample.sampleRate) / sampleRate) * target.ratio
278
367
  : ((regionEnd - regionStart) / (seconds * sampleRate)) * target.ratio;
279
368
  const natural = Math.max(1, Math.floor((regionEnd - regionStart) / step));
280
- const release = Math.max(1, Math.round(RELEASE_SECONDS * sampleRate));
369
+ const release = Math.max(
370
+ 1,
371
+ Math.round((ref.fadeTime ?? RELEASE_SECONDS) * sampleRate),
372
+ );
373
+ const endFade = Math.max(
374
+ 1,
375
+ Math.round((ref.fadeTime ?? END_FADE_SECONDS) * sampleRate),
376
+ );
281
377
  const loop = ref.loop === true;
282
378
  const clipped =
283
379
  ref.clip === undefined ? held : Math.max(1, Math.floor(held * ref.clip));
@@ -294,7 +390,7 @@ export function planSamplerVoices(
294
390
  fade = release;
295
391
  } else {
296
392
  length = natural;
297
- fade = Math.max(1, Math.round(END_FADE_SECONDS * sampleRate));
393
+ fade = endFade;
298
394
  }
299
395
  // accelerate: the rate ramps by `accelerate`× over the planned length;
300
396
  // a non-looping voice ends where the ramped read runs out (or stops).
@@ -308,7 +404,7 @@ export function planSamplerVoices(
308
404
  const until = Math.max(1, Math.floor(reach));
309
405
  if (until < length) {
310
406
  length = until;
311
- fade = Math.max(1, Math.round(END_FADE_SECONDS * sampleRate));
407
+ fade = endFade;
312
408
  }
313
409
  }
314
410
  const loopFrom = fitted
@@ -341,6 +437,12 @@ export function planSamplerVoices(
341
437
  loopSpan: loopTo - loopFrom,
342
438
  ramp,
343
439
  squiz: ref.squiz ?? 1,
440
+ ...(ref.fadeInTime !== undefined
441
+ ? { attack: Math.max(1, ref.fadeInTime * sampleRate) }
442
+ : {}),
443
+ ...(ref.from !== undefined && sample.left && sample.right
444
+ ? { stereo: true }
445
+ : {}),
344
446
  ...(performance?.cents ? { cents: performance.cents } : {}),
345
447
  ...(performance?.damp ? { damp: performance.damp } : {}),
346
448
  };
@@ -456,6 +558,48 @@ function fitVoice(
456
558
  });
457
559
  }
458
560
 
561
+ /** A SampleRef with a pitch or formant shift (0.6.1). */
562
+ export function isShifted(ref: SampleRef): boolean {
563
+ return (
564
+ (ref.shift !== undefined && ref.shift !== 0) || ref.formant !== undefined
565
+ );
566
+ }
567
+
568
+ /** Buffers already shifted, by the identity of the buffer they shift. */
569
+ const shiftKeys = new WeakMap<Float32Array, string>();
570
+ let shiftSerial = 0;
571
+
572
+ /**
573
+ * The shifted copy of `sample` (whole buffer, same length). Returns null
574
+ * while a live shift is still computing (the voice stays silent).
575
+ */
576
+ function shiftVoice(
577
+ ref: SampleRef,
578
+ sample: DecodedSample,
579
+ kind: "raw" | "fit",
580
+ ): DecodedSample | undefined {
581
+ // Decoded files key by content; fitted buffers (already cached by their
582
+ // own key) by identity.
583
+ let base = shiftKeys.get(sample.mono);
584
+ if (base === undefined) {
585
+ base =
586
+ kind === "raw"
587
+ ? `${sample.sha256}:${sample.sampleRate}:${sample.frames}`
588
+ : `fit#${(shiftSerial += 1)}`;
589
+ shiftKeys.set(sample.mono, base);
590
+ }
591
+ const key = `shift:${base}:${ref.shift ?? 0}:${ref.formant ?? "follow"}`;
592
+ const buffer = shiftedBuffer(
593
+ key,
594
+ sample.mono,
595
+ sample.sampleRate,
596
+ ref.shift ?? 0,
597
+ ref.formant,
598
+ );
599
+ if (!buffer) return undefined;
600
+ return Object.freeze({ ...sample, frames: buffer.length, mono: buffer });
601
+ }
602
+
459
603
  /** Fingerprint of a position map: its length and 64 exact samples. */
460
604
  function mapPrint(map: FitMap, frames: number): string {
461
605
  const parts = [map.length.toString()];
@@ -483,6 +627,8 @@ export function renderSamplerVoices(
483
627
  voices: readonly SamplerVoice[],
484
628
  timing: SamplerTiming,
485
629
  gainAt: (tick: number) => number,
630
+ /** The channel a stereo voice reads (absent: every voice reads mono). */
631
+ channel?: "left" | "right",
486
632
  ): void {
487
633
  const { sampleRate, score, warp } = timing;
488
634
  const samplesPerTick =
@@ -491,7 +637,8 @@ export function renderSamplerVoices(
491
637
  for (const voice of voices) {
492
638
  const { sample, step, regionStart, regionEnd, reverse, loop } = voice;
493
639
  const { loopStart, loopSpan, ramp } = voice;
494
- const mono = sample.mono;
640
+ const mono =
641
+ channel && voice.stereo ? (sample[channel] ?? sample.mono) : sample.mono;
495
642
  const span = regionEnd - regionStart;
496
643
  const crossfade = loop
497
644
  ? Math.min(LOOP_CROSSFADE_SECONDS * sample.sampleRate, loopSpan / 4)
@@ -549,7 +696,7 @@ export function renderSamplerVoices(
549
696
  const elapsed = index - voice.start;
550
697
  const value = squizzed ? squizzed[elapsed]! : read(elapsed);
551
698
  if (value === undefined) break;
552
- let envelope = Math.min(1, elapsed / attack);
699
+ let envelope = Math.min(1, elapsed / (voice.attack ?? attack));
553
700
  if (index >= fadeFrom) envelope *= (voice.end - index) / voice.fade;
554
701
  const damp = voice.damp;
555
702
  if (damp && elapsed / sampleRate > damp.from)
@@ -58,6 +58,12 @@ export type DecodedSample = Readonly<{
58
58
  channels: number;
59
59
  frames: number;
60
60
  mono: Float32Array;
61
+ /**
62
+ * The two channels of a stereo file (0.6.1): a resampled ref (one with
63
+ * `from`) plays them as stereo. Absent for mono and multichannel files.
64
+ */
65
+ left?: Float32Array;
66
+ right?: Float32Array;
61
67
  }>;
62
68
 
63
69
  export type SampleProblem = Readonly<{
@@ -324,6 +330,20 @@ function checkShape(sampleRate: number, channels: number, frames: number) {
324
330
  }
325
331
 
326
332
  /** Average the channels; the renderer plays sampler voices in mono before pan. */
333
+ /** A stereo file's left and right channels (nothing for other shapes). */
334
+ export function stereoChannels(
335
+ pcm: PcmData,
336
+ ): { left: Float32Array; right: Float32Array } | Record<string, never> {
337
+ if (pcm.channels !== 2) return {};
338
+ const left = new Float32Array(pcm.frames);
339
+ const right = new Float32Array(pcm.frames);
340
+ for (let frame = 0; frame < pcm.frames; frame += 1) {
341
+ left[frame] = pcm.data[frame * 2]!;
342
+ right[frame] = pcm.data[frame * 2 + 1]!;
343
+ }
344
+ return { left, right };
345
+ }
346
+
327
347
  export function monoMix(pcm: PcmData): Float32Array {
328
348
  if (pcm.channels === 1) return pcm.data;
329
349
  const mono = new Float32Array(pcm.frames);
@@ -896,6 +916,7 @@ export class SampleLibrary implements SampleSource {
896
916
  channels: pcm.channels,
897
917
  frames: pcm.frames,
898
918
  mono: monoMix(pcm),
919
+ ...stereoChannels(pcm),
899
920
  });
900
921
  this.remember(sample);
901
922
  return sample;
@@ -943,6 +964,7 @@ export class SampleLibrary implements SampleSource {
943
964
  channels: pcm.channels,
944
965
  frames: pcm.frames,
945
966
  mono: monoMix(pcm),
967
+ ...stereoChannels(pcm),
946
968
  });
947
969
  this.remember(sample);
948
970
  return sample;
@@ -966,7 +988,10 @@ export class SampleLibrary implements SampleSource {
966
988
  }
967
989
 
968
990
  private remember(sample: DecodedSample): void {
969
- const bytes = sample.mono.byteLength;
991
+ const bytes =
992
+ sample.mono.byteLength +
993
+ (sample.left?.byteLength ?? 0) +
994
+ (sample.right?.byteLength ?? 0);
970
995
  this.memory.set(sample.sha256, { sample, bytes });
971
996
  this.memoryBytes += bytes;
972
997
  for (const [sha, entry] of this.memory) {