@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
package/src/audio/wav.ts
CHANGED
|
@@ -10,6 +10,12 @@ import {
|
|
|
10
10
|
type TrackScore,
|
|
11
11
|
} from "../../core/score.ts";
|
|
12
12
|
import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
|
|
13
|
+
import {
|
|
14
|
+
performanceTimingFor,
|
|
15
|
+
tunedTiming,
|
|
16
|
+
performNotes,
|
|
17
|
+
type PerformedNote,
|
|
18
|
+
} from "../../core/expression.ts";
|
|
13
19
|
import {
|
|
14
20
|
planSamplerVoices,
|
|
15
21
|
renderSamplerVoices,
|
|
@@ -53,12 +59,21 @@ import {
|
|
|
53
59
|
type OrbitBus,
|
|
54
60
|
} from "./effects/bus.ts";
|
|
55
61
|
import { tableFor, wavetableOscillator } from "./wavetable.ts";
|
|
62
|
+
import { noteHz, resolveTuning, type TuningTable } from "../../core/tuning.ts";
|
|
56
63
|
import {
|
|
57
64
|
duckGains,
|
|
58
65
|
duckSettings,
|
|
59
66
|
orbitOf,
|
|
60
67
|
type Ducker,
|
|
61
68
|
} from "./effects/duck.ts";
|
|
69
|
+
import {
|
|
70
|
+
loopSecondsOf,
|
|
71
|
+
performedNotes,
|
|
72
|
+
slowestBpmOf,
|
|
73
|
+
} from "../../core/tempo.ts";
|
|
74
|
+
import { sampleWarpFor, warpedSpan, type SampleWarp } from "./warp.ts";
|
|
75
|
+
import { applyMaster, type MasterReport } from "./master.ts";
|
|
76
|
+
import type { SongMaster } from "../../core/master.ts";
|
|
62
77
|
|
|
63
78
|
export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
|
|
64
79
|
|
|
@@ -75,6 +90,12 @@ export type RenderOptions = WavOptions &
|
|
|
75
90
|
* tracks render silent; scores without samplers are unaffected.
|
|
76
91
|
*/
|
|
77
92
|
samples?: SampleBank;
|
|
93
|
+
/**
|
|
94
|
+
* Ticks added to each note's start when seeding its noise. An arranged
|
|
95
|
+
* render window passes its origin so seeded voices sound as they do in
|
|
96
|
+
* a single pass of the whole song. Zero (the default) changes nothing.
|
|
97
|
+
*/
|
|
98
|
+
seedTick?: number;
|
|
78
99
|
}>;
|
|
79
100
|
|
|
80
101
|
/** Interleaved stereo 16-bit PCM plus its frame count. */
|
|
@@ -83,6 +104,8 @@ export type RenderedAudio = Readonly<{
|
|
|
83
104
|
channels: 2;
|
|
84
105
|
frames: number;
|
|
85
106
|
pcm: Int16Array;
|
|
107
|
+
/** Loudness the song master reached; absent without a master. */
|
|
108
|
+
master?: MasterReport;
|
|
86
109
|
}>;
|
|
87
110
|
|
|
88
111
|
/** Output channel count for every render and export. */
|
|
@@ -145,6 +168,16 @@ const MAX_DRUM_SECONDS = 0.6;
|
|
|
145
168
|
const CONTROL_SAMPLES = 32;
|
|
146
169
|
/** One-shot renders keep this much ring-out after the last bar. */
|
|
147
170
|
const ONE_SHOT_TAIL_SECONDS = 0.35;
|
|
171
|
+
|
|
172
|
+
function oneShotSeconds(
|
|
173
|
+
loopSeconds: number,
|
|
174
|
+
samplerTail: number,
|
|
175
|
+
reverbTail: number,
|
|
176
|
+
): number {
|
|
177
|
+
return (
|
|
178
|
+
loopSeconds + Math.max(ONE_SHOT_TAIL_SECONDS, samplerTail) + reverbTail
|
|
179
|
+
);
|
|
180
|
+
}
|
|
148
181
|
/** Loop renders fold at most this much tail back onto the loop start. */
|
|
149
182
|
const MAX_LOOP_TAIL_SECONDS = 8;
|
|
150
183
|
|
|
@@ -155,10 +188,14 @@ type RenderContext = Readonly<{
|
|
|
155
188
|
samplesPerTick: number;
|
|
156
189
|
tempoBpm: number;
|
|
157
190
|
irs?: ReadonlyMap<string, DecodedSample>;
|
|
191
|
+
/** Tempo map in samples (core/tempo.ts); absent: constant tempo. */
|
|
192
|
+
warp?: SampleWarp;
|
|
193
|
+
seedTick?: number;
|
|
158
194
|
}>;
|
|
159
195
|
|
|
160
196
|
/** Exact (fractional) loop length in frames at a sample rate. */
|
|
161
197
|
export function loopFrames(score: TrackScore, sampleRate: number): number {
|
|
198
|
+
if (score.time) return loopSecondsOf(score) * sampleRate;
|
|
162
199
|
return (score.bars * score.beatsPerBar * 60 * sampleRate) / score.tempoBpm;
|
|
163
200
|
}
|
|
164
201
|
|
|
@@ -256,11 +293,38 @@ export class StemRenderer {
|
|
|
256
293
|
return { stems: this.stems.size, bytes: this.cacheBytes };
|
|
257
294
|
}
|
|
258
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Seconds a one-shot render of `score` needs (song plus tail) before the
|
|
298
|
+
* renderer's cap, so callers can tell when a single pass would cut it.
|
|
299
|
+
*/
|
|
300
|
+
public oneShotSeconds(
|
|
301
|
+
score: TrackScore,
|
|
302
|
+
options: RenderOptions = {},
|
|
303
|
+
): number {
|
|
304
|
+
const sampleRate = clampSampleRate(options.sampleRate);
|
|
305
|
+
const bank = options.samples ?? EMPTY_SAMPLE_BANK;
|
|
306
|
+
const reverbTail = Math.max(
|
|
307
|
+
0,
|
|
308
|
+
...score.tracks.map((track) =>
|
|
309
|
+
reverbTailFor(track, sampleRate, bank.irs),
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
const samplerTail = Math.min(
|
|
313
|
+
MAX_LOOP_TAIL_SECONDS,
|
|
314
|
+
Math.max(
|
|
315
|
+
samplerTailSeconds(score, bank, sampleRate),
|
|
316
|
+
...score.tracks.map(synthTailSeconds),
|
|
317
|
+
),
|
|
318
|
+
);
|
|
319
|
+
return oneShotSeconds(loopSecondsOf(score), samplerTail, reverbTail);
|
|
320
|
+
}
|
|
321
|
+
|
|
259
322
|
public render(score: TrackScore, options: RenderOptions = {}): RenderedAudio {
|
|
260
323
|
const sampleRate = clampSampleRate(options.sampleRate);
|
|
261
324
|
const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
|
|
262
|
-
const loopSeconds = (score
|
|
325
|
+
const loopSeconds = loopSecondsOf(score);
|
|
263
326
|
const bank = options.samples ?? EMPTY_SAMPLE_BANK;
|
|
327
|
+
const warp = sampleWarpFor(score, sampleRate);
|
|
264
328
|
const reverbTail = Math.max(
|
|
265
329
|
0,
|
|
266
330
|
...score.tracks.map((track) =>
|
|
@@ -293,14 +357,17 @@ export class StemRenderer {
|
|
|
293
357
|
reverbTail +
|
|
294
358
|
Math.max(
|
|
295
359
|
0,
|
|
296
|
-
|
|
360
|
+
// The slowest tempo in the map gives the longest echoes.
|
|
361
|
+
...score.tracks.map((track) =>
|
|
362
|
+
delayTailFor(track, slowestBpmOf(score)),
|
|
363
|
+
),
|
|
297
364
|
),
|
|
298
365
|
);
|
|
299
366
|
samples = frames + Math.ceil(tailSeconds * sampleRate);
|
|
300
367
|
} else {
|
|
301
368
|
const seconds = Math.min(
|
|
302
369
|
maxSeconds,
|
|
303
|
-
loopSeconds
|
|
370
|
+
oneShotSeconds(loopSeconds, samplerTail, reverbTail),
|
|
304
371
|
);
|
|
305
372
|
frames = Math.max(1, Math.ceil(seconds * sampleRate));
|
|
306
373
|
samples = frames;
|
|
@@ -312,6 +379,8 @@ export class StemRenderer {
|
|
|
312
379
|
samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
|
|
313
380
|
tempoBpm: score.tempoBpm,
|
|
314
381
|
...(bank.irs ? { irs: bank.irs } : {}),
|
|
382
|
+
...(warp ? { warp } : {}),
|
|
383
|
+
...(options.seedTick ? { seedTick: options.seedTick } : {}),
|
|
315
384
|
};
|
|
316
385
|
this.renders += 1;
|
|
317
386
|
const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
|
|
@@ -319,20 +388,34 @@ export class StemRenderer {
|
|
|
319
388
|
mixR.fill(0);
|
|
320
389
|
const tracks = new Map(score.tracks.map((track) => [track.id, track]));
|
|
321
390
|
const groups = new Map<string, Note[]>();
|
|
322
|
-
|
|
391
|
+
// Track time (rate, phase, cycle) places notes on the song timeline.
|
|
392
|
+
for (const note of performedNotes(score)) {
|
|
323
393
|
const group = groups.get(note.trackId);
|
|
324
394
|
if (group) group.push(note);
|
|
325
395
|
else groups.set(note.trackId, [note]);
|
|
326
396
|
}
|
|
397
|
+
// Note expression and track performance (core/expression.ts): the
|
|
398
|
+
// notes as played. A track with neither gets its notes back unchanged.
|
|
399
|
+
const timing = performanceTimingFor(score);
|
|
400
|
+
const performed = new Map<string, readonly PerformedNote[]>();
|
|
401
|
+
for (const [trackId, notes] of groups) {
|
|
402
|
+
const track = tracks.get(trackId);
|
|
403
|
+
performed.set(
|
|
404
|
+
trackId,
|
|
405
|
+
performNotes(track, notes, tunedTiming(timing, score, track)),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
327
408
|
// Orbit ducking is a gain on finished stems (src/audio/effects/duck.ts).
|
|
328
409
|
const duckers: Ducker[] = [];
|
|
329
|
-
for (const [trackId
|
|
410
|
+
for (const [trackId] of groups) {
|
|
330
411
|
const settings = duckSettings(tracks.get(trackId));
|
|
331
412
|
if (settings && isTrackAudible(score, trackId))
|
|
332
413
|
duckers.push({
|
|
333
414
|
trackId,
|
|
334
415
|
...settings,
|
|
335
|
-
onsets:
|
|
416
|
+
onsets: performed
|
|
417
|
+
.get(trackId)!
|
|
418
|
+
.map((note) => noteSpan(note, context).start),
|
|
336
419
|
});
|
|
337
420
|
}
|
|
338
421
|
const ducked = duckGains(
|
|
@@ -349,11 +432,16 @@ export class StemRenderer {
|
|
|
349
432
|
for (const [trackId, notes] of groups) {
|
|
350
433
|
if (!isTrackAudible(score, trackId)) continue;
|
|
351
434
|
const track = tracks.get(trackId);
|
|
435
|
+
// Stems key on the written notes (with the track's performance
|
|
436
|
+
// settings); the voices render the performed ones.
|
|
437
|
+
const played = performed.get(trackId)!;
|
|
352
438
|
const busOrbit = sharedOrbitOf(track);
|
|
353
439
|
const sampler = isSamplerInstrument(track?.instrument);
|
|
354
440
|
// Wavetable hook: the oscillator factory for a wavetable track (its
|
|
355
441
|
// table id joins the stem key), undefined for every other instrument.
|
|
356
442
|
const wavetable = track ? wavetableHook(track, bank, context) : undefined;
|
|
443
|
+
// The merged song and track tuning; undefined keeps 12-TET untouched.
|
|
444
|
+
const tuning = resolveTuning(score.tuning, track?.tuning, score.key);
|
|
357
445
|
const key = stemKey(
|
|
358
446
|
track,
|
|
359
447
|
notes,
|
|
@@ -406,16 +494,28 @@ export class StemRenderer {
|
|
|
406
494
|
const stereo = synthVoice && isStereoVoice(track);
|
|
407
495
|
if (stereo) dryR.fill(0);
|
|
408
496
|
if (sampler) {
|
|
409
|
-
if (track) renderSamplerNotes(dry,
|
|
497
|
+
if (track) renderSamplerNotes(dry, played, track, context, bank);
|
|
410
498
|
} else if (synthVoice && track) {
|
|
411
499
|
const gainAt = (tick: number) => trackGainAt(track, tick);
|
|
412
500
|
const voice = {
|
|
413
501
|
...context,
|
|
414
502
|
ticksPerBeat: score.ticksPerBeat,
|
|
415
503
|
...(wavetable ? { oscillatorFor: wavetable.oscillatorFor } : {}),
|
|
504
|
+
...(tuning ? { tuning } : {}),
|
|
416
505
|
};
|
|
417
|
-
|
|
506
|
+
const warp = context.warp;
|
|
507
|
+
for (const note of played) {
|
|
418
508
|
const { start, length } = noteSpan(note, context);
|
|
509
|
+
// The voice asks for `startTick + elapsed / samplesPerTick`;
|
|
510
|
+
// through a tempo map that sample's tick comes from the map.
|
|
511
|
+
const noteGainAt = warp
|
|
512
|
+
? (tick: number) =>
|
|
513
|
+
gainAt(
|
|
514
|
+
warp.tick(
|
|
515
|
+
start + (tick - note.startTick) * context.samplesPerTick,
|
|
516
|
+
),
|
|
517
|
+
)
|
|
518
|
+
: gainAt;
|
|
419
519
|
renderSynthNote(
|
|
420
520
|
dry,
|
|
421
521
|
stereo ? dryR : undefined,
|
|
@@ -424,14 +524,14 @@ export class StemRenderer {
|
|
|
424
524
|
start,
|
|
425
525
|
length,
|
|
426
526
|
voice,
|
|
427
|
-
|
|
527
|
+
noteGainAt,
|
|
428
528
|
);
|
|
429
529
|
}
|
|
430
530
|
} else {
|
|
431
531
|
const drums = isDrumInstrument(track?.instrument);
|
|
432
|
-
for (const note of
|
|
532
|
+
for (const note of played) {
|
|
433
533
|
if (drums) renderDrumNote(dry, note, track, context);
|
|
434
|
-
else renderToneNote(dry, note, track, context);
|
|
534
|
+
else renderToneNote(dry, note, track, context, tuning);
|
|
435
535
|
}
|
|
436
536
|
}
|
|
437
537
|
if (track) applyMonoChain(dry, track, context);
|
|
@@ -526,12 +626,26 @@ export class StemRenderer {
|
|
|
526
626
|
// Linear effects superpose, so folding the tail onto the start yields
|
|
527
627
|
// the steady state of the loop playing forever.
|
|
528
628
|
if (samples > frames) foldTail(mixL, mixR, frames, samples);
|
|
529
|
-
|
|
629
|
+
// The song master (src/audio/master.ts); none leaves the mix untouched.
|
|
630
|
+
const mastered = applyMaster(
|
|
631
|
+
mixL,
|
|
632
|
+
mixR,
|
|
633
|
+
frames,
|
|
634
|
+
sampleRate,
|
|
635
|
+
score.master,
|
|
636
|
+
options.loop === true,
|
|
637
|
+
);
|
|
638
|
+
// A master's output is a finished level, so its reduction to 16 bits is
|
|
639
|
+
// TPDF-dithered (seeded: renders stay byte-identical run to run).
|
|
640
|
+
const pcm = mastered
|
|
641
|
+
? toPcm(mastered.left, mastered.right, frames, true)
|
|
642
|
+
: toPcm(mixL, mixR, frames);
|
|
530
643
|
return Object.freeze({
|
|
531
644
|
sampleRate,
|
|
532
645
|
channels: RENDER_CHANNELS,
|
|
533
646
|
frames,
|
|
534
647
|
pcm,
|
|
648
|
+
...(mastered ? { master: mastered.report } : {}),
|
|
535
649
|
});
|
|
536
650
|
}
|
|
537
651
|
|
|
@@ -630,12 +744,59 @@ function foldTail(
|
|
|
630
744
|
}
|
|
631
745
|
|
|
632
746
|
/** Interleaved 16-bit PCM of the first `frames` mix samples. */
|
|
747
|
+
/**
|
|
748
|
+
* The song master over an already summed 16-bit mix (an arrangement
|
|
749
|
+
* rendered in windows), so the whole song is limited and normalized as one
|
|
750
|
+
* pass is. Undefined when the master is absent or bypassed.
|
|
751
|
+
*/
|
|
752
|
+
export function masterSummedPcm(
|
|
753
|
+
pcm: Int16Array,
|
|
754
|
+
frames: number,
|
|
755
|
+
sampleRate: number,
|
|
756
|
+
master: SongMaster | undefined,
|
|
757
|
+
loop: boolean,
|
|
758
|
+
): { pcm: Int16Array; report: MasterReport } | undefined {
|
|
759
|
+
const left = new Float64Array(frames);
|
|
760
|
+
const right = new Float64Array(frames);
|
|
761
|
+
for (let index = 0; index < frames; index += 1) {
|
|
762
|
+
left[index] = pcm[index * 2]! / 32767;
|
|
763
|
+
right[index] = pcm[index * 2 + 1]! / 32767;
|
|
764
|
+
}
|
|
765
|
+
const mastered = applyMaster(left, right, frames, sampleRate, master, loop);
|
|
766
|
+
if (!mastered) return undefined;
|
|
767
|
+
return {
|
|
768
|
+
pcm: toPcm(mastered.left, mastered.right, frames, true),
|
|
769
|
+
report: mastered.report,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
633
773
|
function toPcm(
|
|
634
774
|
mixL: Float64Array,
|
|
635
775
|
mixR: Float64Array,
|
|
636
776
|
frames: number,
|
|
777
|
+
dither = false,
|
|
637
778
|
): Int16Array {
|
|
638
779
|
const pcm = new Int16Array(frames * RENDER_CHANNELS);
|
|
780
|
+
if (dither) {
|
|
781
|
+
// Triangular dither of +-1 LSB: the sum of two uniform values, from a
|
|
782
|
+
// fixed-seed xorshift so every render of a score dithers the same way.
|
|
783
|
+
let state = 0x9e3779b9;
|
|
784
|
+
const uniform = () => {
|
|
785
|
+
state ^= state << 13;
|
|
786
|
+
state ^= state >>> 17;
|
|
787
|
+
state ^= state << 5;
|
|
788
|
+
return (state >>> 0) / 4_294_967_296;
|
|
789
|
+
};
|
|
790
|
+
for (let index = 0; index < frames; index += 1) {
|
|
791
|
+
pcm[index * 2] = clamp16(
|
|
792
|
+
Math.round(mixL[index]! * 32767 + uniform() - uniform()),
|
|
793
|
+
);
|
|
794
|
+
pcm[index * 2 + 1] = clamp16(
|
|
795
|
+
Math.round(mixR[index]! * 32767 + uniform() - uniform()),
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
return pcm;
|
|
799
|
+
}
|
|
639
800
|
for (let index = 0; index < frames; index += 1) {
|
|
640
801
|
pcm[index * 2] = clamp16(mixL[index]! * 32767);
|
|
641
802
|
pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
|
|
@@ -680,12 +841,20 @@ function stemKey(
|
|
|
680
841
|
context.score.beatsPerBar,
|
|
681
842
|
context.sampleRate,
|
|
682
843
|
context.samples,
|
|
844
|
+
// Tempo map, meter changes and fermatas move every note.
|
|
845
|
+
...(context.score.time ? [context.score.time] : []),
|
|
683
846
|
// Sampler stems also depend on the decoded files: a replaced or missing
|
|
684
847
|
// sample changes the key even when the score did not change.
|
|
685
848
|
...(bank && track?.sampler ? [samplerVoiceDigest(track, bank)] : []),
|
|
686
849
|
...(wavetableId ? [wavetableId] : []),
|
|
687
850
|
// A convolution reverb's impulse (its sha256 for a sample).
|
|
688
851
|
...(impulseId ? [impulseId] : []),
|
|
852
|
+
// The song tuning, and the key whose tonic is the default root.
|
|
853
|
+
...(context.score.tuning || track?.tuning
|
|
854
|
+
? [context.score.tuning ?? null, context.score.key]
|
|
855
|
+
: []),
|
|
856
|
+
// Seeded noise follows the window origin of an arranged render.
|
|
857
|
+
...(context.seedTick ? [`seed:${context.seedTick}`] : []),
|
|
689
858
|
]);
|
|
690
859
|
}
|
|
691
860
|
|
|
@@ -708,7 +877,11 @@ function renderSamplerNotes(
|
|
|
708
877
|
context: RenderContext,
|
|
709
878
|
bank: SampleBank,
|
|
710
879
|
): void {
|
|
711
|
-
const timing = {
|
|
880
|
+
const timing = {
|
|
881
|
+
score: context.score,
|
|
882
|
+
sampleRate: context.sampleRate,
|
|
883
|
+
...(context.warp ? { warp: context.warp } : {}),
|
|
884
|
+
};
|
|
712
885
|
renderSamplerVoices(
|
|
713
886
|
target,
|
|
714
887
|
planSamplerVoices(track, notes, bank, timing),
|
|
@@ -732,7 +905,8 @@ function noteSpan(
|
|
|
732
905
|
note: Note,
|
|
733
906
|
context: RenderContext,
|
|
734
907
|
): { start: number; length: number } {
|
|
735
|
-
const { score, sampleRate } = context;
|
|
908
|
+
const { score, sampleRate, warp } = context;
|
|
909
|
+
if (warp) return warpedSpan(warp, note.startTick, note.durationTicks);
|
|
736
910
|
const start = Math.max(
|
|
737
911
|
0,
|
|
738
912
|
Math.floor(
|
|
@@ -755,13 +929,18 @@ function renderToneNote(
|
|
|
755
929
|
note: Note,
|
|
756
930
|
track: Track | undefined,
|
|
757
931
|
context: RenderContext,
|
|
932
|
+
tuning?: TuningTable,
|
|
758
933
|
): void {
|
|
759
934
|
const { sampleRate, samples, samplesPerTick } = context;
|
|
760
935
|
const instrument = track?.instrument ?? "sine";
|
|
761
936
|
const { start, length } = noteSpan(note, context);
|
|
762
937
|
const end = Math.min(samples, start + length);
|
|
763
|
-
const frequency =
|
|
938
|
+
const frequency = noteHz(note.pitch, note.cents, tuning);
|
|
939
|
+
if (!(frequency > 0)) return;
|
|
764
940
|
const velocity = Math.max(0, Math.min(1, note.velocity));
|
|
941
|
+
const performance = (note as PerformedNote).performance;
|
|
942
|
+
const cents = performance?.cents;
|
|
943
|
+
let bent = 0;
|
|
765
944
|
for (let index = start; index < end; index += 1) {
|
|
766
945
|
const elapsed = index - start;
|
|
767
946
|
const remaining = end - index;
|
|
@@ -771,9 +950,24 @@ function renderToneNote(
|
|
|
771
950
|
Math.min(attack, release) *
|
|
772
951
|
velocity *
|
|
773
952
|
0.28 *
|
|
774
|
-
trackGainAt(
|
|
775
|
-
|
|
776
|
-
|
|
953
|
+
trackGainAt(
|
|
954
|
+
track,
|
|
955
|
+
context.warp
|
|
956
|
+
? context.warp.tick(index)
|
|
957
|
+
: note.startTick + elapsed / samplesPerTick,
|
|
958
|
+
);
|
|
959
|
+
// Plain notes keep the closed-form phase (byte-identical output).
|
|
960
|
+
let phase = (frequency * elapsed) / sampleRate;
|
|
961
|
+
if (cents) {
|
|
962
|
+
phase = bent;
|
|
963
|
+
bent +=
|
|
964
|
+
(frequency * 2 ** (cents(elapsed / sampleRate) / 1200)) / sampleRate;
|
|
965
|
+
}
|
|
966
|
+
const damp = performance?.damp;
|
|
967
|
+
const t = elapsed / sampleRate;
|
|
968
|
+
const held =
|
|
969
|
+
damp && t > damp.from ? Math.exp(-(t - damp.from) / damp.tau) : 1;
|
|
970
|
+
target[index]! += legacyWave(instrument, phase) * envelope * held;
|
|
777
971
|
}
|
|
778
972
|
}
|
|
779
973
|
|
|
@@ -799,7 +993,9 @@ function renderDrumNote(
|
|
|
799
993
|
start + Math.ceil((kit?.seconds ?? MAX_DRUM_SECONDS) * sampleRate),
|
|
800
994
|
);
|
|
801
995
|
const voice = drumVoiceForPitch(note.pitch);
|
|
802
|
-
const random = seededRandom(
|
|
996
|
+
const random = seededRandom(
|
|
997
|
+
`${note.id}:${note.startTick + (context.seedTick ?? 0)}`,
|
|
998
|
+
);
|
|
803
999
|
const velocity = Math.max(0, Math.min(1, note.velocity));
|
|
804
1000
|
let phase = 0;
|
|
805
1001
|
let previousNoise = 0;
|
|
@@ -865,7 +1061,12 @@ function renderDrumNote(
|
|
|
865
1061
|
release *
|
|
866
1062
|
velocity *
|
|
867
1063
|
0.5 *
|
|
868
|
-
trackGainAt(
|
|
1064
|
+
trackGainAt(
|
|
1065
|
+
track,
|
|
1066
|
+
context.warp
|
|
1067
|
+
? context.warp.tick(index)
|
|
1068
|
+
: note.startTick + (index - start) / samplesPerTick,
|
|
1069
|
+
);
|
|
869
1070
|
}
|
|
870
1071
|
}
|
|
871
1072
|
|
|
@@ -892,7 +1093,13 @@ function applyPan(
|
|
|
892
1093
|
for (let index = 0; index < dry.length; index += 1) {
|
|
893
1094
|
if (lane.length > 0 && index % CONTROL_SAMPLES === 0)
|
|
894
1095
|
[gainL, gainR] = gains(
|
|
895
|
-
interpolateAutomation(
|
|
1096
|
+
interpolateAutomation(
|
|
1097
|
+
lane,
|
|
1098
|
+
context.warp
|
|
1099
|
+
? context.warp.tick(index)
|
|
1100
|
+
: index / context.samplesPerTick,
|
|
1101
|
+
staticPan,
|
|
1102
|
+
),
|
|
896
1103
|
);
|
|
897
1104
|
const sample = dry[index]!;
|
|
898
1105
|
left[index] = sample * gainL;
|
|
@@ -930,16 +1137,29 @@ function wavetableHook(
|
|
|
930
1137
|
lane.length > 0
|
|
931
1138
|
? (tick: number) => interpolateAutomation(lane, tick, settings.wt ?? 0)
|
|
932
1139
|
: undefined;
|
|
1140
|
+
const warp = context.warp;
|
|
933
1141
|
return {
|
|
934
1142
|
id: table.id,
|
|
935
|
-
oscillatorFor: (note) =>
|
|
936
|
-
|
|
1143
|
+
oscillatorFor: (note) => {
|
|
1144
|
+
const span = noteSpan(note, context);
|
|
1145
|
+
return wavetableOscillator(table, settings, note, {
|
|
937
1146
|
sampleRate: context.sampleRate,
|
|
938
1147
|
samplesPerTick: context.samplesPerTick,
|
|
939
1148
|
secondsPerTick: context.samplesPerTick / context.sampleRate,
|
|
940
|
-
gate:
|
|
941
|
-
|
|
942
|
-
|
|
1149
|
+
gate: span.length / context.sampleRate,
|
|
1150
|
+
// Through a tempo map the lane is read at the sample's song tick.
|
|
1151
|
+
positionAt:
|
|
1152
|
+
warp && positionAt
|
|
1153
|
+
? (tick: number) =>
|
|
1154
|
+
positionAt(
|
|
1155
|
+
warp.tick(
|
|
1156
|
+
span.start +
|
|
1157
|
+
(tick - note.startTick) * context.samplesPerTick,
|
|
1158
|
+
),
|
|
1159
|
+
)
|
|
1160
|
+
: positionAt,
|
|
1161
|
+
});
|
|
1162
|
+
},
|
|
943
1163
|
};
|
|
944
1164
|
}
|
|
945
1165
|
|
|
@@ -975,3 +1195,58 @@ function writeAscii(target: Uint8Array, offset: number, value: string): void {
|
|
|
975
1195
|
function clamp16(value: number): number {
|
|
976
1196
|
return Math.max(-32_768, Math.min(32_767, Math.round(value)));
|
|
977
1197
|
}
|
|
1198
|
+
|
|
1199
|
+
/** A named point in a WAV export: a sample frame and its label. */
|
|
1200
|
+
export type WavCue = Readonly<{ frame: number; label: string }>;
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Append a `cue ` chunk and a `LIST adtl` chunk of `labl` labels, so DAWs
|
|
1204
|
+
* and DJ tools show the cues (dawg writes section starts). No cues returns
|
|
1205
|
+
* the bytes unchanged.
|
|
1206
|
+
*/
|
|
1207
|
+
export function withWavCues(
|
|
1208
|
+
bytes: Uint8Array,
|
|
1209
|
+
cues: readonly WavCue[],
|
|
1210
|
+
): Uint8Array {
|
|
1211
|
+
if (cues.length === 0 || bytes.byteLength < 12) return bytes;
|
|
1212
|
+
const encoder = new TextEncoder();
|
|
1213
|
+
const labels = cues.map((cue) =>
|
|
1214
|
+
encoder.encode(`${cue.label.slice(0, 200)}\0`),
|
|
1215
|
+
);
|
|
1216
|
+
const cueSize = 4 + 24 * cues.length;
|
|
1217
|
+
const lablSizes = labels.map((text) => 4 + text.byteLength);
|
|
1218
|
+
const adtlSize =
|
|
1219
|
+
4 + lablSizes.reduce((sum, size) => sum + 8 + size + (size % 2), 0);
|
|
1220
|
+
const base = bytes.byteLength + (bytes.byteLength % 2);
|
|
1221
|
+
const out = new Uint8Array(base + 8 + cueSize + 8 + adtlSize);
|
|
1222
|
+
out.set(bytes);
|
|
1223
|
+
const view = new DataView(out.buffer);
|
|
1224
|
+
let at = base;
|
|
1225
|
+
writeAscii(out, at, "cue ");
|
|
1226
|
+
view.setUint32(at + 4, cueSize, true);
|
|
1227
|
+
view.setUint32(at + 8, cues.length, true);
|
|
1228
|
+
at += 12;
|
|
1229
|
+
cues.forEach((cue, index) => {
|
|
1230
|
+
const frame = Math.max(0, Math.round(cue.frame));
|
|
1231
|
+
view.setUint32(at, index + 1, true);
|
|
1232
|
+
view.setUint32(at + 4, frame, true);
|
|
1233
|
+
writeAscii(out, at + 8, "data");
|
|
1234
|
+
view.setUint32(at + 12, 0, true);
|
|
1235
|
+
view.setUint32(at + 16, 0, true);
|
|
1236
|
+
view.setUint32(at + 20, frame, true);
|
|
1237
|
+
at += 24;
|
|
1238
|
+
});
|
|
1239
|
+
writeAscii(out, at, "LIST");
|
|
1240
|
+
view.setUint32(at + 4, adtlSize, true);
|
|
1241
|
+
writeAscii(out, at + 8, "adtl");
|
|
1242
|
+
at += 12;
|
|
1243
|
+
labels.forEach((text, index) => {
|
|
1244
|
+
writeAscii(out, at, "labl");
|
|
1245
|
+
view.setUint32(at + 4, lablSizes[index]!, true);
|
|
1246
|
+
view.setUint32(at + 8, index + 1, true);
|
|
1247
|
+
out.set(text, at + 12);
|
|
1248
|
+
at += 8 + lablSizes[index]! + (lablSizes[index]! % 2);
|
|
1249
|
+
});
|
|
1250
|
+
view.setUint32(4, out.byteLength - 8, true);
|
|
1251
|
+
return out;
|
|
1252
|
+
}
|