@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.
- package/CHANGELOG.md +99 -0
- package/DAWG.md +551 -33
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +41 -12
- package/core/expression.ts +1241 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/resonators.ts +574 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +717 -62
- package/core/sdk/eval-child.ts +40 -3
- package/core/sdk/print.ts +614 -27
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2824 -45
- package/core/sections.ts +2072 -0
- package/core/strings.ts +845 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +29 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +29 -0
- package/guides/tempo.md +27 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +9 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +68 -6
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +27 -4
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +574 -16
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +9 -0
- package/src/audio/arrange.ts +489 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +6 -1
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +100 -21
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +263 -17
- package/src/audio/loudness.ts +596 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +76 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +269 -34
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +147 -0
- package/src/audio/wav.ts +377 -30
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +941 -0
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +355 -27
- package/src/commands/keys.ts +353 -0
- package/src/commands/master.ts +361 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +967 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +919 -41
- package/src/project/check.ts +13 -0
- package/src/render.ts +139 -8
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +967 -23
- package/src/tui/modal-menu.ts +118 -0
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +459 -43
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +63 -1
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- 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
|
+
}
|
package/src/audio/player.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
122
|
-
const
|
|
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(
|
|
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(
|
package/src/audio/preview.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
78
|
-
const last = score.bars - bars;
|
|
96
|
+
const last = hi - bars;
|
|
79
97
|
const clampBar = (bar: number) =>
|
|
80
|
-
Math.max(
|
|
81
|
-
|
|
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
|
|
87
|
-
note.startTick < (from + bars)
|
|
109
|
+
note.startTick >= barStartTick(score, from) &&
|
|
110
|
+
note.startTick < barStartTick(score, from + bars),
|
|
88
111
|
);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
281
|
-
const
|
|
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:
|
|
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
|
|
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) {
|
package/src/audio/renderer.ts
CHANGED
|
@@ -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
|
|
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
|
+
});
|