@hraness/dawg 0.4.1 → 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.
- package/CHANGELOG.md +70 -0
- package/DAWG.md +293 -31
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -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 +27 -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 +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -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/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -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/effects/bus.ts +5 -4
- 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/space.ts +92 -2
- package/src/audio/engine.ts +90 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -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 +74 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +301 -26
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +709 -33
- package/src/render.ts +109 -7
- 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/menu-time.ts +401 -0
- package/src/tui/menu.ts +635 -14
- 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 +414 -41
- 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
|
}
|
|
@@ -277,9 +307,8 @@ export function previewScore(
|
|
|
277
307
|
Math.min(region.bars, score.bars - Math.max(0, region.startBar)),
|
|
278
308
|
);
|
|
279
309
|
const startBar = Math.max(0, Math.min(region.startBar, score.bars - 1));
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
const end = start + bars * barTicks;
|
|
310
|
+
const start = barStartTick(score, startBar);
|
|
311
|
+
const end = barStartTick(score, startBar + bars);
|
|
283
312
|
const context = options.context === true;
|
|
284
313
|
const anySolo = score.tracks.some((candidate) => candidate.solo === true);
|
|
285
314
|
const tracks = score.tracks
|
|
@@ -313,14 +342,23 @@ export function previewScore(
|
|
|
313
342
|
role = phraseRole(track);
|
|
314
343
|
notes.push(...defaultPhrase(score, track, bars, role));
|
|
315
344
|
}
|
|
345
|
+
// The song's tempo map and meter, rebased to the region's first bar.
|
|
346
|
+
const timing = songTimeSlice(score, startBar, bars);
|
|
316
347
|
const preview = new TrackScore({
|
|
317
|
-
tempoBpm:
|
|
348
|
+
tempoBpm: timing.tempoBpm,
|
|
349
|
+
...(timing.time ? { time: timing.time } : {}),
|
|
318
350
|
beatsPerBar: score.beatsPerBar,
|
|
319
351
|
bars,
|
|
320
352
|
ticksPerBeat: score.ticksPerBeat,
|
|
321
353
|
key: score.key,
|
|
322
354
|
tracks,
|
|
323
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
|
+
: {}),
|
|
324
362
|
});
|
|
325
363
|
return {
|
|
326
364
|
score: preview,
|
|
@@ -330,6 +368,28 @@ export function previewScore(
|
|
|
330
368
|
};
|
|
331
369
|
}
|
|
332
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
|
+
|
|
333
393
|
// ── analysis ──────────────────────────────────────────────────────────
|
|
334
394
|
|
|
335
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
|
|
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
|
});
|
package/src/audio/sampler.ts
CHANGED
|
@@ -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)
|
|
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
|
-
/**
|
|
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
|
-
|
|
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(
|
|
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
|
|
178
|
-
if (!
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|