@hraness/dawg 0.2.0 → 0.4.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 +201 -0
- package/DAWG.md +762 -11
- package/README.md +79 -30
- package/core/chords.ts +1724 -0
- package/core/diff.ts +261 -0
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/key.ts +43 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/pitch.ts +60 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +1054 -14
- package/core/sdk/eval-child.ts +125 -0
- package/core/sdk/eval.ts +260 -0
- package/core/sdk/print.ts +675 -0
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +4159 -0
- package/core/slug.ts +19 -0
- package/core/synth.ts +1001 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +317 -16
- package/src/agent/brief.ts +50 -5
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +55 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +705 -14
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +35 -14
- package/src/agent/xcb.ts +233 -21
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +337 -0
- package/src/audio/engine.ts +430 -33
- package/src/audio/kits.ts +200 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +420 -0
- package/src/audio/samples.ts +1025 -0
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +550 -334
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +192 -0
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +535 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +34 -15
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +462 -0
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +2136 -170
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +186 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +397 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/media/wavetable.ts +202 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +49 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +6 -38
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +2125 -0
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +984 -0
- package/src/tui/sketch.ts +108 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +684 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +398 -33
- package/tui/drums.ts +44 -0
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +24 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +188 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { TrackScore } from "../../core/score.ts";
|
|
2
|
+
import type { RenderReply, RenderRequest } from "./render-worker.ts";
|
|
3
|
+
import { SampleLibrary, hasSamplerTracks } from "./samples.ts";
|
|
4
|
+
import { StemRenderer } from "./wav.ts";
|
|
5
|
+
|
|
6
|
+
export type LoopRender = Readonly<{
|
|
7
|
+
pcm: Int16Array;
|
|
8
|
+
frames: number;
|
|
9
|
+
sampleRate: number;
|
|
10
|
+
/** Milliseconds the render itself took (worker or inline). */
|
|
11
|
+
renderMs: number;
|
|
12
|
+
}>;
|
|
13
|
+
|
|
14
|
+
export type LoopRendererOptions = Readonly<{
|
|
15
|
+
sampleRate: number;
|
|
16
|
+
/** Set false to render on the calling thread (tests, diagnostics). */
|
|
17
|
+
worker?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Project root sampler voices resolve under (decoded in the worker, or
|
|
20
|
+
* inline on fallback). Without it sampler tracks render silent.
|
|
21
|
+
*/
|
|
22
|
+
projectRoot?: string;
|
|
23
|
+
}>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Renders loops for the engine off the main thread. A single long-lived
|
|
27
|
+
* worker owns a stem cache; the PCM buffer comes back transferred, not
|
|
28
|
+
* copied. If the worker cannot start or crashes, rendering falls back to the
|
|
29
|
+
* calling thread with its own cache, so sound never depends on the worker.
|
|
30
|
+
*/
|
|
31
|
+
export class LoopRenderer {
|
|
32
|
+
private readonly sampleRate: number;
|
|
33
|
+
private readonly useWorker: boolean;
|
|
34
|
+
private worker: Bun.Worker | undefined;
|
|
35
|
+
private workerBroken = false;
|
|
36
|
+
private inline: StemRenderer | undefined;
|
|
37
|
+
private library: SampleLibrary | undefined;
|
|
38
|
+
private readonly projectRoot: string | undefined;
|
|
39
|
+
private nextId = 1;
|
|
40
|
+
private readonly pending = new Map<
|
|
41
|
+
number,
|
|
42
|
+
{ resolve: (render: LoopRender) => void; reject: (error: Error) => void }
|
|
43
|
+
>();
|
|
44
|
+
private disposed = false;
|
|
45
|
+
|
|
46
|
+
public constructor(options: LoopRendererOptions) {
|
|
47
|
+
this.sampleRate = options.sampleRate;
|
|
48
|
+
this.useWorker = options.worker ?? true;
|
|
49
|
+
this.projectRoot = options.projectRoot;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True while renders run in the worker. */
|
|
53
|
+
public get offThread(): boolean {
|
|
54
|
+
return this.useWorker && !this.workerBroken;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public async render(score: TrackScore): Promise<LoopRender> {
|
|
58
|
+
if (this.disposed) throw new Error("renderer disposed");
|
|
59
|
+
if (this.offThread) {
|
|
60
|
+
try {
|
|
61
|
+
return await this.renderInWorker(score);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (!(error instanceof WorkerLostError)) throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return this.renderInline(score);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Stops the worker; in-flight renders fall back inline on their callers. */
|
|
70
|
+
public dispose(): void {
|
|
71
|
+
this.disposed = true;
|
|
72
|
+
this.failWorker(new WorkerLostError("renderer disposed"));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async renderInline(score: TrackScore): Promise<LoopRender> {
|
|
76
|
+
this.inline ??= new StemRenderer();
|
|
77
|
+
const started = performance.now();
|
|
78
|
+
let samples;
|
|
79
|
+
if (this.projectRoot !== undefined && hasSamplerTracks(score)) {
|
|
80
|
+
this.library ??= new SampleLibrary({ projectRoot: this.projectRoot });
|
|
81
|
+
samples = await this.library.load(score);
|
|
82
|
+
}
|
|
83
|
+
const audio = this.inline.render(score, {
|
|
84
|
+
sampleRate: this.sampleRate,
|
|
85
|
+
loop: true,
|
|
86
|
+
samples,
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
pcm: audio.pcm,
|
|
90
|
+
frames: audio.frames,
|
|
91
|
+
sampleRate: audio.sampleRate,
|
|
92
|
+
renderMs: performance.now() - started,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private renderInWorker(score: TrackScore): Promise<LoopRender> {
|
|
97
|
+
const worker = this.ensureWorker();
|
|
98
|
+
const id = this.nextId;
|
|
99
|
+
this.nextId += 1;
|
|
100
|
+
return new Promise<LoopRender>((resolve, reject) => {
|
|
101
|
+
this.pending.set(id, { resolve, reject });
|
|
102
|
+
const request: RenderRequest = {
|
|
103
|
+
id,
|
|
104
|
+
score: score.toJSON(),
|
|
105
|
+
sampleRate: this.sampleRate,
|
|
106
|
+
...(this.projectRoot === undefined
|
|
107
|
+
? {}
|
|
108
|
+
: { projectRoot: this.projectRoot }),
|
|
109
|
+
};
|
|
110
|
+
try {
|
|
111
|
+
worker.postMessage(request);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
this.failWorker(
|
|
114
|
+
new WorkerLostError(
|
|
115
|
+
error instanceof Error ? error.message : String(error),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private ensureWorker(): Bun.Worker {
|
|
123
|
+
if (this.worker) return this.worker;
|
|
124
|
+
let worker: Bun.Worker;
|
|
125
|
+
try {
|
|
126
|
+
// The DOM lib's Worker type lacks Bun's `unref`; the runtime object is Bun's.
|
|
127
|
+
worker = new Worker(
|
|
128
|
+
new URL("./render-worker.ts", import.meta.url).href,
|
|
129
|
+
) as unknown as Bun.Worker;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw new WorkerLostError(
|
|
132
|
+
error instanceof Error ? error.message : String(error),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
worker.addEventListener("message", (event: Event) => {
|
|
136
|
+
const reply = (event as MessageEvent<RenderReply>).data;
|
|
137
|
+
const waiter = this.pending.get(reply.id);
|
|
138
|
+
if (!waiter) return;
|
|
139
|
+
this.pending.delete(reply.id);
|
|
140
|
+
if (reply.ok)
|
|
141
|
+
waiter.resolve({
|
|
142
|
+
pcm: reply.pcm,
|
|
143
|
+
frames: reply.frames,
|
|
144
|
+
sampleRate: reply.sampleRate,
|
|
145
|
+
renderMs: reply.renderMs,
|
|
146
|
+
});
|
|
147
|
+
else waiter.reject(new Error(reply.error));
|
|
148
|
+
});
|
|
149
|
+
worker.addEventListener("error", (event: Event) => {
|
|
150
|
+
const message = (event as Partial<ErrorEvent>).message;
|
|
151
|
+
this.failWorker(
|
|
152
|
+
new WorkerLostError(
|
|
153
|
+
typeof message === "string" ? message : "worker error",
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
// A render worker never keeps the process alive on its own.
|
|
158
|
+
worker.unref();
|
|
159
|
+
this.worker = worker;
|
|
160
|
+
return worker;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private failWorker(error: WorkerLostError): void {
|
|
164
|
+
this.workerBroken = true;
|
|
165
|
+
const worker = this.worker;
|
|
166
|
+
this.worker = undefined;
|
|
167
|
+
worker?.terminate();
|
|
168
|
+
const waiters = [...this.pending.values()];
|
|
169
|
+
this.pending.clear();
|
|
170
|
+
for (const waiter of waiters) waiter.reject(error);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
class WorkerLostError extends Error {}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sample playback for sampler tracks, with Strudel's semantics:
|
|
3
|
+
*
|
|
4
|
+
* - `begin`/`end` pick a 0..1 window of the file.
|
|
5
|
+
* - `speed` scales the playback rate (and so the pitch); negative reverses.
|
|
6
|
+
* - `loop` repeats the window (with a short crossfade) for the note's length.
|
|
7
|
+
* - `gain` scales the voice; velocity and track volume apply as for synths.
|
|
8
|
+
* - `choke` is Strudel's `cut`: a new hit in the same group stops the sounding
|
|
9
|
+
* voice with a short fade.
|
|
10
|
+
* - `loopBegin`/`loopEnd` loop a part of the window; `clip` (`legato`) cuts
|
|
11
|
+
* the voice at the note's length times the factor; `unit` "c"/"s" and
|
|
12
|
+
* `fit` turn `speed` into a duration (cycles = bars, seconds, the note);
|
|
13
|
+
* `accelerate` ramps the rate; `squiz` raises pitch per zero-crossing
|
|
14
|
+
* cycle (described in Tidal/SuperDirt docs; implemented here from that
|
|
15
|
+
* description).
|
|
16
|
+
* - keyed mode repitches from `root` (rate × 2^((pitch − root)/12)) and holds
|
|
17
|
+
* for the note's length; oneshot mode maps voices to pitch slots from 36 in
|
|
18
|
+
* voice-name order and plays the whole window unless `loop` is set.
|
|
19
|
+
*
|
|
20
|
+
* Voices are mixed in mono into the track's dry buffer, so the track's
|
|
21
|
+
* filter, pan, delay, reverb and automation apply exactly as for synth tracks.
|
|
22
|
+
* Everything is plain arithmetic over decoded samples: identical files give
|
|
23
|
+
* identical output.
|
|
24
|
+
*/
|
|
25
|
+
import {
|
|
26
|
+
SCORE_LIMITS,
|
|
27
|
+
samplerVoiceSlots,
|
|
28
|
+
type Note,
|
|
29
|
+
type SampleRef,
|
|
30
|
+
type Track,
|
|
31
|
+
type TrackScore,
|
|
32
|
+
} from "../../core/score.ts";
|
|
33
|
+
import { sampleKey, type DecodedSample, type SampleBank } from "./samples.ts";
|
|
34
|
+
|
|
35
|
+
/** Output level of a full-scale sample at velocity 1 and gain 1. */
|
|
36
|
+
export const SAMPLE_LEVEL = 0.7;
|
|
37
|
+
/** Anti-click fade-in on every voice start. */
|
|
38
|
+
const ATTACK_SECONDS = 0.001;
|
|
39
|
+
/** Fade at a natural end, a held release, a choke and a voice steal. */
|
|
40
|
+
const END_FADE_SECONDS = 0.003;
|
|
41
|
+
const RELEASE_SECONDS = 0.01;
|
|
42
|
+
const CHOKE_SECONDS = 0.005;
|
|
43
|
+
/** Loop seam crossfade (source time). */
|
|
44
|
+
const LOOP_CROSSFADE_SECONDS = 0.005;
|
|
45
|
+
|
|
46
|
+
export type SamplerTiming = Readonly<{
|
|
47
|
+
score: TrackScore;
|
|
48
|
+
sampleRate: number;
|
|
49
|
+
}>;
|
|
50
|
+
|
|
51
|
+
/** One planned voice: when it sounds and how it reads its sample. */
|
|
52
|
+
export type SamplerVoice = {
|
|
53
|
+
readonly voice: string;
|
|
54
|
+
readonly sample: DecodedSample;
|
|
55
|
+
readonly startTick: number;
|
|
56
|
+
readonly start: number;
|
|
57
|
+
/** Output frame the voice is silent from (exclusive). */
|
|
58
|
+
end: number;
|
|
59
|
+
/** Frames of linear fade ending at `end`. */
|
|
60
|
+
fade: number;
|
|
61
|
+
/** Source frames advanced per output frame (always positive). */
|
|
62
|
+
readonly step: number;
|
|
63
|
+
readonly reverse: boolean;
|
|
64
|
+
readonly loop: boolean;
|
|
65
|
+
/** Window in source frames. */
|
|
66
|
+
readonly regionStart: number;
|
|
67
|
+
readonly regionEnd: number;
|
|
68
|
+
readonly gain: number;
|
|
69
|
+
readonly choke: string | undefined;
|
|
70
|
+
/** Loop part as travel offsets from the playback start (begin, or end reversed). */
|
|
71
|
+
readonly loopStart: number;
|
|
72
|
+
readonly loopSpan: number;
|
|
73
|
+
/** Rate ramp per output frame (`accelerate` / reference length); 0 is off. */
|
|
74
|
+
readonly ramp: number;
|
|
75
|
+
/** `squiz` ratio; 1 is off. */
|
|
76
|
+
readonly squiz: number;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Source frames travelled after `elapsed` output frames: `step·(e + k·e²/2)`
|
|
81
|
+
* with the rate held at zero once a negative ramp reaches it.
|
|
82
|
+
*/
|
|
83
|
+
function travelAt(elapsed: number, step: number, ramp: number): number {
|
|
84
|
+
if (ramp === 0) return elapsed * step;
|
|
85
|
+
const e = ramp < 0 ? Math.min(elapsed, -1 / ramp) : elapsed;
|
|
86
|
+
return step * (e + (ramp * e * e) / 2);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Output frames until `distance` source frames are travelled (Infinity if never). */
|
|
90
|
+
function framesToTravel(distance: number, step: number, ramp: number): number {
|
|
91
|
+
if (ramp === 0) return distance / step;
|
|
92
|
+
const d = distance / step;
|
|
93
|
+
const disc = 1 + 2 * ramp * d;
|
|
94
|
+
if (disc < 0) return Infinity;
|
|
95
|
+
return (Math.sqrt(disc) - 1) / ramp;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function noteStartFrame(note: Note, timing: SamplerTiming): number {
|
|
99
|
+
const { score, sampleRate } = timing;
|
|
100
|
+
return Math.max(
|
|
101
|
+
0,
|
|
102
|
+
Math.floor(
|
|
103
|
+
(((note.startTick / score.ticksPerBeat) * 60) / score.tempoBpm) *
|
|
104
|
+
sampleRate,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function noteLengthFrames(note: Note, timing: SamplerTiming): number {
|
|
110
|
+
const { score, sampleRate } = timing;
|
|
111
|
+
return Math.max(
|
|
112
|
+
1,
|
|
113
|
+
Math.floor(
|
|
114
|
+
(((note.durationTicks / score.ticksPerBeat) * 60) / score.tempoBpm) *
|
|
115
|
+
sampleRate,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Which voice a note plays and the pitch ratio it plays at. */
|
|
121
|
+
function voiceResolver(
|
|
122
|
+
track: Track,
|
|
123
|
+
): (pitch: number) => { voice: string; ratio: number } | undefined {
|
|
124
|
+
const sampler = track.sampler!;
|
|
125
|
+
if (sampler.mode === "oneshot") {
|
|
126
|
+
const byPitch = new Map<number, string>();
|
|
127
|
+
for (const [voice, slot] of samplerVoiceSlots(sampler))
|
|
128
|
+
byPitch.set(slot, voice);
|
|
129
|
+
return (pitch) => {
|
|
130
|
+
const voice = byPitch.get(pitch);
|
|
131
|
+
return voice === undefined ? undefined : { voice, ratio: 1 };
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// Keyed: the voice whose root is the highest at or below the pitch (a
|
|
135
|
+
// multi-sampled instrument), else the lowest root.
|
|
136
|
+
const keyed = Object.entries(sampler.voices)
|
|
137
|
+
.map(([voice, ref]) => ({ voice, root: ref.root ?? 60 }))
|
|
138
|
+
.sort((a, b) => a.root - b.root || (a.voice < b.voice ? -1 : 1));
|
|
139
|
+
return (pitch) => {
|
|
140
|
+
let chosen = keyed[0];
|
|
141
|
+
for (const candidate of keyed)
|
|
142
|
+
if (candidate.root <= pitch) chosen = candidate;
|
|
143
|
+
if (!chosen) return undefined;
|
|
144
|
+
return { voice: chosen.voice, ratio: 2 ** ((pitch - chosen.root) / 12) };
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Plans every voice a sampler track plays, with choke and polyphony applied. */
|
|
149
|
+
export function planSamplerVoices(
|
|
150
|
+
track: Track,
|
|
151
|
+
notes: readonly Note[],
|
|
152
|
+
bank: SampleBank,
|
|
153
|
+
timing: SamplerTiming,
|
|
154
|
+
): SamplerVoice[] {
|
|
155
|
+
const sampler = track.sampler;
|
|
156
|
+
if (!sampler) return [];
|
|
157
|
+
const resolve = voiceResolver(track);
|
|
158
|
+
const ordered = [...notes].sort(
|
|
159
|
+
(a, b) =>
|
|
160
|
+
a.startTick - b.startTick ||
|
|
161
|
+
a.pitch - b.pitch ||
|
|
162
|
+
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
|
163
|
+
);
|
|
164
|
+
const { sampleRate } = timing;
|
|
165
|
+
const voices: SamplerVoice[] = [];
|
|
166
|
+
const lastInGroup = new Map<string, SamplerVoice>();
|
|
167
|
+
const maxVoices = SCORE_LIMITS.maxSamplerVoices;
|
|
168
|
+
const stop = (voice: SamplerVoice, at: number, seconds: number) => {
|
|
169
|
+
if (voice.end <= at) return;
|
|
170
|
+
const fade = Math.max(1, Math.round(seconds * sampleRate));
|
|
171
|
+
const end = Math.max(voice.start + 1, at + fade);
|
|
172
|
+
if (end >= voice.end) return;
|
|
173
|
+
voice.end = end;
|
|
174
|
+
voice.fade = Math.min(fade, end - voice.start);
|
|
175
|
+
};
|
|
176
|
+
for (const note of ordered) {
|
|
177
|
+
const target = resolve(note.pitch);
|
|
178
|
+
if (!target) continue;
|
|
179
|
+
const ref: SampleRef | undefined = sampler.voices[target.voice];
|
|
180
|
+
const sample = bank.voices.get(sampleKey(track.id, target.voice));
|
|
181
|
+
if (!ref || !sample) continue;
|
|
182
|
+
const speed = ref.speed ?? 1;
|
|
183
|
+
const regionStart = (ref.begin ?? 0) * sample.frames;
|
|
184
|
+
const regionEnd = (ref.end ?? 1) * sample.frames;
|
|
185
|
+
const held = noteLengthFrames(note, timing);
|
|
186
|
+
// `fit`, `unit: "c"` and `unit: "s"` give the window a duration; the
|
|
187
|
+
// keyed ratio still repitches on top.
|
|
188
|
+
const unit = ref.unit ?? "r";
|
|
189
|
+
const seconds = ref.fit
|
|
190
|
+
? held / sampleRate
|
|
191
|
+
: unit === "c"
|
|
192
|
+
? (timing.score.beatsPerBar * 60) /
|
|
193
|
+
timing.score.tempoBpm /
|
|
194
|
+
Math.abs(speed)
|
|
195
|
+
: unit === "s"
|
|
196
|
+
? Math.abs(speed)
|
|
197
|
+
: undefined;
|
|
198
|
+
const step =
|
|
199
|
+
seconds === undefined
|
|
200
|
+
? ((Math.abs(speed) * sample.sampleRate) / sampleRate) * target.ratio
|
|
201
|
+
: ((regionEnd - regionStart) / (seconds * sampleRate)) * target.ratio;
|
|
202
|
+
const natural = Math.max(1, Math.floor((regionEnd - regionStart) / step));
|
|
203
|
+
const release = Math.max(1, Math.round(RELEASE_SECONDS * sampleRate));
|
|
204
|
+
const loop = ref.loop === true;
|
|
205
|
+
const clipped =
|
|
206
|
+
ref.clip === undefined ? held : Math.max(1, Math.floor(held * ref.clip));
|
|
207
|
+
let length: number;
|
|
208
|
+
let fade: number;
|
|
209
|
+
if (loop) {
|
|
210
|
+
length = clipped + release;
|
|
211
|
+
fade = release;
|
|
212
|
+
} else if (
|
|
213
|
+
(sampler.mode === "keyed" || ref.clip !== undefined) &&
|
|
214
|
+
clipped + release < natural
|
|
215
|
+
) {
|
|
216
|
+
length = clipped + release;
|
|
217
|
+
fade = release;
|
|
218
|
+
} else {
|
|
219
|
+
length = natural;
|
|
220
|
+
fade = Math.max(1, Math.round(END_FADE_SECONDS * sampleRate));
|
|
221
|
+
}
|
|
222
|
+
// accelerate: the rate ramps by `accelerate`× over the planned length;
|
|
223
|
+
// a non-looping voice ends where the ramped read runs out (or stops).
|
|
224
|
+
const accelerate = ref.accelerate ?? 0;
|
|
225
|
+
const ramp = accelerate === 0 ? 0 : accelerate / length;
|
|
226
|
+
if (ramp !== 0 && !loop) {
|
|
227
|
+
const reach = Math.min(
|
|
228
|
+
framesToTravel(regionEnd - regionStart, step, ramp),
|
|
229
|
+
ramp < 0 ? -1 / ramp : Infinity,
|
|
230
|
+
);
|
|
231
|
+
const until = Math.max(1, Math.floor(reach));
|
|
232
|
+
if (until < length) {
|
|
233
|
+
length = until;
|
|
234
|
+
fade = Math.max(1, Math.round(END_FADE_SECONDS * sampleRate));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const loopFrom =
|
|
238
|
+
ref.loopBegin === undefined ? regionStart : ref.loopBegin * sample.frames;
|
|
239
|
+
const loopTo =
|
|
240
|
+
ref.loopEnd === undefined ? regionEnd : ref.loopEnd * sample.frames;
|
|
241
|
+
const start = noteStartFrame(note, timing);
|
|
242
|
+
const voice: SamplerVoice = {
|
|
243
|
+
voice: target.voice,
|
|
244
|
+
sample,
|
|
245
|
+
startTick: note.startTick,
|
|
246
|
+
start,
|
|
247
|
+
end: start + length,
|
|
248
|
+
fade: Math.min(fade, length),
|
|
249
|
+
step,
|
|
250
|
+
reverse: speed < 0,
|
|
251
|
+
loop,
|
|
252
|
+
regionStart,
|
|
253
|
+
regionEnd,
|
|
254
|
+
gain: (ref.gain ?? 1) * Math.max(0, Math.min(1, note.velocity)),
|
|
255
|
+
choke: ref.choke,
|
|
256
|
+
loopStart: speed < 0 ? regionEnd - loopTo : loopFrom - regionStart,
|
|
257
|
+
loopSpan: loopTo - loopFrom,
|
|
258
|
+
ramp,
|
|
259
|
+
squiz: ref.squiz ?? 1,
|
|
260
|
+
};
|
|
261
|
+
if (voice.choke !== undefined) {
|
|
262
|
+
const previous = lastInGroup.get(voice.choke);
|
|
263
|
+
if (previous) stop(previous, start, CHOKE_SECONDS);
|
|
264
|
+
lastInGroup.set(voice.choke, voice);
|
|
265
|
+
}
|
|
266
|
+
// Polyphony: steal the oldest sounding voice beyond the limit.
|
|
267
|
+
const sounding = voices.filter((other) => other.end > start);
|
|
268
|
+
if (sounding.length >= maxVoices)
|
|
269
|
+
for (const other of sounding.slice(0, sounding.length - maxVoices + 1))
|
|
270
|
+
stop(other, start, CHOKE_SECONDS);
|
|
271
|
+
voices.push(voice);
|
|
272
|
+
}
|
|
273
|
+
return voices;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Linear-interpolated read, clamped to the sample. */
|
|
277
|
+
function readAt(mono: Float32Array, position: number): number {
|
|
278
|
+
const last = mono.length - 1;
|
|
279
|
+
if (position <= 0) return mono[0]!;
|
|
280
|
+
if (position >= last) return mono[last]!;
|
|
281
|
+
const base = Math.floor(position);
|
|
282
|
+
const a = mono[base]!;
|
|
283
|
+
return a + (mono[base + 1]! - a) * (position - base);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Mix planned voices into a mono buffer. `gainAt(tick)` is the track's
|
|
288
|
+
* volume (with automation) at a score tick.
|
|
289
|
+
*/
|
|
290
|
+
export function renderSamplerVoices(
|
|
291
|
+
target: Float64Array,
|
|
292
|
+
voices: readonly SamplerVoice[],
|
|
293
|
+
timing: SamplerTiming,
|
|
294
|
+
gainAt: (tick: number) => number,
|
|
295
|
+
): void {
|
|
296
|
+
const { sampleRate, score } = timing;
|
|
297
|
+
const samplesPerTick =
|
|
298
|
+
(sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat);
|
|
299
|
+
const attack = Math.max(1, ATTACK_SECONDS * sampleRate);
|
|
300
|
+
for (const voice of voices) {
|
|
301
|
+
const { sample, step, regionStart, regionEnd, reverse, loop } = voice;
|
|
302
|
+
const { loopStart, loopSpan, ramp } = voice;
|
|
303
|
+
const mono = sample.mono;
|
|
304
|
+
const span = regionEnd - regionStart;
|
|
305
|
+
const crossfade = loop
|
|
306
|
+
? Math.min(LOOP_CROSSFADE_SECONDS * sample.sampleRate, loopSpan / 4)
|
|
307
|
+
: 0;
|
|
308
|
+
const period = loopSpan - crossfade;
|
|
309
|
+
const end = Math.min(target.length, voice.end);
|
|
310
|
+
const fadeFrom = voice.end - voice.fade;
|
|
311
|
+
const level = SAMPLE_LEVEL * voice.gain;
|
|
312
|
+
const at = (offset: number) =>
|
|
313
|
+
readAt(mono, reverse ? regionEnd - 1 - offset : regionStart + offset);
|
|
314
|
+
// The raw read at an elapsed frame, or undefined past a one-shot's end.
|
|
315
|
+
const read = (elapsed: number): number | undefined => {
|
|
316
|
+
const travelled =
|
|
317
|
+
ramp === 0 ? elapsed * step : travelAt(elapsed, step, ramp);
|
|
318
|
+
if (!loop) return travelled >= span ? undefined : at(travelled);
|
|
319
|
+
if (travelled < loopStart) return at(travelled);
|
|
320
|
+
const into = travelled - loopStart;
|
|
321
|
+
const phase =
|
|
322
|
+
into < crossfade || period <= 0
|
|
323
|
+
? into % Math.max(loopSpan, 1e-9)
|
|
324
|
+
: crossfade + ((into - crossfade) % period);
|
|
325
|
+
let value = at(loopStart + phase);
|
|
326
|
+
if (crossfade > 0 && phase >= period) {
|
|
327
|
+
const mix = (phase - period) / crossfade;
|
|
328
|
+
value = value * (1 - mix) + at(loopStart + phase - period) * mix;
|
|
329
|
+
}
|
|
330
|
+
return value;
|
|
331
|
+
};
|
|
332
|
+
const squizzed =
|
|
333
|
+
voice.squiz > 1
|
|
334
|
+
? squizBuffer(read, end - voice.start, voice.squiz)
|
|
335
|
+
: undefined;
|
|
336
|
+
for (let index = voice.start; index < end; index += 1) {
|
|
337
|
+
const elapsed = index - voice.start;
|
|
338
|
+
const value = squizzed ? squizzed[elapsed]! : read(elapsed);
|
|
339
|
+
if (value === undefined) break;
|
|
340
|
+
let envelope = Math.min(1, elapsed / attack);
|
|
341
|
+
if (index >= fadeFrom) envelope *= (voice.end - index) / voice.fade;
|
|
342
|
+
target[index]! +=
|
|
343
|
+
value *
|
|
344
|
+
envelope *
|
|
345
|
+
level *
|
|
346
|
+
gainAt(voice.startTick + elapsed / samplesPerTick);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Longest zero-crossing cycle `squiz` treats as one (longer runs split). */
|
|
352
|
+
const SQUIZ_MAX_CYCLE = 4096;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Squiz (Tidal/SuperDirt's description: a simplistic pitch raiser): cut the
|
|
356
|
+
* voice at its upward zero crossings and play each cycle `ratio`× faster,
|
|
357
|
+
* repeating it to fill the cycle's original length. Length is kept, the
|
|
358
|
+
* pitch rises, and the timbre turns buzzy, as documented.
|
|
359
|
+
*/
|
|
360
|
+
export function squizBuffer(
|
|
361
|
+
read: (elapsed: number) => number | undefined,
|
|
362
|
+
length: number,
|
|
363
|
+
ratio: number,
|
|
364
|
+
): Float64Array {
|
|
365
|
+
const raw = new Float64Array(Math.max(0, length));
|
|
366
|
+
let filled = raw.length;
|
|
367
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
368
|
+
const value = read(i);
|
|
369
|
+
if (value === undefined) {
|
|
370
|
+
filled = i;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
raw[i] = value;
|
|
374
|
+
}
|
|
375
|
+
const out = new Float64Array(raw.length);
|
|
376
|
+
let start = 0;
|
|
377
|
+
while (start < filled) {
|
|
378
|
+
let stop = start + 1;
|
|
379
|
+
while (
|
|
380
|
+
stop < filled &&
|
|
381
|
+
stop - start < SQUIZ_MAX_CYCLE &&
|
|
382
|
+
!(raw[stop - 1]! < 0 && raw[stop]! >= 0)
|
|
383
|
+
)
|
|
384
|
+
stop += 1;
|
|
385
|
+
const size = stop - start;
|
|
386
|
+
for (let i = 0; i < size; i += 1) {
|
|
387
|
+
const position = (i * ratio) % size;
|
|
388
|
+
const base = Math.floor(position);
|
|
389
|
+
const next = base + 1 < size ? base + 1 : 0;
|
|
390
|
+
const frac = position - base;
|
|
391
|
+
out[start + i] =
|
|
392
|
+
raw[start + base]! + (raw[start + next]! - raw[start + base]!) * frac;
|
|
393
|
+
}
|
|
394
|
+
start = stop;
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Seconds a sampler track rings past the loop end (bounded by the caller),
|
|
401
|
+
* so loop renders fold whole samples onto the start.
|
|
402
|
+
*/
|
|
403
|
+
export function samplerTailSeconds(
|
|
404
|
+
score: TrackScore,
|
|
405
|
+
bank: SampleBank,
|
|
406
|
+
sampleRate: number,
|
|
407
|
+
): number {
|
|
408
|
+
if (bank.voices.size === 0) return 0;
|
|
409
|
+
const loopEnd =
|
|
410
|
+
((score.bars * score.beatsPerBar * 60) / score.tempoBpm) * sampleRate;
|
|
411
|
+
const timing = { score, sampleRate };
|
|
412
|
+
let latest = 0;
|
|
413
|
+
for (const track of score.tracks) {
|
|
414
|
+
if (!track.sampler) continue;
|
|
415
|
+
const notes = score.notes.filter((note) => note.trackId === track.id);
|
|
416
|
+
for (const voice of planSamplerVoices(track, notes, bank, timing))
|
|
417
|
+
latest = Math.max(latest, voice.end - loopEnd);
|
|
418
|
+
}
|
|
419
|
+
return latest / sampleRate;
|
|
420
|
+
}
|