@hraness/dawg 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/DAWG.md +300 -35
- 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/chain.ts +12 -2
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/convolution.ts +43 -5
- 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 +197 -50
- package/src/audio/engine.ts +175 -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 +87 -16
- 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 +448 -59
- 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 +826 -47
- 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 +58 -5
- package/src/tui/euclid.ts +50 -6
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +656 -16
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +95 -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 +79 -12
- 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,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arranged rendering: what playback and export hear when the song has
|
|
3
|
+
* section mutes or variations, a form, or a looped section. A song with
|
|
4
|
+
* none of those takes exactly today's render call, so 0.4 projects render
|
|
5
|
+
* byte-identically.
|
|
6
|
+
*
|
|
7
|
+
* Long arrangements render in bar-aligned windows, each with a pre-roll
|
|
8
|
+
* that carries held notes and tails across the seam, so memory stays
|
|
9
|
+
* bounded however long the form plays (up to MAX_SONG_SECONDS).
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
SCORE_LIMITS,
|
|
13
|
+
ScoreValidationError,
|
|
14
|
+
TrackScore,
|
|
15
|
+
type Note,
|
|
16
|
+
} from "../../core/score.ts";
|
|
17
|
+
import {
|
|
18
|
+
MAX_SONG_SECONDS,
|
|
19
|
+
applySectionChanges,
|
|
20
|
+
arrangedBars,
|
|
21
|
+
arrangedNotes,
|
|
22
|
+
arrangedSlice,
|
|
23
|
+
bakeTrackTime,
|
|
24
|
+
barBeats,
|
|
25
|
+
barTicks,
|
|
26
|
+
barsToSeconds,
|
|
27
|
+
findSection,
|
|
28
|
+
flattenForm,
|
|
29
|
+
formPositionAt,
|
|
30
|
+
formSegments,
|
|
31
|
+
sectionScore,
|
|
32
|
+
sliceSongTime,
|
|
33
|
+
} from "../../core/sections.ts";
|
|
34
|
+
import {
|
|
35
|
+
barStartTick,
|
|
36
|
+
bpmAtTick,
|
|
37
|
+
loopSecondsOf,
|
|
38
|
+
loopTicksOf,
|
|
39
|
+
secondsAtTick,
|
|
40
|
+
type TimeScore,
|
|
41
|
+
} from "../../core/tempo.ts";
|
|
42
|
+
import {
|
|
43
|
+
RENDER_CHANNELS,
|
|
44
|
+
StemRenderer,
|
|
45
|
+
clampSampleRate,
|
|
46
|
+
encodeWav,
|
|
47
|
+
masterSummedPcm,
|
|
48
|
+
type RenderOptions,
|
|
49
|
+
type RenderedAudio,
|
|
50
|
+
type WavCue,
|
|
51
|
+
} from "./wav.ts";
|
|
52
|
+
|
|
53
|
+
/** What one default render pass holds before the renderer cuts it. */
|
|
54
|
+
const DEFAULT_PASS_SECONDS = 30;
|
|
55
|
+
/** Arrangements up to this long render in a single pass. */
|
|
56
|
+
const SINGLE_PASS_SECONDS = 45;
|
|
57
|
+
/** Bars per window aim for this many seconds. */
|
|
58
|
+
const WINDOW_SECONDS = 20;
|
|
59
|
+
/** Tail (release, delay, reverb, ducking) a window's pre-roll covers. */
|
|
60
|
+
const OVERHANG_SECONDS = 8;
|
|
61
|
+
/** Crossfade where a window restarts a note held from beyond its reach. */
|
|
62
|
+
const SEAM_FADE_SECONDS = 0.05;
|
|
63
|
+
/** Most seconds a window renders, pre-roll included (the renderer caps 60). */
|
|
64
|
+
const WINDOW_BUDGET_SECONDS = 50;
|
|
65
|
+
|
|
66
|
+
/** The score as export hears it: the form baked in, section changes applied. */
|
|
67
|
+
export function exportScore(score: TrackScore): TrackScore {
|
|
68
|
+
// A form past the score's bar limit throws here; renderArranged renders
|
|
69
|
+
// such forms window by window instead, so it never needs this.
|
|
70
|
+
return flattenForm(bakeTrackTime(score));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Section starts as WAV cues, in playback order (the form's passes when it
|
|
75
|
+
* has one). Empty without sections, or when the form is too long to flatten.
|
|
76
|
+
*/
|
|
77
|
+
export function sectionCues(
|
|
78
|
+
score: TrackScore,
|
|
79
|
+
sampleRate: number,
|
|
80
|
+
): readonly WavCue[] {
|
|
81
|
+
if (score.sections.length === 0) return [];
|
|
82
|
+
let flat: TrackScore;
|
|
83
|
+
try {
|
|
84
|
+
flat = exportScore(score);
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
const end = loopTicksOf(flat);
|
|
89
|
+
return flat.sections
|
|
90
|
+
.map((section) => ({
|
|
91
|
+
tick: barStartTick(flat, section.startBar),
|
|
92
|
+
label: section.name,
|
|
93
|
+
}))
|
|
94
|
+
.filter((cue) => cue.tick < end)
|
|
95
|
+
.map((cue) => ({
|
|
96
|
+
frame: Math.round(secondsAtTick(flat, cue.tick) * sampleRate),
|
|
97
|
+
label: cue.label,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The section playback loops, when `loopSection` names one. */
|
|
102
|
+
export function loopedSection(score: TrackScore) {
|
|
103
|
+
return score.loopSection === undefined
|
|
104
|
+
? undefined
|
|
105
|
+
: findSection(score, score.loopSection);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The score as playback loops it: the looped section, else the export. */
|
|
109
|
+
export function playbackScore(score: TrackScore): TrackScore {
|
|
110
|
+
const section = loopedSection(score);
|
|
111
|
+
return section
|
|
112
|
+
? sectionScore(bakeTrackTime(score), section)
|
|
113
|
+
: exportScore(score);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The timeline the transport runs on: the looped section, the whole form
|
|
118
|
+
* (which may be longer than a score allows), else the song. Its tempo map
|
|
119
|
+
* drives the transport clock, so beats and seconds agree with what plays.
|
|
120
|
+
*/
|
|
121
|
+
export function playbackTime(score: TrackScore): TimeScore {
|
|
122
|
+
const section = loopedSection(score);
|
|
123
|
+
if (section) return sectionScore(score, section);
|
|
124
|
+
const segments = formSegments(score);
|
|
125
|
+
if (segments.length === 0) return score;
|
|
126
|
+
const ticks = barTicks(score);
|
|
127
|
+
const timed = sliceSongTime(
|
|
128
|
+
score,
|
|
129
|
+
segments.map((segment) => ({
|
|
130
|
+
from: segment.section.startBar * ticks,
|
|
131
|
+
to: (segment.section.startBar + segment.bars) * ticks,
|
|
132
|
+
offset: segment.startBar * ticks,
|
|
133
|
+
})),
|
|
134
|
+
);
|
|
135
|
+
return {
|
|
136
|
+
tempoBpm: timed.tempoBpm,
|
|
137
|
+
beatsPerBar: score.beatsPerBar,
|
|
138
|
+
bars: arrangedBars(score),
|
|
139
|
+
ticksPerBeat: score.ticksPerBeat,
|
|
140
|
+
...(timed.time ? { time: timed.time } : {}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The score beat the highway shows for transport `beat`: inside the looped
|
|
146
|
+
* section, or the matching beat of the form pass that is playing. Songs
|
|
147
|
+
* without a form or section loop get `beat` back unchanged.
|
|
148
|
+
*/
|
|
149
|
+
export function scoreBeatAt(score: TrackScore, beat: number): number {
|
|
150
|
+
const section = loopedSection(score);
|
|
151
|
+
const perBar = barBeats(score);
|
|
152
|
+
if (section) {
|
|
153
|
+
const length = section.bars * perBar;
|
|
154
|
+
const phase = Number.isFinite(beat)
|
|
155
|
+
? ((beat % length) + length) % length
|
|
156
|
+
: 0;
|
|
157
|
+
return section.startBar * perBar + phase;
|
|
158
|
+
}
|
|
159
|
+
if (score.form.length === 0) return beat;
|
|
160
|
+
return formPositionAt(score, beat).scoreBeat;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Render `score` as it is arranged. `loop` renders what playback loops (the
|
|
165
|
+
* looped section, else the form); one-shot renders what export writes (the
|
|
166
|
+
* form; the section loop is ignored).
|
|
167
|
+
*/
|
|
168
|
+
export function renderArranged(
|
|
169
|
+
renderer: StemRenderer,
|
|
170
|
+
score: TrackScore,
|
|
171
|
+
options: RenderOptions = {},
|
|
172
|
+
): RenderedAudio {
|
|
173
|
+
if (
|
|
174
|
+
!(options.loop && loopedSection(score)) &&
|
|
175
|
+
formSegments(score).length === 0 &&
|
|
176
|
+
applySectionChanges(score) === score &&
|
|
177
|
+
fitsOnePass(renderer, score, options)
|
|
178
|
+
)
|
|
179
|
+
return renderer.render(score, options);
|
|
180
|
+
score = bakeTrackTime(score);
|
|
181
|
+
const section = options.loop ? loopedSection(score) : undefined;
|
|
182
|
+
if (!section && formSegments(score).length > 0)
|
|
183
|
+
return renderForm(renderer, score, options);
|
|
184
|
+
const played = section
|
|
185
|
+
? sectionScore(score, section)
|
|
186
|
+
: applySectionChanges(score);
|
|
187
|
+
if (played === score && fitsOnePass(renderer, score, options))
|
|
188
|
+
return renderer.render(score, options);
|
|
189
|
+
const ticks = barTicks(played);
|
|
190
|
+
const seconds = secondsAtTick(played, played.bars * ticks);
|
|
191
|
+
if (seconds > MAX_SONG_SECONDS)
|
|
192
|
+
throw new ScoreValidationError(
|
|
193
|
+
`the song plays ${Math.round(seconds)} s; the limit is ${MAX_SONG_SECONDS} s`,
|
|
194
|
+
"score-limit",
|
|
195
|
+
);
|
|
196
|
+
if (seconds <= SINGLE_PASS_SECONDS)
|
|
197
|
+
return renderer.render(played, { ...options, maxSeconds: 60 });
|
|
198
|
+
return renderWindows(
|
|
199
|
+
renderer,
|
|
200
|
+
played,
|
|
201
|
+
played,
|
|
202
|
+
played.bars,
|
|
203
|
+
(from, to) =>
|
|
204
|
+
played.notes.filter(
|
|
205
|
+
(note) => note.startTick >= from * ticks && note.startTick < to * ticks,
|
|
206
|
+
),
|
|
207
|
+
(from, bars) =>
|
|
208
|
+
sectionScore(played, { name: "window", startBar: from, bars }),
|
|
209
|
+
options,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Whether a single default pass (capped at 30 s) holds all of `score`: the
|
|
215
|
+
* loop for playback, the song plus its tail for export. A caller's own
|
|
216
|
+
* `maxSeconds` is a deliberate cap and always takes the single pass. Longer
|
|
217
|
+
* songs render in windows instead of being cut at 30 s.
|
|
218
|
+
*/
|
|
219
|
+
function fitsOnePass(
|
|
220
|
+
renderer: StemRenderer,
|
|
221
|
+
score: TrackScore,
|
|
222
|
+
options: RenderOptions,
|
|
223
|
+
): boolean {
|
|
224
|
+
if (options.maxSeconds !== undefined) return true;
|
|
225
|
+
return options.loop
|
|
226
|
+
? loopSecondsOf(score) <= DEFAULT_PASS_SECONDS
|
|
227
|
+
: renderer.oneShotSeconds(score, options) <= DEFAULT_PASS_SECONDS;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A form renders window by window straight from its sections, never as one
|
|
232
|
+
* baked score, so a form may run past the score's bar and note limits (up to
|
|
233
|
+
* MAX_SONG_SECONDS).
|
|
234
|
+
*/
|
|
235
|
+
function renderForm(
|
|
236
|
+
renderer: StemRenderer,
|
|
237
|
+
score: TrackScore,
|
|
238
|
+
options: RenderOptions,
|
|
239
|
+
): RenderedAudio {
|
|
240
|
+
const total = arrangedBars(score);
|
|
241
|
+
const timeline = playbackTime(score);
|
|
242
|
+
const seconds = secondsAtTick(timeline, total * barTicks(score));
|
|
243
|
+
if (seconds > MAX_SONG_SECONDS)
|
|
244
|
+
throw new ScoreValidationError(
|
|
245
|
+
`the form plays ${Math.round(seconds)} s; the limit is ${MAX_SONG_SECONDS} s`,
|
|
246
|
+
"score-limit",
|
|
247
|
+
);
|
|
248
|
+
return renderWindows(
|
|
249
|
+
renderer,
|
|
250
|
+
score,
|
|
251
|
+
timeline,
|
|
252
|
+
total,
|
|
253
|
+
(from, to) => arrangedNotes(score, from, to),
|
|
254
|
+
(from, bars) => arrangedSlice(score, from, bars, 0),
|
|
255
|
+
options,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** `renderScorePcm` for arranged songs. */
|
|
260
|
+
export function renderArrangedPcm(
|
|
261
|
+
score: TrackScore,
|
|
262
|
+
options: RenderOptions = {},
|
|
263
|
+
): RenderedAudio {
|
|
264
|
+
return renderArranged(new StemRenderer({ maxCacheBytes: 0 }), score, options);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** `renderScoreWav` for arranged songs: what `dawg render` writes. */
|
|
268
|
+
export function renderArrangedWav(
|
|
269
|
+
score: TrackScore,
|
|
270
|
+
options: Omit<RenderOptions, "loop"> = {},
|
|
271
|
+
): Uint8Array {
|
|
272
|
+
const audio = renderArrangedPcm(score, { ...options, loop: false });
|
|
273
|
+
return encodeWav(audio.pcm, audio.sampleRate, RENDER_CHANNELS);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Render a long arrangement as consecutive windows that each own a run of
|
|
278
|
+
* output frames. A window starts rendering early enough (its pre-roll) to
|
|
279
|
+
* include every note still sounding or ringing where its frames begin, so
|
|
280
|
+
* held notes, release and effect tails, sidechain ducking and seeded noise
|
|
281
|
+
* continue across the seams as in one pass; the pre-roll's own frames are
|
|
282
|
+
* dropped. A note held longer than a window can reach back (about
|
|
283
|
+
* WINDOW_BUDGET_SECONDS) restarts at the window's pre-roll instead of going
|
|
284
|
+
* silent.
|
|
285
|
+
*
|
|
286
|
+
* `notesIn(from, to)` lists the notes starting in bars `from..to` at
|
|
287
|
+
* absolute ticks, in render order; `frame(from, bars)` is the window's
|
|
288
|
+
* score (tracks, automation, tempo) without notes. `timeline` places bars
|
|
289
|
+
* in seconds. The song master runs once over the joined mix, as in one pass.
|
|
290
|
+
*/
|
|
291
|
+
function renderWindows(
|
|
292
|
+
renderer: StemRenderer,
|
|
293
|
+
score: TrackScore,
|
|
294
|
+
timeline: TimeScore,
|
|
295
|
+
totalBars: number,
|
|
296
|
+
notesIn: (from: number, to: number) => Note[],
|
|
297
|
+
frame: (from: number, bars: number) => TrackScore,
|
|
298
|
+
options: RenderOptions,
|
|
299
|
+
): RenderedAudio {
|
|
300
|
+
const sampleRate = clampSampleRate(options.sampleRate);
|
|
301
|
+
const ticks = barTicks(score);
|
|
302
|
+
// A tempo map changes how long bars are: ring and reach plan with the
|
|
303
|
+
// fastest bars, window lengths with the slowest (renders stay <= 60 s).
|
|
304
|
+
// Without one both are the song tempo, as before.
|
|
305
|
+
const bpms = [
|
|
306
|
+
score.tempoBpm,
|
|
307
|
+
...(timeline.time?.tempo ?? []).map((e) => e.bpm),
|
|
308
|
+
];
|
|
309
|
+
const fermataStretch = timeline.time?.fermatas ? 2 : 1;
|
|
310
|
+
const beatsPerBar = score.beatsPerBar;
|
|
311
|
+
const fastBar = (beatsPerBar * 60) / Math.max(...bpms);
|
|
312
|
+
const slowBar = ((beatsPerBar * 60) / Math.min(...bpms)) * fermataStretch;
|
|
313
|
+
const frameAt = (bar: number) =>
|
|
314
|
+
Math.round(secondsAtTick(timeline, bar * ticks) * sampleRate);
|
|
315
|
+
const ring = Math.max(1, Math.ceil(OVERHANG_SECONDS / fastBar));
|
|
316
|
+
const ringTicks = Math.ceil((OVERHANG_SECONDS / fastBar) * ticks);
|
|
317
|
+
const budget = Math.max(1, Math.floor(WINDOW_BUDGET_SECONDS / slowBar));
|
|
318
|
+
const reach = budget - 1;
|
|
319
|
+
const most = Math.max(1, Math.floor(WINDOW_SECONDS / slowBar));
|
|
320
|
+
const master = score.master;
|
|
321
|
+
const look = SCORE_LIMITS.maxBars;
|
|
322
|
+
const parts: {
|
|
323
|
+
offset: number;
|
|
324
|
+
pcm: Int16Array;
|
|
325
|
+
frames: number;
|
|
326
|
+
/** Frames at the start that fade in over the previous window's end. */
|
|
327
|
+
fadeIn: number;
|
|
328
|
+
/** Frames at the end that fade out under the next window. */
|
|
329
|
+
fadeOut: number;
|
|
330
|
+
}[] = [];
|
|
331
|
+
for (let start = 0; start < totalBars;) {
|
|
332
|
+
const startTick = start * ticks;
|
|
333
|
+
// Every note up to the next window, in render order: tracks sum in the
|
|
334
|
+
// order of their first note, as in a single pass.
|
|
335
|
+
const all = notesIn(Math.max(0, start - look), start + most + 1);
|
|
336
|
+
let pre = Math.max(0, start - Math.min(ring, reach));
|
|
337
|
+
// A note the pre-roll can reach pulls it back to its start; one held
|
|
338
|
+
// from further back restarts in the pre-roll instead (below), so long
|
|
339
|
+
// drones keep full-size windows and render time stays linear.
|
|
340
|
+
for (const note of all)
|
|
341
|
+
if (
|
|
342
|
+
note.startTick < startTick &&
|
|
343
|
+
note.startTick + note.durationTicks + ringTicks > startTick &&
|
|
344
|
+
Math.floor(note.startTick / ticks) >= start - reach
|
|
345
|
+
)
|
|
346
|
+
pre = Math.min(pre, Math.floor(note.startTick / ticks));
|
|
347
|
+
pre = Math.max(pre, start - reach);
|
|
348
|
+
const end = Math.min(
|
|
349
|
+
totalBars,
|
|
350
|
+
start + most,
|
|
351
|
+
Math.max(start + 1, pre + budget),
|
|
352
|
+
);
|
|
353
|
+
const last = end >= totalBars;
|
|
354
|
+
// One bar of the next window's notes, for anything that sounds early.
|
|
355
|
+
const noteTo = last ? end : Math.min(totalBars, end + 1);
|
|
356
|
+
const preTick = pre * ticks;
|
|
357
|
+
const notes: Note[] = [];
|
|
358
|
+
let restarted = false;
|
|
359
|
+
for (const note of all) {
|
|
360
|
+
if (note.startTick >= noteTo * ticks) continue;
|
|
361
|
+
const stop = note.startTick + note.durationTicks;
|
|
362
|
+
if (note.startTick >= preTick)
|
|
363
|
+
notes.push({ ...note, startTick: note.startTick - preTick });
|
|
364
|
+
// Held from before the pre-roll could reach: restart it there.
|
|
365
|
+
else if (stop > preTick && stop + ringTicks > startTick) {
|
|
366
|
+
notes.push({ ...note, startTick: 0, durationTicks: stop - preTick });
|
|
367
|
+
restarted = true;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Export ends like a one-shot render; a loop rings out to fold back.
|
|
371
|
+
const bars =
|
|
372
|
+
(last ? end : noteTo) - pre + (last && options.loop ? ring : 0);
|
|
373
|
+
const window = new TrackScore({
|
|
374
|
+
...frame(pre, bars).toJSON(),
|
|
375
|
+
notes,
|
|
376
|
+
master: null,
|
|
377
|
+
});
|
|
378
|
+
const audio = renderer.render(window, {
|
|
379
|
+
...options,
|
|
380
|
+
sampleRate,
|
|
381
|
+
loop: false,
|
|
382
|
+
seedTick: preTick,
|
|
383
|
+
// Past `end` too, so voices that fade where the buffer stops (the
|
|
384
|
+
// legacy tone voice) fade in frames this window drops.
|
|
385
|
+
maxSeconds: last
|
|
386
|
+
? 60
|
|
387
|
+
: Math.min(
|
|
388
|
+
60,
|
|
389
|
+
secondsAtTick(timeline, noteTo * ticks) -
|
|
390
|
+
secondsAtTick(timeline, pre * ticks),
|
|
391
|
+
),
|
|
392
|
+
});
|
|
393
|
+
// A restarted note's phase differs from the previous window's, so the
|
|
394
|
+
// seam crossfades over the previous window's last frames instead of
|
|
395
|
+
// stepping; seams without a restart join sample for sample.
|
|
396
|
+
const fade =
|
|
397
|
+
restarted && parts.length > 0
|
|
398
|
+
? Math.max(
|
|
399
|
+
0,
|
|
400
|
+
Math.min(
|
|
401
|
+
Math.round(SEAM_FADE_SECONDS * sampleRate),
|
|
402
|
+
frameAt(start) - frameAt(pre),
|
|
403
|
+
parts.at(-1)!.frames,
|
|
404
|
+
),
|
|
405
|
+
)
|
|
406
|
+
: 0;
|
|
407
|
+
const from = frameAt(start) - frameAt(pre) - fade;
|
|
408
|
+
const to = last
|
|
409
|
+
? audio.frames
|
|
410
|
+
: Math.min(audio.frames, frameAt(end) - frameAt(pre));
|
|
411
|
+
if (fade > 0) parts.at(-1)!.fadeOut = fade;
|
|
412
|
+
parts.push({
|
|
413
|
+
offset: frameAt(start) - fade,
|
|
414
|
+
pcm: audio.pcm.subarray(from * RENDER_CHANNELS, to * RENDER_CHANNELS),
|
|
415
|
+
frames: Math.max(0, to - from),
|
|
416
|
+
fadeIn: fade,
|
|
417
|
+
fadeOut: 0,
|
|
418
|
+
});
|
|
419
|
+
start = end;
|
|
420
|
+
}
|
|
421
|
+
const loopLength = Math.max(1, frameAt(totalBars));
|
|
422
|
+
let length = Math.max(...parts.map((part) => part.offset + part.frames));
|
|
423
|
+
const mix = new Int32Array(length * RENDER_CHANNELS);
|
|
424
|
+
for (const { offset, pcm, frames, fadeIn, fadeOut } of parts) {
|
|
425
|
+
const base = offset * RENDER_CHANNELS;
|
|
426
|
+
for (let frame = 0; frame < frames; frame += 1) {
|
|
427
|
+
// Linear fades: everything both windows share sums back to itself;
|
|
428
|
+
// only the restarted note's phase step blends over the fade.
|
|
429
|
+
const gain =
|
|
430
|
+
frame < fadeIn
|
|
431
|
+
? (frame + 0.5) / fadeIn
|
|
432
|
+
: frame >= frames - fadeOut
|
|
433
|
+
? 1 - (frame - (frames - fadeOut) + 0.5) / fadeOut
|
|
434
|
+
: 1;
|
|
435
|
+
for (let channel = 0; channel < RENDER_CHANNELS; channel += 1) {
|
|
436
|
+
const index = frame * RENDER_CHANNELS + channel;
|
|
437
|
+
mix[base + index]! +=
|
|
438
|
+
gain === 1 ? pcm[index]! : Math.round(pcm[index]! * gain);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (options.loop) {
|
|
443
|
+
// Fold the tail past the loop end back onto its start.
|
|
444
|
+
for (
|
|
445
|
+
let index = loopLength * RENDER_CHANNELS;
|
|
446
|
+
index < mix.length;
|
|
447
|
+
index += 1
|
|
448
|
+
)
|
|
449
|
+
mix[index % (loopLength * RENDER_CHANNELS)]! += mix[index]!;
|
|
450
|
+
length = loopLength;
|
|
451
|
+
}
|
|
452
|
+
const pcm = new Int16Array(length * RENDER_CHANNELS);
|
|
453
|
+
for (let index = 0; index < pcm.length; index += 1)
|
|
454
|
+
pcm[index] = Math.max(-32_768, Math.min(32_767, mix[index]!));
|
|
455
|
+
const mastered = masterSummedPcm(
|
|
456
|
+
pcm,
|
|
457
|
+
length,
|
|
458
|
+
sampleRate,
|
|
459
|
+
master,
|
|
460
|
+
options.loop === true,
|
|
461
|
+
);
|
|
462
|
+
if (mastered)
|
|
463
|
+
return {
|
|
464
|
+
sampleRate,
|
|
465
|
+
channels: RENDER_CHANNELS,
|
|
466
|
+
frames: length,
|
|
467
|
+
pcm: mastered.pcm,
|
|
468
|
+
master: mastered.report,
|
|
469
|
+
};
|
|
470
|
+
return { sampleRate, channels: RENDER_CHANNELS, frames: length, pcm };
|
|
471
|
+
}
|
package/src/audio/audition.ts
CHANGED
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
* every change, and the pattern picker previews a pattern before it lands.
|
|
6
6
|
*
|
|
7
7
|
* Like `live.ts` this only builds a smaller score; the renderer is shared,
|
|
8
|
-
* so an audition sounds exactly like the loop
|
|
8
|
+
* so an audition sounds exactly like the track does in the loop before the
|
|
9
|
+
* song master. Auditions and live notes are pre-master on purpose: the
|
|
10
|
+
* master's glue, limiter and loudness target act on the whole mix, and
|
|
11
|
+
* running them over one voice would pump and boost it by a different amount
|
|
12
|
+
* than the loop. The staged A/B loop (`src/tui/audition.ts`) is the place to
|
|
13
|
+
* hear master changes; it plays the full mix while one is staged.
|
|
9
14
|
*/
|
|
10
15
|
import {
|
|
11
16
|
SCORE_LIMITS,
|
|
@@ -13,6 +18,11 @@ import {
|
|
|
13
18
|
type Note,
|
|
14
19
|
type Track,
|
|
15
20
|
} from "../../core/score.ts";
|
|
21
|
+
import {
|
|
22
|
+
barStartTick,
|
|
23
|
+
meterSegments,
|
|
24
|
+
secondsAtTick,
|
|
25
|
+
} from "../../core/tempo.ts";
|
|
16
26
|
import type { LiveNotePcm } from "./live.ts";
|
|
17
27
|
import type { SampleBank } from "./samples.ts";
|
|
18
28
|
import { RENDER_CHANNELS, renderScorePcm } from "./wav.ts";
|
|
@@ -39,7 +49,8 @@ export function renderAudition(
|
|
|
39
49
|
(candidate) => candidate.id === request.trackId,
|
|
40
50
|
);
|
|
41
51
|
if (!track) return undefined;
|
|
42
|
-
const
|
|
52
|
+
const opening = meterSegments(score)[0]!;
|
|
53
|
+
const secondsPerBar = secondsAtTick(score, opening.barTicks);
|
|
43
54
|
const bars = Math.max(
|
|
44
55
|
1,
|
|
45
56
|
Math.min(
|
|
@@ -48,7 +59,7 @@ export function renderAudition(
|
|
|
48
59
|
Math.floor(MAX_AUDITION_SECONDS / secondsPerBar) || 1,
|
|
49
60
|
),
|
|
50
61
|
);
|
|
51
|
-
const endTick =
|
|
62
|
+
const endTick = barStartTick(score, bars);
|
|
52
63
|
const notes: Note[] = score.notes.filter(
|
|
53
64
|
(note) =>
|
|
54
65
|
note.trackId === track.id &&
|
|
@@ -61,6 +72,8 @@ export function renderAudition(
|
|
|
61
72
|
beatsPerBar: score.beatsPerBar,
|
|
62
73
|
bars,
|
|
63
74
|
ticksPerBeat: score.ticksPerBeat,
|
|
75
|
+
// The song's meter and tempo map, so bar spans match `endTick`.
|
|
76
|
+
...(score.time ? { time: score.time } : {}),
|
|
64
77
|
tracks: [auditionTrack(track)],
|
|
65
78
|
notes: notes.map((note) => ({
|
|
66
79
|
...note,
|
package/src/audio/click.ts
CHANGED
|
@@ -7,14 +7,27 @@
|
|
|
7
7
|
* nothing here touches the score.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { beatMarks, loopTicksOf, type TimeScore } from "../../core/tempo.ts";
|
|
11
|
+
|
|
10
12
|
export type ClickLevel = "accent" | "beat" | "sub";
|
|
11
13
|
|
|
14
|
+
/** A click at a transport beat. */
|
|
15
|
+
export type ClickMark = Readonly<{ beat: number; level: ClickLevel }>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Clicks in the half-open beat span [from, to), for meters a fixed
|
|
19
|
+
* `beatsPerBar` cannot describe (meter changes, compound and odd meters).
|
|
20
|
+
*/
|
|
21
|
+
export type ClickGrid = (from: number, to: number) => readonly ClickMark[];
|
|
22
|
+
|
|
12
23
|
export type ClickSettings = Readonly<{
|
|
13
24
|
/** 0..1 output level. */
|
|
14
25
|
volume: number;
|
|
15
26
|
beatsPerBar: number;
|
|
16
27
|
/** Clicks per beat: 1 = quarters, 2 = eighths, 4 = sixteenths. */
|
|
17
28
|
subdivision: number;
|
|
29
|
+
/** Replaces the fixed beatsPerBar × subdivision grid when present. */
|
|
30
|
+
grid?: ClickGrid;
|
|
18
31
|
}>;
|
|
19
32
|
|
|
20
33
|
export const DEFAULT_CLICK_VOLUME = 0.6;
|
|
@@ -50,9 +63,20 @@ export function clicksIn(
|
|
|
50
63
|
fromBeat: number,
|
|
51
64
|
toBeat: number,
|
|
52
65
|
frames: number,
|
|
53
|
-
settings: Pick<ClickSettings, "beatsPerBar" | "subdivision">,
|
|
66
|
+
settings: Pick<ClickSettings, "beatsPerBar" | "subdivision" | "grid">,
|
|
54
67
|
): ClickEvent[] {
|
|
55
68
|
if (!(toBeat > fromBeat) || frames <= 0) return [];
|
|
69
|
+
if (settings.grid) {
|
|
70
|
+
const span = toBeat - fromBeat;
|
|
71
|
+
const events: ClickEvent[] = [];
|
|
72
|
+
for (const mark of settings.grid(fromBeat, toBeat)) {
|
|
73
|
+
const offset = Math.floor(((mark.beat - fromBeat) / span) * frames);
|
|
74
|
+
if (offset < 0 || offset >= frames) continue;
|
|
75
|
+
events.push({ offset, level: mark.level, step: Math.round(mark.beat) });
|
|
76
|
+
if (events.length > 64) break;
|
|
77
|
+
}
|
|
78
|
+
return events;
|
|
79
|
+
}
|
|
56
80
|
const subdivision = Math.max(
|
|
57
81
|
1,
|
|
58
82
|
Math.min(MAX_SUBDIVISION, Math.round(settings.subdivision)),
|
|
@@ -77,6 +101,94 @@ export function clicksIn(
|
|
|
77
101
|
return events;
|
|
78
102
|
}
|
|
79
103
|
|
|
104
|
+
const gridCache = new WeakMap<object, Map<number, readonly ClickMark[]>>();
|
|
105
|
+
|
|
106
|
+
/** Clicks of one loop pass through the score's meters (cached). */
|
|
107
|
+
function loopClicks(
|
|
108
|
+
score: TimeScore,
|
|
109
|
+
subdivision: number,
|
|
110
|
+
): readonly ClickMark[] {
|
|
111
|
+
let bySubdivision = gridCache.get(score);
|
|
112
|
+
if (!bySubdivision) {
|
|
113
|
+
bySubdivision = new Map();
|
|
114
|
+
gridCache.set(score, bySubdivision);
|
|
115
|
+
}
|
|
116
|
+
const cached = bySubdivision.get(subdivision);
|
|
117
|
+
if (cached) return cached;
|
|
118
|
+
const tpb = score.ticksPerBeat;
|
|
119
|
+
const loopTicks = loopTicksOf(score);
|
|
120
|
+
const marks = beatMarks(score, 0, loopTicks);
|
|
121
|
+
const out: ClickMark[] = [];
|
|
122
|
+
marks.forEach((mark, index) => {
|
|
123
|
+
const next = marks[index + 1]?.tick ?? loopTicks;
|
|
124
|
+
out.push({ beat: mark.tick / tpb, level: mark.bar ? "accent" : "beat" });
|
|
125
|
+
for (let sub = 1; sub < subdivision; sub += 1)
|
|
126
|
+
out.push({
|
|
127
|
+
beat: (mark.tick + ((next - mark.tick) * sub) / subdivision) / tpb,
|
|
128
|
+
level: "sub",
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
const frozen = Object.freeze(out);
|
|
132
|
+
bySubdivision.set(subdivision, frozen);
|
|
133
|
+
return frozen;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The click grid through meter changes: downbeats accent, each meter
|
|
138
|
+
* clicks its beat unit (dotted in compound meters), every loop pass.
|
|
139
|
+
*/
|
|
140
|
+
export function meterClickGrid(
|
|
141
|
+
score: () => TimeScore,
|
|
142
|
+
subdivision = 1,
|
|
143
|
+
): ClickGrid {
|
|
144
|
+
return (from, to) => {
|
|
145
|
+
const current = score();
|
|
146
|
+
const loopBeats = loopTicksOf(current) / current.ticksPerBeat;
|
|
147
|
+
const clicks = loopClicks(
|
|
148
|
+
current,
|
|
149
|
+
Math.max(1, Math.min(MAX_SUBDIVISION, Math.round(subdivision))),
|
|
150
|
+
);
|
|
151
|
+
const out: ClickMark[] = [];
|
|
152
|
+
if (!(loopBeats > 0) || !(to > from)) return out;
|
|
153
|
+
for (
|
|
154
|
+
let pass = Math.floor(from / loopBeats);
|
|
155
|
+
pass * loopBeats < to && out.length <= 64;
|
|
156
|
+
pass += 1
|
|
157
|
+
) {
|
|
158
|
+
const base = pass * loopBeats;
|
|
159
|
+
for (const click of clicks) {
|
|
160
|
+
const beat = base + click.beat;
|
|
161
|
+
if (beat >= to) break;
|
|
162
|
+
if (beat >= from) out.push({ beat, level: click.level });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Count-in clicks: `bars` bars of `barBeats` beats clicking every
|
|
171
|
+
* `clickBeats`, ending at `endBeat` where the transport takes over.
|
|
172
|
+
*/
|
|
173
|
+
export function countInClicks(
|
|
174
|
+
endBeat: number,
|
|
175
|
+
bars: number,
|
|
176
|
+
barBeats: number,
|
|
177
|
+
clickBeats: number,
|
|
178
|
+
from: number,
|
|
179
|
+
to: number,
|
|
180
|
+
): ClickMark[] {
|
|
181
|
+
const out: ClickMark[] = [];
|
|
182
|
+
const start = endBeat - bars * barBeats;
|
|
183
|
+
for (let bar = 0; bar < bars; bar += 1)
|
|
184
|
+
for (let at = 0; at < barBeats - 1e-9; at += clickBeats) {
|
|
185
|
+
const beat = start + bar * barBeats + at;
|
|
186
|
+
if (beat >= from && beat < to && beat < endBeat)
|
|
187
|
+
out.push({ beat, level: at === 0 ? "accent" : "beat" });
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
80
192
|
const CLICK_TONES: Readonly<
|
|
81
193
|
Record<ClickLevel, { hz: number; gain: number; ms: number }>
|
|
82
194
|
> = Object.freeze({
|