@hraness/dawg 0.4.1 → 0.6.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 (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. 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. */
@@ -56,6 +59,14 @@ export type PreviewOptions = Readonly<{
56
59
  * the staged settings this way, so a voicing or arp change is audible.
57
60
  */
58
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;
59
70
  }>;
60
71
 
61
72
  export type Preview = Readonly<{
@@ -72,23 +83,42 @@ export function previewRegion(
72
83
  trackId: string,
73
84
  beat = 0,
74
85
  ): PreviewRegion {
75
- 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 };
76
95
  const bars = PREVIEW_REGION_BARS;
77
- const barTicks = score.beatsPerBar * score.ticksPerBeat;
78
- const last = score.bars - bars;
96
+ const last = hi - bars;
79
97
  const clampBar = (bar: number) =>
80
- Math.max(0, Math.min(last, Number.isFinite(bar) ? Math.floor(bar) : 0));
81
- 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
+ );
82
105
  const notes = score.notes.filter((note) => note.trackId === trackId);
83
106
  const inRegion = (from: number) =>
84
107
  notes.some(
85
108
  (note) =>
86
- note.startTick >= from * barTicks &&
87
- note.startTick < (from + bars) * barTicks,
109
+ note.startTick >= barStartTick(score, from) &&
110
+ note.startTick < barStartTick(score, from + bars),
88
111
  );
89
- if (notes.length > 0 && !inRegion(startBar)) {
90
- const first = Math.min(...notes.map((note) => note.startTick));
91
- 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));
92
122
  }
93
123
  return { startBar, bars };
94
124
  }
@@ -99,6 +129,8 @@ export function phraseRole(track: Track): PhraseRole {
99
129
  if (isSamplerInstrument(track.instrument))
100
130
  return track.sampler?.mode === "keyed" ? "chord" : "groove";
101
131
  if (isWavetableInstrument(track.instrument)) return "drone";
132
+ // Grain clouds are textures: held notes let them evolve.
133
+ if (track.instrument === "granular") return "drone";
102
134
  const words = `${track.instrument} ${track.name} ${track.id}`.toLowerCase();
103
135
  if (/\b(bass|sub|808)/.test(words)) return "riff";
104
136
  if (/\b(lead|arp|pluck|saw|square|pulse|z_|mono|melody|hook)/.test(words))
@@ -277,9 +309,8 @@ export function previewScore(
277
309
  Math.min(region.bars, score.bars - Math.max(0, region.startBar)),
278
310
  );
279
311
  const startBar = Math.max(0, Math.min(region.startBar, score.bars - 1));
280
- const barTicks = score.beatsPerBar * score.ticksPerBeat;
281
- const start = startBar * barTicks;
282
- const end = start + bars * barTicks;
312
+ const start = barStartTick(score, startBar);
313
+ const end = barStartTick(score, startBar + bars);
283
314
  const context = options.context === true;
284
315
  const anySolo = score.tracks.some((candidate) => candidate.solo === true);
285
316
  const tracks = score.tracks
@@ -313,14 +344,23 @@ export function previewScore(
313
344
  role = phraseRole(track);
314
345
  notes.push(...defaultPhrase(score, track, bars, role));
315
346
  }
347
+ // The song's tempo map and meter, rebased to the region's first bar.
348
+ const timing = songTimeSlice(score, startBar, bars);
316
349
  const preview = new TrackScore({
317
- tempoBpm: score.tempoBpm,
350
+ tempoBpm: timing.tempoBpm,
351
+ ...(timing.time ? { time: timing.time } : {}),
318
352
  beatsPerBar: score.beatsPerBar,
319
353
  bars,
320
354
  ticksPerBeat: score.ticksPerBeat,
321
355
  key: score.key,
322
356
  tracks,
323
357
  notes,
358
+ // The master is the song's output stage, so a soloed preview passes
359
+ // through it too, the way a DAW solo still reaches the master bus: at
360
+ // the song's fixed drive, not a target re-solved on what is soloed.
361
+ ...(score.master
362
+ ? { master: previewMaster(score.master, context, options.masterGainDb) }
363
+ : {}),
324
364
  });
325
365
  return {
326
366
  score: preview,
@@ -330,6 +370,28 @@ export function previewScore(
330
370
  };
331
371
  }
332
372
 
373
+ /**
374
+ * The master a preview plays: the song's, with its loudness target swapped
375
+ * for a fixed limiter drive (see PreviewOptions.masterGainDb).
376
+ */
377
+ export function previewMaster(
378
+ master: SongMaster,
379
+ context: boolean,
380
+ masterGainDb: number | undefined,
381
+ ): SongMaster {
382
+ if (master.target === undefined) return master;
383
+ if (masterGainDb === undefined && context) return master;
384
+ const { target: _target, ...rest } = master;
385
+ if (!rest.limiter || masterGainDb === undefined) return rest;
386
+ return {
387
+ ...rest,
388
+ limiter: {
389
+ ...rest.limiter,
390
+ gain: Math.max(0, Math.min(24, masterGainDb)),
391
+ },
392
+ };
393
+ }
394
+
333
395
  // ── analysis ──────────────────────────────────────────────────────────
334
396
 
335
397
  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
  });
@@ -0,0 +1,287 @@
1
+ /**
2
+ * The modal percussion engine (0.6, lane f06-modal): renders a track whose
3
+ * instrument is `"modal"` and that carries a `modal` field. Registered in
4
+ * `src/audio/instruments.ts`; without the field the legacy tone voice plays.
5
+ *
6
+ * Per track: notes in onset order, at most `MAX_RESONATOR_VOICES` sounding
7
+ * (the oldest onset is stolen with a 5 ms fade), each a `ModalBank` struck
8
+ * once and rung for up to `MAX_RESONATOR_TAIL` seconds after note-off with
9
+ * early stop on silence. Stealing depends only on earlier onsets, so a
10
+ * render is prefix-stable: adding a later note never changes earlier audio
11
+ * before that note starts.
12
+ *
13
+ * 0.5 hooks: tuning and note cents (`noteHz`), bends and glides
14
+ * (`performance.cents`, faded per tick above 0.45 sr), articulation as
15
+ * timbre only (accent and marcato harden the mallet; staccato damps sooner
16
+ * through the shorter length), sustain pedal (longer lengths) and half
17
+ * pedal (`performance.damp`), velocity curve and humanize (already in the
18
+ * performed notes), and the tempo map (`context.warp`).
19
+ */
20
+ import type { PerformedNote } from "../../core/expression.ts";
21
+ import {
22
+ MAX_RESONATOR_TAIL,
23
+ MAX_RESONATOR_VOICES,
24
+ MODAL_LANE_PARAMS,
25
+ modalSettings,
26
+ modalTailSeconds,
27
+ RESONATOR_TABLE_VERSION,
28
+ type ModalSettings,
29
+ } from "../../core/resonators.ts";
30
+ import type { AutomationPoint, Track } from "../../core/score.ts";
31
+ import { noteHz } from "../../core/tuning.ts";
32
+ import { ModalBank, MODAL_BLOCK } from "./dsp/modal.ts";
33
+ import { logCosh } from "./dsp/shape.ts";
34
+ import { interpolateAutomation } from "./effects/common.ts";
35
+ import type { EngineContext, InstrumentEngine } from "./instruments.ts";
36
+ import { warpedSpan } from "./warp.ts";
37
+
38
+ /**
39
+ * Raised-cosine fade applied to a stolen voice, seconds. At 80 ms the
40
+ * largest per-sample gain step is pi/(2*fade samples): -61 dB at 22.05 kHz,
41
+ * -68 dB at 48 kHz, so stealing a long gong or kempul does not click.
42
+ */
43
+ const STEAL_FADE = 0.08;
44
+ /** Extra mallet hardness for accent and marcato. */
45
+ const ACCENT_HARDNESS = 0.2;
46
+ /**
47
+ * Track-level safety knee: exactly linear up to KNEE, then a tanh shoulder
48
+ * that never passes CEILING. Single notes (about 0.8 at velocity 0.8) and
49
+ * dyads pass bit-exact; only dense velocity-1 chords reach the shoulder. A
50
+ * six-note chord at velocity 1 sums to about 4-5 and lands under the
51
+ * ceiling, which the centre pan and track gain keep below 0 dBFS (spec
52
+ * test 13).
53
+ */
54
+ export const MODAL_KNEE = 1;
55
+ const KNEE = MODAL_KNEE;
56
+ const CEILING = 1.38;
57
+ const SPAN = CEILING - KNEE;
58
+
59
+ function knee(x: number): number {
60
+ const a = Math.abs(x);
61
+ if (a <= KNEE) return x;
62
+ return Math.sign(x) * (KNEE + SPAN * Math.tanh((a - KNEE) / SPAN));
63
+ }
64
+
65
+ /** Antiderivative of `knee` (even), for first-order ADAA. */
66
+ function kneeF(x: number): number {
67
+ const a = Math.abs(x);
68
+ if (a <= KNEE) return 0.5 * x * x;
69
+ return (
70
+ 0.5 * KNEE * KNEE +
71
+ KNEE * (a - KNEE) +
72
+ SPAN * SPAN * logCosh((a - KNEE) / SPAN)
73
+ );
74
+ }
75
+
76
+ /**
77
+ * The knee with first-order ADAA only inside the shoulder: while this and
78
+ * the previous sample are both under KNEE the output is the input itself, so
79
+ * the limiter adds no two-tap low-pass or half-sample delay to normal play.
80
+ */
81
+ export function applyModalKnee(dry: Float64Array, samples: number): void {
82
+ let x1 = 0;
83
+ let F1 = 0;
84
+ for (let index = 0; index < samples; index += 1) {
85
+ const x = dry[index]!;
86
+ const Fx = kneeF(x);
87
+ if (Math.abs(x) > KNEE || Math.abs(x1) > KNEE) {
88
+ const dx = x - x1;
89
+ dry[index] = Math.abs(dx) < 1e-5 ? knee(0.5 * (x + x1)) : (Fx - F1) / dx;
90
+ }
91
+ x1 = x;
92
+ F1 = Fx;
93
+ }
94
+ }
95
+
96
+ type Planned = Readonly<{
97
+ note: PerformedNote;
98
+ start: number;
99
+ length: number;
100
+ }>;
101
+
102
+ function span(
103
+ note: PerformedNote,
104
+ context: EngineContext,
105
+ ): { start: number; length: number } {
106
+ if (context.warp)
107
+ return warpedSpan(context.warp, note.startTick, note.durationTicks);
108
+ const perTick =
109
+ (60 * context.sampleRate) / (context.tempoBpm * context.ticksPerBeat);
110
+ return {
111
+ start: Math.max(0, Math.floor(note.startTick * perTick)),
112
+ length: Math.max(1, Math.floor(note.durationTicks * perTick)),
113
+ };
114
+ }
115
+
116
+ function laneOf(
117
+ track: Track,
118
+ param: string,
119
+ ): readonly AutomationPoint[] | undefined {
120
+ const lanes = track.fxAutomation as
121
+ Readonly<Record<string, readonly AutomationPoint[]>> | undefined;
122
+ const points = lanes?.[`modal-${param}`];
123
+ return points && points.length > 0 ? points : undefined;
124
+ }
125
+
126
+ /** Renders a modal track's performed notes into `dry` (mono). */
127
+ export function renderResonatorTrack(
128
+ dry: Float64Array,
129
+ notes: readonly PerformedNote[],
130
+ track: Track,
131
+ context: EngineContext,
132
+ ): void {
133
+ const { sampleRate, samples } = context;
134
+ const samplesPerTick = context.samplesPerTick;
135
+ const tickAt = (index: number): number =>
136
+ context.warp ? context.warp.tick(index) : index / samplesPerTick;
137
+ const planned: Planned[] = notes
138
+ .map((note) => ({ note, ...span(note, context) }))
139
+ .filter((entry) => entry.start < samples)
140
+ .sort(
141
+ (a, b) =>
142
+ a.start - b.start ||
143
+ a.note.pitch - b.note.pitch ||
144
+ (a.note.id < b.note.id ? -1 : a.note.id > b.note.id ? 1 : 0),
145
+ );
146
+ const lanes = new Map<string, readonly AutomationPoint[]>();
147
+ for (const { param } of MODAL_LANE_PARAMS) {
148
+ const points = laneOf(track, param);
149
+ if (points) lanes.set(param, points);
150
+ }
151
+ const motorLane = lanes.get("motordepth");
152
+ const volumeLane = track.volumeAutomation ?? [];
153
+ const volume = Math.max(0, Math.min(1, track.volume ?? 1));
154
+ const gainAt = (index: number): number =>
155
+ volume *
156
+ (volumeLane.length > 0
157
+ ? interpolateAutomation(volumeLane, tickAt(index), 1)
158
+ : 1);
159
+ const seedTick = context.seedTick ?? 0;
160
+ const base = modalSettings(track.modal);
161
+ const banks: ModalBank[] = [];
162
+ // Conservative end of each planned voice (its ring cap, or a steal).
163
+ const ends: number[] = [];
164
+ const steals: number[] = [];
165
+ const sounding: number[] = [];
166
+ for (let i = 0; i < planned.length; i += 1) {
167
+ const { note, start, length } = planned[i]!;
168
+ const onsetTick = note.startTick;
169
+ const settings =
170
+ lanes.size === 0
171
+ ? base
172
+ : clampLanes(
173
+ modalSettings(track.modal, (param) => {
174
+ const points = lanes.get(param);
175
+ return points
176
+ ? interpolateAutomation(
177
+ points,
178
+ onsetTick,
179
+ base[param as keyof typeof base] as number,
180
+ )
181
+ : undefined;
182
+ }),
183
+ );
184
+ const performance = note.performance;
185
+ const accent =
186
+ performance?.accent || note.articulation === "marcato"
187
+ ? ACCENT_HARDNESS
188
+ : 0;
189
+ const bank = new ModalBank(
190
+ settings,
191
+ {
192
+ hz: noteHz(note.pitch, note.cents, context.tuning),
193
+ velocity: note.velocity,
194
+ start: start / sampleRate,
195
+ duration: length / sampleRate,
196
+ seed: `${note.id}:${note.startTick + seedTick}:modal`,
197
+ ...(accent ? { accent } : {}),
198
+ ...(performance?.cents ? { cents: performance.cents } : {}),
199
+ ...(motorLane
200
+ ? {
201
+ motordepth: (t: number) =>
202
+ interpolateAutomation(
203
+ motorLane,
204
+ tickAt(start + t * sampleRate),
205
+ settings.motordepth,
206
+ ),
207
+ }
208
+ : {}),
209
+ },
210
+ sampleRate,
211
+ MAX_RESONATOR_TAIL,
212
+ );
213
+ banks.push(bank);
214
+ ends.push(start + bank.endAt);
215
+ steals.push(Infinity);
216
+ // Voice stealing in onset order: drop finished voices, then steal the
217
+ // oldest onset while the pool is full.
218
+ for (let k = sounding.length - 1; k >= 0; k -= 1)
219
+ if (ends[sounding[k]!]! <= start) sounding.splice(k, 1);
220
+ while (sounding.length >= MAX_RESONATOR_VOICES) {
221
+ const oldest = sounding.shift()!;
222
+ steals[oldest] = start;
223
+ ends[oldest] = Math.min(
224
+ ends[oldest]!,
225
+ start + Math.round(STEAL_FADE * sampleRate),
226
+ );
227
+ }
228
+ sounding.push(i);
229
+ }
230
+ const scratch = new Float64Array(MODAL_BLOCK);
231
+ const fade = Math.max(1, Math.round(STEAL_FADE * sampleRate));
232
+ for (let i = 0; i < planned.length; i += 1) {
233
+ const { note, start } = planned[i]!;
234
+ const bank = banks[i]!;
235
+ const stop = Math.min(samples, ends[i]!);
236
+ const steal = steals[i]!;
237
+ const damp = note.performance?.damp;
238
+ const dampFrom = damp ? start + Math.round(damp.from * sampleRate) : 0;
239
+ let at = start;
240
+ while (at < stop) {
241
+ const count = Math.min(MODAL_BLOCK, stop - at);
242
+ scratch.fill(0, 0, count);
243
+ const alive = bank.process(scratch, 0, count);
244
+ const gain = gainAt(at);
245
+ for (let j = 0; j < count; j += 1) {
246
+ const index = at + j;
247
+ let level = gain;
248
+ if (index >= steal)
249
+ level *=
250
+ index - steal >= fade
251
+ ? 0
252
+ : 0.5 + 0.5 * Math.cos((Math.PI * (index - steal)) / fade);
253
+ if (damp && index > dampFrom)
254
+ level *= Math.exp(-(index - dampFrom) / (damp.tau * sampleRate));
255
+ dry[index]! += scratch[j]! * level;
256
+ }
257
+ if (!alive) break;
258
+ at += count;
259
+ }
260
+ }
261
+ // Causal and stateless across renders, so the output stays prefix-stable.
262
+ applyModalKnee(dry, samples);
263
+ }
264
+
265
+ /** Lane values land inside each parameter's range. */
266
+ function clampLanes(settings: ModalSettings): ModalSettings {
267
+ const out: Record<string, number | string> = { ...settings };
268
+ for (const { param, spec } of MODAL_LANE_PARAMS) {
269
+ const value = out[param];
270
+ if (typeof value === "number")
271
+ out[param] = Math.max(spec.min, Math.min(spec.max, value));
272
+ }
273
+ return Object.freeze(out) as ModalSettings;
274
+ }
275
+
276
+ /** The modal engine as registered in `src/audio/instruments.ts`. */
277
+ export const MODAL_ENGINE: InstrumentEngine = Object.freeze({
278
+ id: "modal",
279
+ field: "modal",
280
+ render(dry, _dryR, notes, track, context) {
281
+ renderResonatorTrack(dry, notes, track, context);
282
+ },
283
+ tailSeconds: (track: Track, lowestPitch?: number) =>
284
+ modalTailSeconds(track.modal, lowestPitch),
285
+ stereo: () => false,
286
+ assetDigests: () => [`resonators:${RESONATOR_TABLE_VERSION}`],
287
+ });