@hraness/dawg 0.4.0 → 0.5.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.
Files changed (116) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/DAWG.md +300 -35
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/chain.ts +12 -2
  64. package/src/audio/effects/common.ts +30 -1
  65. package/src/audio/effects/convolution.ts +43 -5
  66. package/src/audio/effects/dynamics.ts +2 -1
  67. package/src/audio/effects/filter.ts +5 -3
  68. package/src/audio/effects/modulation.ts +5 -1
  69. package/src/audio/effects/space.ts +197 -50
  70. package/src/audio/engine.ts +175 -20
  71. package/src/audio/live.ts +33 -6
  72. package/src/audio/loudness.ts +551 -0
  73. package/src/audio/master.ts +660 -0
  74. package/src/audio/measure-worker.ts +45 -0
  75. package/src/audio/measure.ts +110 -0
  76. package/src/audio/player.ts +11 -6
  77. package/src/audio/preview.ts +87 -16
  78. package/src/audio/render-worker.ts +6 -1
  79. package/src/audio/renderer.ts +7 -1
  80. package/src/audio/sampler.ts +108 -19
  81. package/src/audio/synth/voice.ts +60 -13
  82. package/src/audio/synth/zzfx.ts +10 -4
  83. package/src/audio/warp.ts +86 -0
  84. package/src/audio/wav.ts +448 -59
  85. package/src/commands/arrange.ts +949 -0
  86. package/src/commands/expression.ts +934 -0
  87. package/src/commands/help.ts +281 -24
  88. package/src/commands/master.ts +361 -0
  89. package/src/commands/music.ts +6 -1
  90. package/src/commands/synth.ts +11 -1
  91. package/src/commands/time.ts +964 -0
  92. package/src/commands/tuning.ts +490 -0
  93. package/src/main.ts +826 -47
  94. package/src/render.ts +109 -7
  95. package/src/session/daemon.ts +18 -8
  96. package/src/session/naming.ts +8 -1
  97. package/src/session/rebase.ts +21 -0
  98. package/src/tui/arrange-menu.ts +390 -0
  99. package/src/tui/audition.ts +58 -5
  100. package/src/tui/euclid.ts +50 -6
  101. package/src/tui/fader.ts +409 -0
  102. package/src/tui/menu-time.ts +401 -0
  103. package/src/tui/menu.ts +656 -16
  104. package/src/tui/performance-menu.ts +235 -0
  105. package/src/tui/play-chords.ts +95 -1
  106. package/src/tui/play-mode.ts +150 -6
  107. package/src/tui/play-session.ts +414 -41
  108. package/tui/app.ts +262 -13
  109. package/tui/arrange-strip.ts +174 -0
  110. package/tui/drawer.ts +478 -0
  111. package/tui/grammar.ts +79 -12
  112. package/tui/guide.ts +351 -0
  113. package/tui/highway.ts +87 -4
  114. package/tui/hits.ts +68 -0
  115. package/tui/input.ts +7 -0
  116. package/tui/keys.ts +72 -0
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Render a score and measure the result: the shared path behind
3
+ * `master measure`, `dawg render`'s loudness line and the measure_mix agent
4
+ * tool. The loop is measured as a loop playing forever (its steady state),
5
+ * a one-shot as rendered, so every reading matches what playback and export
6
+ * produce. Measuring never changes the score.
7
+ */
8
+ import type { TrackScore } from "../../core/score.ts";
9
+ import type { MeasureReply, MeasureRequest } from "./measure-worker.ts";
10
+ import { measureMix, pcmChannels, type MixMeasurement } from "./loudness.ts";
11
+ import type { MasterReport } from "./master.ts";
12
+ import {
13
+ DEFAULT_SAMPLE_RATE,
14
+ renderScorePcm,
15
+ type RenderedAudio,
16
+ type RenderOptions,
17
+ } from "./wav.ts";
18
+
19
+ /** The rate a song with a master exports (and is measured) at. */
20
+ export const MASTER_SAMPLE_RATE = 48_000;
21
+
22
+ /**
23
+ * The rate `dawg render` writes and measurements use, so a reading matches
24
+ * the exported file: 48 kHz once the song has a master (a deliverable, with
25
+ * room for the high shelf and a true peak a platform's resampler will not
26
+ * raise), the engine's 22,050 Hz otherwise (dawg 0.4 exports unchanged).
27
+ */
28
+ export function exportSampleRate(score: TrackScore): number {
29
+ return score.master ? MASTER_SAMPLE_RATE : DEFAULT_SAMPLE_RATE;
30
+ }
31
+
32
+ export type ScoreMeasurement = Readonly<{
33
+ mix: MixMeasurement;
34
+ master?: MasterReport;
35
+ sampleRate: number;
36
+ seconds: number;
37
+ loop: boolean;
38
+ }>;
39
+
40
+ /** Measure audio rendered elsewhere (the export, a preview). */
41
+ export function measureRendered(
42
+ audio: RenderedAudio,
43
+ loop: boolean,
44
+ ): ScoreMeasurement {
45
+ const [left, right] = pcmChannels(audio.pcm);
46
+ return Object.freeze({
47
+ mix: measureMix(left, right, audio.sampleRate, { loop }),
48
+ ...(audio.master ? { master: audio.master } : {}),
49
+ sampleRate: audio.sampleRate,
50
+ seconds: audio.frames / audio.sampleRate,
51
+ loop,
52
+ });
53
+ }
54
+
55
+ /** Render `score` (as a loop by default) and measure it. */
56
+ export function measureScore(
57
+ score: TrackScore,
58
+ options: RenderOptions = {},
59
+ ): ScoreMeasurement {
60
+ const loop = options.loop ?? true;
61
+ const audio = renderScorePcm(score, {
62
+ sampleRate: exportSampleRate(score),
63
+ ...options,
64
+ loop,
65
+ });
66
+ return measureRendered(audio, loop);
67
+ }
68
+
69
+ /**
70
+ * `measureScore` in a worker, so the caller's thread keeps running; falls
71
+ * back to measuring inline if the worker cannot start or fails.
72
+ */
73
+ export async function measureScoreOffThread(
74
+ score: TrackScore,
75
+ options: Readonly<{
76
+ sampleRate?: number;
77
+ loop?: boolean;
78
+ projectRoot?: string;
79
+ }> = {},
80
+ ): Promise<ScoreMeasurement> {
81
+ let worker: Worker | undefined;
82
+ try {
83
+ worker = new Worker(new URL("./measure-worker.ts", import.meta.url).href);
84
+ const running = worker;
85
+ const reply = await new Promise<MeasureReply>((resolve, reject) => {
86
+ running.addEventListener("message", (event) =>
87
+ resolve((event as MessageEvent<MeasureReply>).data),
88
+ );
89
+ running.addEventListener("error", (event) =>
90
+ reject(new Error((event as ErrorEvent).message ?? "worker error")),
91
+ );
92
+ const request: MeasureRequest = {
93
+ score: score.toJSON(),
94
+ ...options,
95
+ };
96
+ running.postMessage(request);
97
+ });
98
+ if (reply.ok) return reply.measured;
99
+ throw new Error(reply.error);
100
+ } catch {
101
+ return measureScore(score, {
102
+ ...(options.sampleRate === undefined
103
+ ? {}
104
+ : { sampleRate: options.sampleRate }),
105
+ ...(options.loop === undefined ? {} : { loop: options.loop }),
106
+ });
107
+ } finally {
108
+ worker?.terminate();
109
+ }
110
+ }
@@ -2,7 +2,9 @@ import { unlink } from "node:fs/promises";
2
2
  import type { TrackScore } from "../../core/score.ts";
3
3
  import { PlaybackLock } from "./lock.ts";
4
4
  import { SampleLibrary, hasSamplerTracks, type SampleBank } from "./samples.ts";
5
- import { RENDER_CHANNELS, encodeWav, renderScorePcm } from "./wav.ts";
5
+ import { RENDER_CHANNELS, encodeWav } from "./wav.ts";
6
+ import { transportMapFor } from "./clock.ts";
7
+ import { playbackTime, renderArrangedPcm } from "./arrange.ts";
6
8
 
7
9
  /** Small best-effort local player. The score and transport remain testable without a sound device. */
8
10
  export class LoopPlayer {
@@ -118,12 +120,15 @@ export function renderLoopWav(
118
120
  beat = 0,
119
121
  samples?: SampleBank,
120
122
  ): Uint8Array {
121
- const audio = renderScorePcm(score, { loop: true, samples });
122
- const framesPerBeat = (60 * audio.sampleRate) / score.tempoBpm;
123
+ const audio = renderArrangedPcm(score, { loop: true, samples });
124
+ const timeline = playbackTime(score);
125
+ const framesPerBeat = (60 * audio.sampleRate) / timeline.tempoBpm;
126
+ const map = transportMapFor(timeline);
127
+ const first = map
128
+ ? map.seconds(Math.max(0, beat) % map.loopBeats) * audio.sampleRate
129
+ : Math.max(0, beat) * framesPerBeat;
123
130
  const start =
124
- ((Math.round(Math.max(0, beat) * framesPerBeat) % audio.frames) +
125
- audio.frames) %
126
- audio.frames;
131
+ ((Math.round(first) % audio.frames) + audio.frames) % audio.frames;
127
132
  const rotated = new Int16Array(audio.pcm.length);
128
133
  rotated.set(audio.pcm.subarray(start * RENDER_CHANNELS));
129
134
  rotated.set(
@@ -16,6 +16,7 @@
16
16
  * wavetables so position changes are audible.
17
17
  */
18
18
  import { isDrumInstrument } from "../../core/drums.ts";
19
+ import type { SongMaster } from "../../core/master.ts";
19
20
  import { MODES, parseKey } from "../../core/chords.ts";
20
21
  import { rhythmVoicePitch } from "../../core/rhythm.ts";
21
22
  import {
@@ -28,7 +29,9 @@ import {
28
29
  type NoteInput,
29
30
  type Track,
30
31
  } from "../../core/score.ts";
32
+ import { barAt, barStartTick, songTimeSlice } from "../../core/tempo.ts";
31
33
  import { interpolateAutomation } from "./effects/common.ts";
34
+ import { loopedSection, scoreBeatAt } from "./arrange.ts";
32
35
  import { RENDER_CHANNELS } from "./wav.ts";
33
36
 
34
37
  /** Longest preview loop, in bars. */
@@ -50,6 +53,20 @@ export type PreviewOptions = Readonly<{
50
53
  beat?: number;
51
54
  /** Overrides the region choice (tests, the agent tool). */
52
55
  region?: PreviewRegion;
56
+ /**
57
+ * Replaces the focused track's notes with this phrase (from tick 0 of the
58
+ * region). The chord settings screen plays a progression performed with
59
+ * the staged settings this way, so a voicing or arp change is audible.
60
+ */
61
+ phrase?: (score: TrackScore, track: Track, bars: number) => NoteInput[];
62
+ /**
63
+ * The limiter drive the song's master solved for its loudness target on
64
+ * the full mix, dB. A preview plays the master at this fixed drive instead
65
+ * of re-solving the target on its few bars, so a volume edit stays
66
+ * audible. Without it a solo plays the master with no target (the
67
+ * limiter at its own gain), and a context preview solves on its bars.
68
+ */
69
+ masterGainDb?: number;
53
70
  }>;
54
71
 
55
72
  export type Preview = Readonly<{
@@ -66,23 +83,42 @@ export function previewRegion(
66
83
  trackId: string,
67
84
  beat = 0,
68
85
  ): PreviewRegion {
69
- if (score.bars <= MAX_PREVIEW_BARS) return { startBar: 0, bars: score.bars };
86
+ // A looped section bounds the audition, and the playhead (an arranged
87
+ // beat when the song has a form) maps back to the bars it is playing.
88
+ const section = loopedSection(score);
89
+ const bounded = section !== undefined && section.startBar < score.bars;
90
+ const lo = bounded ? section.startBar : 0;
91
+ const hi = bounded
92
+ ? Math.min(score.bars, section.startBar + section.bars)
93
+ : score.bars;
94
+ if (hi - lo <= MAX_PREVIEW_BARS) return { startBar: lo, bars: hi - lo };
70
95
  const bars = PREVIEW_REGION_BARS;
71
- const barTicks = score.beatsPerBar * score.ticksPerBeat;
72
- const last = score.bars - bars;
96
+ const last = hi - bars;
73
97
  const clampBar = (bar: number) =>
74
- Math.max(0, Math.min(last, Number.isFinite(bar) ? Math.floor(bar) : 0));
75
- let startBar = clampBar(beat / score.beatsPerBar);
98
+ Math.max(lo, Math.min(last, Number.isFinite(bar) ? Math.floor(bar) : lo));
99
+ // Bars through the meter changes; 0.4 scores keep `beat / beatsPerBar`.
100
+ const barOfTick = (tick: number) => barAt(score, tick).bar;
101
+ const scoreBeat = scoreBeatAt(score, beat);
102
+ let startBar = clampBar(
103
+ Number.isFinite(scoreBeat) ? barOfTick(scoreBeat * score.ticksPerBeat) : lo,
104
+ );
76
105
  const notes = score.notes.filter((note) => note.trackId === trackId);
77
106
  const inRegion = (from: number) =>
78
107
  notes.some(
79
108
  (note) =>
80
- note.startTick >= from * barTicks &&
81
- note.startTick < (from + bars) * barTicks,
109
+ note.startTick >= barStartTick(score, from) &&
110
+ note.startTick < barStartTick(score, from + bars),
82
111
  );
83
- if (notes.length > 0 && !inRegion(startBar)) {
84
- const first = Math.min(...notes.map((note) => note.startTick));
85
- startBar = clampBar(first / barTicks);
112
+ const loTick = barStartTick(score, lo);
113
+ const hiTick = barStartTick(score, hi);
114
+ const pool = bounded
115
+ ? notes.filter(
116
+ (note) => note.startTick >= loTick && note.startTick < hiTick,
117
+ )
118
+ : notes;
119
+ if (pool.length > 0 && !inRegion(startBar)) {
120
+ const first = Math.min(...pool.map((note) => note.startTick));
121
+ startBar = clampBar(barOfTick(first));
86
122
  }
87
123
  return { startBar, bars };
88
124
  }
@@ -271,9 +307,8 @@ export function previewScore(
271
307
  Math.min(region.bars, score.bars - Math.max(0, region.startBar)),
272
308
  );
273
309
  const startBar = Math.max(0, Math.min(region.startBar, score.bars - 1));
274
- const barTicks = score.beatsPerBar * score.ticksPerBeat;
275
- const start = startBar * barTicks;
276
- const end = start + bars * barTicks;
310
+ const start = barStartTick(score, startBar);
311
+ const end = barStartTick(score, startBar + bars);
277
312
  const context = options.context === true;
278
313
  const anySolo = score.tracks.some((candidate) => candidate.solo === true);
279
314
  const tracks = score.tracks
@@ -288,28 +323,42 @@ export function previewScore(
288
323
  ...(context && anySolo ? { solo: true } : {}),
289
324
  };
290
325
  });
291
- const own = score.notes.some((note) => note.trackId === trackId);
326
+ const replaced = options.phrase !== undefined;
327
+ const own = !replaced && score.notes.some((note) => note.trackId === trackId);
292
328
  const notes: NoteInput[] = score.notes
293
329
  .filter(
294
330
  (note) =>
295
331
  (context || note.trackId === trackId) &&
332
+ !(replaced && note.trackId === trackId) &&
296
333
  note.startTick >= start &&
297
334
  note.startTick < end,
298
335
  )
299
336
  .map((note) => ({ ...note, startTick: note.startTick - start }));
300
337
  let role: PhraseRole | undefined;
301
- if (!own) {
338
+ if (options.phrase) {
339
+ role = "chord";
340
+ notes.push(...options.phrase(score, track, bars));
341
+ } else if (!own) {
302
342
  role = phraseRole(track);
303
343
  notes.push(...defaultPhrase(score, track, bars, role));
304
344
  }
345
+ // The song's tempo map and meter, rebased to the region's first bar.
346
+ const timing = songTimeSlice(score, startBar, bars);
305
347
  const preview = new TrackScore({
306
- tempoBpm: score.tempoBpm,
348
+ tempoBpm: timing.tempoBpm,
349
+ ...(timing.time ? { time: timing.time } : {}),
307
350
  beatsPerBar: score.beatsPerBar,
308
351
  bars,
309
352
  ticksPerBeat: score.ticksPerBeat,
310
353
  key: score.key,
311
354
  tracks,
312
355
  notes,
356
+ // The master is the song's output stage, so a soloed preview passes
357
+ // through it too, the way a DAW solo still reaches the master bus: at
358
+ // the song's fixed drive, not a target re-solved on what is soloed.
359
+ ...(score.master
360
+ ? { master: previewMaster(score.master, context, options.masterGainDb) }
361
+ : {}),
313
362
  });
314
363
  return {
315
364
  score: preview,
@@ -319,6 +368,28 @@ export function previewScore(
319
368
  };
320
369
  }
321
370
 
371
+ /**
372
+ * The master a preview plays: the song's, with its loudness target swapped
373
+ * for a fixed limiter drive (see PreviewOptions.masterGainDb).
374
+ */
375
+ export function previewMaster(
376
+ master: SongMaster,
377
+ context: boolean,
378
+ masterGainDb: number | undefined,
379
+ ): SongMaster {
380
+ if (master.target === undefined) return master;
381
+ if (masterGainDb === undefined && context) return master;
382
+ const { target: _target, ...rest } = master;
383
+ if (!rest.limiter || masterGainDb === undefined) return rest;
384
+ return {
385
+ ...rest,
386
+ limiter: {
387
+ ...rest.limiter,
388
+ gain: Math.max(0, Math.min(24, masterGainDb)),
389
+ },
390
+ };
391
+ }
392
+
322
393
  // ── analysis ──────────────────────────────────────────────────────────
323
394
 
324
395
  export type SoundStats = Readonly<{
@@ -5,6 +5,8 @@
5
5
  */
6
6
  import { scoreFromJSON } from "../../core/score.ts";
7
7
  import { SampleLibrary, hasSamplerTracks } from "./samples.ts";
8
+ import type { MasterReport } from "./master.ts";
9
+ import { renderArranged } from "./arrange.ts";
8
10
  import { StemRenderer } from "./wav.ts";
9
11
 
10
12
  export type RenderRequest = Readonly<{
@@ -24,6 +26,8 @@ export type RenderReply =
24
26
  pcm: Int16Array;
25
27
  /** Milliseconds spent rendering, excluding transfer. */
26
28
  renderMs: number;
29
+ /** Loudness after the song master; absent without one. */
30
+ master?: MasterReport;
27
31
  }>
28
32
  | Readonly<{ id: number; ok: false; error: string }>;
29
33
 
@@ -43,7 +47,7 @@ self.onmessage = async (event: MessageEvent<RenderRequest>) => {
43
47
  library = new SampleLibrary({ projectRoot });
44
48
  samples = await library.load(parsed);
45
49
  }
46
- const audio = renderer.render(parsed, {
50
+ const audio = renderArranged(renderer, parsed, {
47
51
  sampleRate,
48
52
  loop: true,
49
53
  samples,
@@ -55,6 +59,7 @@ self.onmessage = async (event: MessageEvent<RenderRequest>) => {
55
59
  sampleRate: audio.sampleRate,
56
60
  pcm: audio.pcm,
57
61
  renderMs: performance.now() - started,
62
+ ...(audio.master ? { master: audio.master } : {}),
58
63
  };
59
64
  self.postMessage(reply, [audio.pcm.buffer]);
60
65
  } catch (error) {
@@ -1,6 +1,8 @@
1
1
  import type { TrackScore } from "../../core/score.ts";
2
+ import type { MasterReport } from "./master.ts";
2
3
  import type { RenderReply, RenderRequest } from "./render-worker.ts";
3
4
  import { SampleLibrary, hasSamplerTracks } from "./samples.ts";
5
+ import { renderArranged } from "./arrange.ts";
4
6
  import { StemRenderer } from "./wav.ts";
5
7
 
6
8
  export type LoopRender = Readonly<{
@@ -9,6 +11,8 @@ export type LoopRender = Readonly<{
9
11
  sampleRate: number;
10
12
  /** Milliseconds the render itself took (worker or inline). */
11
13
  renderMs: number;
14
+ /** Loudness after the song master; absent without one. */
15
+ master?: MasterReport;
12
16
  }>;
13
17
 
14
18
  export type LoopRendererOptions = Readonly<{
@@ -80,7 +84,7 @@ export class LoopRenderer {
80
84
  this.library ??= new SampleLibrary({ projectRoot: this.projectRoot });
81
85
  samples = await this.library.load(score);
82
86
  }
83
- const audio = this.inline.render(score, {
87
+ const audio = renderArranged(this.inline, score, {
84
88
  sampleRate: this.sampleRate,
85
89
  loop: true,
86
90
  samples,
@@ -90,6 +94,7 @@ export class LoopRenderer {
90
94
  frames: audio.frames,
91
95
  sampleRate: audio.sampleRate,
92
96
  renderMs: performance.now() - started,
97
+ ...(audio.master ? { master: audio.master } : {}),
93
98
  };
94
99
  }
95
100
 
@@ -143,6 +148,7 @@ export class LoopRenderer {
143
148
  frames: reply.frames,
144
149
  sampleRate: reply.sampleRate,
145
150
  renderMs: reply.renderMs,
151
+ ...(reply.master ? { master: reply.master } : {}),
146
152
  });
147
153
  else waiter.reject(new Error(reply.error));
148
154
  });
@@ -13,7 +13,8 @@
13
13
  * `accelerate` ramps the rate; `squiz` raises pitch per zero-crossing
14
14
  * cycle (described in Tidal/SuperDirt docs; implemented here from that
15
15
  * description).
16
- * - keyed mode repitches from `root` (rate × 2^((pitch − root)/12)) and holds
16
+ * - keyed mode repitches from `root` (rate × 2^((pitch − root)/12), or the
17
+ * tuning's key frequency over the root's 12-TET frequency) and holds
17
18
  * for the note's length; oneshot mode maps voices to pitch slots from 36 in
18
19
  * voice-name order and plays the whole window unless `loop` is set.
19
20
  *
@@ -31,6 +32,21 @@ import {
31
32
  type TrackScore,
32
33
  } from "../../core/score.ts";
33
34
  import { sampleKey, type DecodedSample, type SampleBank } from "./samples.ts";
35
+ import {
36
+ barAt,
37
+ loopSecondsOf,
38
+ performedNotes,
39
+ secondsAtTick,
40
+ } from "../../core/tempo.ts";
41
+ import { sampleWarpFor, warpedSpan, type SampleWarp } from "./warp.ts";
42
+ import {
43
+ performanceTimingFor,
44
+ tunedTiming,
45
+ performNotes,
46
+ type NotePerformance,
47
+ type PerformedNote,
48
+ } from "../../core/expression.ts";
49
+ import { resolveTuning, type TuningTable } from "../../core/tuning.ts";
34
50
 
35
51
  /** Output level of a full-scale sample at velocity 1 and gain 1. */
36
52
  export const SAMPLE_LEVEL = 0.7;
@@ -46,6 +62,8 @@ const LOOP_CROSSFADE_SECONDS = 0.005;
46
62
  export type SamplerTiming = Readonly<{
47
63
  score: TrackScore;
48
64
  sampleRate: number;
65
+ /** The song's tempo map in samples; absent keeps constant tempo. */
66
+ warp?: SampleWarp;
49
67
  }>;
50
68
 
51
69
  /** One planned voice: when it sounds and how it reads its sample. */
@@ -74,6 +92,10 @@ export type SamplerVoice = {
74
92
  readonly ramp: number;
75
93
  /** `squiz` ratio; 1 is off. */
76
94
  readonly squiz: number;
95
+ /** Note expression: pitch in cents over time (glide, bend, vibrato). */
96
+ readonly cents?: NotePerformance["cents"];
97
+ /** Half pedal: the level fades from `from` seconds. */
98
+ readonly damp?: NotePerformance["damp"];
77
99
  };
78
100
 
79
101
  /**
@@ -96,7 +118,8 @@ function framesToTravel(distance: number, step: number, ramp: number): number {
96
118
  }
97
119
 
98
120
  function noteStartFrame(note: Note, timing: SamplerTiming): number {
99
- const { score, sampleRate } = timing;
121
+ const { score, sampleRate, warp } = timing;
122
+ if (warp) return warpedSpan(warp, note.startTick, note.durationTicks).start;
100
123
  return Math.max(
101
124
  0,
102
125
  Math.floor(
@@ -107,7 +130,8 @@ function noteStartFrame(note: Note, timing: SamplerTiming): number {
107
130
  }
108
131
 
109
132
  function noteLengthFrames(note: Note, timing: SamplerTiming): number {
110
- const { score, sampleRate } = timing;
133
+ const { score, sampleRate, warp } = timing;
134
+ if (warp) return warpedSpan(warp, note.startTick, note.durationTicks).length;
111
135
  return Math.max(
112
136
  1,
113
137
  Math.floor(
@@ -117,9 +141,15 @@ function noteLengthFrames(note: Note, timing: SamplerTiming): number {
117
141
  );
118
142
  }
119
143
 
120
- /** Which voice a note plays and the pitch ratio it plays at. */
144
+ /**
145
+ * Which voice a note plays and the pitch ratio it plays at. A keyed voice
146
+ * repitches to the tuning's key frequency over its root's 12-TET frequency
147
+ * (a sample is recorded at concert pitch); one-shot slots ignore tuning,
148
+ * since their pitch picks a voice rather than a note.
149
+ */
121
150
  function voiceResolver(
122
151
  track: Track,
152
+ tuning?: TuningTable,
123
153
  ): (pitch: number) => { voice: string; ratio: number } | undefined {
124
154
  const sampler = track.sampler!;
125
155
  if (sampler.mode === "oneshot") {
@@ -141,7 +171,14 @@ function voiceResolver(
141
171
  for (const candidate of keyed)
142
172
  if (candidate.root <= pitch) chosen = candidate;
143
173
  if (!chosen) return undefined;
144
- return { voice: chosen.voice, ratio: 2 ** ((pitch - chosen.root) / 12) };
174
+ if (!tuning)
175
+ return { voice: chosen.voice, ratio: 2 ** ((pitch - chosen.root) / 12) };
176
+ const hz = tuning.hz[pitch]!;
177
+ if (!(hz > 0)) return undefined;
178
+ return {
179
+ voice: chosen.voice,
180
+ ratio: hz / (440 * 2 ** ((chosen.root - 69) / 12)),
181
+ };
145
182
  };
146
183
  }
147
184
 
@@ -154,7 +191,10 @@ export function planSamplerVoices(
154
191
  ): SamplerVoice[] {
155
192
  const sampler = track.sampler;
156
193
  if (!sampler) return [];
157
- const resolve = voiceResolver(track);
194
+ const resolve = voiceResolver(
195
+ track,
196
+ resolveTuning(timing.score.tuning, track.tuning, timing.score.key),
197
+ );
158
198
  const ordered = [...notes].sort(
159
199
  (a, b) =>
160
200
  a.startTick - b.startTick ||
@@ -174,8 +214,12 @@ export function planSamplerVoices(
174
214
  voice.fade = Math.min(fade, end - voice.start);
175
215
  };
176
216
  for (const note of ordered) {
177
- const target = resolve(note.pitch);
178
- if (!target) continue;
217
+ const resolved = resolve(note.pitch);
218
+ if (!resolved) continue;
219
+ // A note's cents repitch every voice, keyed or one-shot.
220
+ const target = note.cents
221
+ ? { ...resolved, ratio: resolved.ratio * 2 ** (note.cents / 1200) }
222
+ : resolved;
179
223
  const ref: SampleRef | undefined = sampler.voices[target.voice];
180
224
  const sample = bank.voices.get(sampleKey(track.id, target.voice));
181
225
  if (!ref || !sample) continue;
@@ -189,9 +233,7 @@ export function planSamplerVoices(
189
233
  const seconds = ref.fit
190
234
  ? held / sampleRate
191
235
  : unit === "c"
192
- ? (timing.score.beatsPerBar * 60) /
193
- timing.score.tempoBpm /
194
- Math.abs(speed)
236
+ ? cycleSeconds(timing.score, note.startTick) / Math.abs(speed)
195
237
  : unit === "s"
196
238
  ? Math.abs(speed)
197
239
  : undefined;
@@ -239,6 +281,7 @@ export function planSamplerVoices(
239
281
  const loopTo =
240
282
  ref.loopEnd === undefined ? regionEnd : ref.loopEnd * sample.frames;
241
283
  const start = noteStartFrame(note, timing);
284
+ const performance = (note as PerformedNote).performance;
242
285
  const voice: SamplerVoice = {
243
286
  voice: target.voice,
244
287
  sample,
@@ -257,6 +300,8 @@ export function planSamplerVoices(
257
300
  loopSpan: loopTo - loopFrom,
258
301
  ramp,
259
302
  squiz: ref.squiz ?? 1,
303
+ ...(performance?.cents ? { cents: performance.cents } : {}),
304
+ ...(performance?.damp ? { damp: performance.damp } : {}),
260
305
  };
261
306
  if (voice.choke !== undefined) {
262
307
  const previous = lastInGroup.get(voice.choke);
@@ -293,7 +338,7 @@ export function renderSamplerVoices(
293
338
  timing: SamplerTiming,
294
339
  gainAt: (tick: number) => number,
295
340
  ): void {
296
- const { sampleRate, score } = timing;
341
+ const { sampleRate, score, warp } = timing;
297
342
  const samplesPerTick =
298
343
  (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat);
299
344
  const attack = Math.max(1, ATTACK_SECONDS * sampleRate);
@@ -311,10 +356,31 @@ export function renderSamplerVoices(
311
356
  const level = SAMPLE_LEVEL * voice.gain;
312
357
  const at = (offset: number) =>
313
358
  readAt(mono, reverse ? regionEnd - 1 - offset : regionStart + offset);
359
+ // A bent voice (note expression) integrates its rate frame by frame;
360
+ // reads arrive in order, so the running sum is kept between calls.
361
+ const bent = voice.cents;
362
+ let bentAt = 0;
363
+ let bentTravel = 0;
364
+ const travel = (elapsed: number): number => {
365
+ if (!bent)
366
+ return ramp === 0 ? elapsed * step : travelAt(elapsed, step, ramp);
367
+ if (elapsed < bentAt) {
368
+ bentAt = 0;
369
+ bentTravel = 0;
370
+ }
371
+ while (bentAt < elapsed) {
372
+ const rate =
373
+ step *
374
+ (ramp === 0 ? 1 : Math.max(0, 1 + ramp * bentAt)) *
375
+ 2 ** (bent(bentAt / sampleRate) / 1200);
376
+ bentTravel += rate;
377
+ bentAt += 1;
378
+ }
379
+ return bentTravel;
380
+ };
314
381
  // The raw read at an elapsed frame, or undefined past a one-shot's end.
315
382
  const read = (elapsed: number): number | undefined => {
316
- const travelled =
317
- ramp === 0 ? elapsed * step : travelAt(elapsed, step, ramp);
383
+ const travelled = travel(elapsed);
318
384
  if (!loop) return travelled >= span ? undefined : at(travelled);
319
385
  if (travelled < loopStart) return at(travelled);
320
386
  const into = travelled - loopStart;
@@ -339,11 +405,16 @@ export function renderSamplerVoices(
339
405
  if (value === undefined) break;
340
406
  let envelope = Math.min(1, elapsed / attack);
341
407
  if (index >= fadeFrom) envelope *= (voice.end - index) / voice.fade;
408
+ const damp = voice.damp;
409
+ if (damp && elapsed / sampleRate > damp.from)
410
+ envelope *= Math.exp(-(elapsed / sampleRate - damp.from) / damp.tau);
342
411
  target[index]! +=
343
412
  value *
344
413
  envelope *
345
414
  level *
346
- gainAt(voice.startTick + elapsed / samplesPerTick);
415
+ gainAt(
416
+ warp ? warp.tick(index) : voice.startTick + elapsed / samplesPerTick,
417
+ );
347
418
  }
348
419
  }
349
420
  }
@@ -406,15 +477,33 @@ export function samplerTailSeconds(
406
477
  sampleRate: number,
407
478
  ): number {
408
479
  if (bank.voices.size === 0) return 0;
409
- const loopEnd =
410
- ((score.bars * score.beatsPerBar * 60) / score.tempoBpm) * sampleRate;
411
- const timing = { score, sampleRate };
480
+ const loopEnd = loopSecondsOf(score) * sampleRate;
481
+ const warp = sampleWarpFor(score, sampleRate);
482
+ const timing = { score, sampleRate, ...(warp ? { warp } : {}) };
412
483
  let latest = 0;
484
+ const performance = performanceTimingFor(score);
413
485
  for (const track of score.tracks) {
414
486
  if (!track.sampler) continue;
415
- const notes = score.notes.filter((note) => note.trackId === track.id);
487
+ const notes = performNotes(
488
+ track,
489
+ performedNotes(score).filter((note) => note.trackId === track.id),
490
+ tunedTiming(performance, score, track),
491
+ );
416
492
  for (const voice of planSamplerVoices(track, notes, bank, timing))
417
493
  latest = Math.max(latest, voice.end - loopEnd);
418
494
  }
419
495
  return latest / sampleRate;
420
496
  }
497
+
498
+ /**
499
+ * Seconds of the bar (a `c` cycle) holding `tick`: through the meter and
500
+ * the tempo map when the song has `time`, else the 0.4 arithmetic.
501
+ */
502
+ function cycleSeconds(score: TrackScore, tick: number): number {
503
+ if (!score.time) return (score.beatsPerBar * 60) / score.tempoBpm;
504
+ const bar = barAt(score, tick);
505
+ return (
506
+ secondsAtTick(score, bar.tick + bar.barTicks) -
507
+ secondsAtTick(score, bar.tick)
508
+ );
509
+ }