@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
package/src/audio/player.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { unlink } from "node:fs/promises";
|
|
2
2
|
import type { TrackScore } from "../../core/score.ts";
|
|
3
3
|
import { PlaybackLock } from "./lock.ts";
|
|
4
|
+
import { SampleLibrary, hasSamplerTracks, type SampleBank } from "./samples.ts";
|
|
4
5
|
import { RENDER_CHANNELS, encodeWav, renderScorePcm } from "./wav.ts";
|
|
5
6
|
|
|
6
7
|
/** Small best-effort local player. The score and transport remain testable without a sound device. */
|
|
@@ -10,7 +11,12 @@ export class LoopPlayer {
|
|
|
10
11
|
private readonly lock: PlaybackLock;
|
|
11
12
|
private readonly lockPath: string | undefined;
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
private library: SampleLibrary | undefined;
|
|
15
|
+
|
|
16
|
+
public constructor(
|
|
17
|
+
lockPath?: string,
|
|
18
|
+
private readonly projectRoot?: string,
|
|
19
|
+
) {
|
|
14
20
|
this.lockPath = lockPath;
|
|
15
21
|
this.lock = new PlaybackLock(lockPath);
|
|
16
22
|
}
|
|
@@ -33,8 +39,13 @@ export class LoopPlayer {
|
|
|
33
39
|
await this.stopAsync();
|
|
34
40
|
if (process.env.DAWG_AUDIO === "0") return;
|
|
35
41
|
if (this.lockPath && !(await this.acquireLock())) return;
|
|
42
|
+
let samples: SampleBank | undefined;
|
|
43
|
+
if (this.projectRoot !== undefined && hasSamplerTracks(score)) {
|
|
44
|
+
this.library ??= new SampleLibrary({ projectRoot: this.projectRoot });
|
|
45
|
+
samples = await this.library.load(score).catch(() => undefined);
|
|
46
|
+
}
|
|
36
47
|
const path = `/tmp/dawg-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.wav`;
|
|
37
|
-
await Bun.write(path, renderLoopWav(score, beat));
|
|
48
|
+
await Bun.write(path, renderLoopWav(score, beat, samples));
|
|
38
49
|
this.file = path;
|
|
39
50
|
this.spawn(path);
|
|
40
51
|
}
|
|
@@ -102,8 +113,12 @@ export class LoopPlayer {
|
|
|
102
113
|
}
|
|
103
114
|
|
|
104
115
|
/** A loop-folded WAV whose first frame is `beat` into the loop. */
|
|
105
|
-
export function renderLoopWav(
|
|
106
|
-
|
|
116
|
+
export function renderLoopWav(
|
|
117
|
+
score: TrackScore,
|
|
118
|
+
beat = 0,
|
|
119
|
+
samples?: SampleBank,
|
|
120
|
+
): Uint8Array {
|
|
121
|
+
const audio = renderScorePcm(score, { loop: true, samples });
|
|
107
122
|
const framesPerBeat = (60 * audio.sampleRate) / score.tempoBpm;
|
|
108
123
|
const start =
|
|
109
124
|
((Math.round(Math.max(0, beat) * framesPerBeat) % audio.frames) +
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sound preview: the short score an audition loop (or `preview_sound`)
|
|
3
|
+
* renders for one track, and a few numbers that describe a render.
|
|
4
|
+
*
|
|
5
|
+
* Like `live.ts` and `audition.ts` this only builds a smaller score; the
|
|
6
|
+
* renderer, stem cache and every effect (filters, buses, impulse
|
|
7
|
+
* responses) are the ordinary ones, so a preview sounds exactly like the
|
|
8
|
+
* same bars of the song.
|
|
9
|
+
*
|
|
10
|
+
* The loop source is the track's own notes over a region of at most
|
|
11
|
+
* `MAX_PREVIEW_BARS` bars: the whole loop when it is that short, otherwise
|
|
12
|
+
* two bars from the bar under the playhead (moved to the track's first
|
|
13
|
+
* bar with notes when those two bars are empty). A track with no notes
|
|
14
|
+
* plays a short phrase chosen by its role: a chord for pads and keys, a
|
|
15
|
+
* riff for bass and leads, a groove for kits, and one held note for
|
|
16
|
+
* wavetables so position changes are audible.
|
|
17
|
+
*/
|
|
18
|
+
import { isDrumInstrument } from "../../core/drums.ts";
|
|
19
|
+
import { MODES, parseKey } from "../../core/chords.ts";
|
|
20
|
+
import { rhythmVoicePitch } from "../../core/rhythm.ts";
|
|
21
|
+
import {
|
|
22
|
+
AUTOMATION_LANES,
|
|
23
|
+
TrackScore,
|
|
24
|
+
isSamplerInstrument,
|
|
25
|
+
isWavetableInstrument,
|
|
26
|
+
samplerVoiceSlots,
|
|
27
|
+
type AutomationPoint,
|
|
28
|
+
type NoteInput,
|
|
29
|
+
type Track,
|
|
30
|
+
} from "../../core/score.ts";
|
|
31
|
+
import { interpolateAutomation } from "./effects/common.ts";
|
|
32
|
+
import { RENDER_CHANNELS } from "./wav.ts";
|
|
33
|
+
|
|
34
|
+
/** Longest preview loop, in bars. */
|
|
35
|
+
export const MAX_PREVIEW_BARS = 4;
|
|
36
|
+
/** Region length when the song is longer than `MAX_PREVIEW_BARS`. */
|
|
37
|
+
export const PREVIEW_REGION_BARS = 2;
|
|
38
|
+
|
|
39
|
+
export type PreviewRegion = Readonly<{ startBar: number; bars: number }>;
|
|
40
|
+
|
|
41
|
+
/** What the loop plays: the track's notes, or a default phrase. */
|
|
42
|
+
export type PreviewSource = "notes" | "phrase";
|
|
43
|
+
|
|
44
|
+
export type PhraseRole = "chord" | "riff" | "lead" | "groove" | "drone";
|
|
45
|
+
|
|
46
|
+
export type PreviewOptions = Readonly<{
|
|
47
|
+
/** Full mix with the track (true) or the track alone (default). */
|
|
48
|
+
context?: boolean;
|
|
49
|
+
/** Playhead or cursor beat, to pick the region in a long song. */
|
|
50
|
+
beat?: number;
|
|
51
|
+
/** Overrides the region choice (tests, the agent tool). */
|
|
52
|
+
region?: PreviewRegion;
|
|
53
|
+
}>;
|
|
54
|
+
|
|
55
|
+
export type Preview = Readonly<{
|
|
56
|
+
score: TrackScore;
|
|
57
|
+
region: PreviewRegion;
|
|
58
|
+
source: PreviewSource;
|
|
59
|
+
/** Set when `source` is `phrase`. */
|
|
60
|
+
role?: PhraseRole;
|
|
61
|
+
}>;
|
|
62
|
+
|
|
63
|
+
/** The bars an audition loops for `trackId` (see the module comment). */
|
|
64
|
+
export function previewRegion(
|
|
65
|
+
score: TrackScore,
|
|
66
|
+
trackId: string,
|
|
67
|
+
beat = 0,
|
|
68
|
+
): PreviewRegion {
|
|
69
|
+
if (score.bars <= MAX_PREVIEW_BARS) return { startBar: 0, bars: score.bars };
|
|
70
|
+
const bars = PREVIEW_REGION_BARS;
|
|
71
|
+
const barTicks = score.beatsPerBar * score.ticksPerBeat;
|
|
72
|
+
const last = score.bars - bars;
|
|
73
|
+
const clampBar = (bar: number) =>
|
|
74
|
+
Math.max(0, Math.min(last, Number.isFinite(bar) ? Math.floor(bar) : 0));
|
|
75
|
+
let startBar = clampBar(beat / score.beatsPerBar);
|
|
76
|
+
const notes = score.notes.filter((note) => note.trackId === trackId);
|
|
77
|
+
const inRegion = (from: number) =>
|
|
78
|
+
notes.some(
|
|
79
|
+
(note) =>
|
|
80
|
+
note.startTick >= from * barTicks &&
|
|
81
|
+
note.startTick < (from + bars) * barTicks,
|
|
82
|
+
);
|
|
83
|
+
if (notes.length > 0 && !inRegion(startBar)) {
|
|
84
|
+
const first = Math.min(...notes.map((note) => note.startTick));
|
|
85
|
+
startBar = clampBar(first / barTicks);
|
|
86
|
+
}
|
|
87
|
+
return { startBar, bars };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The role a default phrase plays for `track` (see the module comment). */
|
|
91
|
+
export function phraseRole(track: Track): PhraseRole {
|
|
92
|
+
if (isDrumInstrument(track.instrument)) return "groove";
|
|
93
|
+
if (isSamplerInstrument(track.instrument))
|
|
94
|
+
return track.sampler?.mode === "keyed" ? "chord" : "groove";
|
|
95
|
+
if (isWavetableInstrument(track.instrument)) return "drone";
|
|
96
|
+
const words = `${track.instrument} ${track.name} ${track.id}`.toLowerCase();
|
|
97
|
+
if (/\b(bass|sub|808)/.test(words)) return "riff";
|
|
98
|
+
if (/\b(lead|arp|pluck|saw|square|pulse|z_|mono|melody|hook)/.test(words))
|
|
99
|
+
return "lead";
|
|
100
|
+
return "chord";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A short phrase for an empty track, in the song's key (C major without
|
|
105
|
+
* one), filling `bars` bars from tick 0.
|
|
106
|
+
*/
|
|
107
|
+
export function defaultPhrase(
|
|
108
|
+
score: TrackScore,
|
|
109
|
+
track: Track,
|
|
110
|
+
bars: number,
|
|
111
|
+
role: PhraseRole = phraseRole(track),
|
|
112
|
+
): NoteInput[] {
|
|
113
|
+
const tpb = score.ticksPerBeat;
|
|
114
|
+
const barTicks = score.beatsPerBar * tpb;
|
|
115
|
+
const total = Math.max(1, bars) * barTicks;
|
|
116
|
+
const key = parseKey(score.key) ?? { tonic: 0, mode: "major" as const };
|
|
117
|
+
const scale = MODES[key.mode];
|
|
118
|
+
// Scale degree (0-based, may exceed 6) to MIDI pitch above `base`.
|
|
119
|
+
const degree = (base: number, step: number) =>
|
|
120
|
+
base + key.tonic + scale[((step % 7) + 7) % 7]! + 12 * Math.floor(step / 7);
|
|
121
|
+
const notes: NoteInput[] = [];
|
|
122
|
+
const add = (
|
|
123
|
+
startTick: number,
|
|
124
|
+
durationTicks: number,
|
|
125
|
+
pitch: number,
|
|
126
|
+
velocity = 0.8,
|
|
127
|
+
) => {
|
|
128
|
+
if (startTick >= total) return;
|
|
129
|
+
notes.push({
|
|
130
|
+
id: `preview-${notes.length + 1}`,
|
|
131
|
+
trackId: track.id,
|
|
132
|
+
startTick,
|
|
133
|
+
durationTicks: Math.max(1, Math.min(durationTicks, total - startTick)),
|
|
134
|
+
pitch: Math.max(0, Math.min(127, pitch)),
|
|
135
|
+
velocity,
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
switch (role) {
|
|
139
|
+
case "drone":
|
|
140
|
+
add(0, total, degree(48, 0));
|
|
141
|
+
break;
|
|
142
|
+
case "chord":
|
|
143
|
+
// I then IV, one bar each, voiced around middle C.
|
|
144
|
+
for (let bar = 0; bar < bars; bar += 1) {
|
|
145
|
+
const root = bar % 2 === 0 ? 0 : 3;
|
|
146
|
+
for (const step of [root, root + 2, root + 4])
|
|
147
|
+
add(bar * barTicks, barTicks - tpb / 8, degree(48, step), 0.7);
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
case "riff":
|
|
151
|
+
case "lead": {
|
|
152
|
+
const base = role === "riff" ? 36 : 60;
|
|
153
|
+
const steps =
|
|
154
|
+
role === "riff" ? [0, 0, 4, 0, 7, 0, 4, 2] : [0, 2, 4, 2, 7, 4, 5, 4];
|
|
155
|
+
const eighth = tpb / 2;
|
|
156
|
+
for (let at = 0, index = 0; at < total; at += eighth, index += 1)
|
|
157
|
+
add(
|
|
158
|
+
at,
|
|
159
|
+
eighth * 0.9,
|
|
160
|
+
degree(base, steps[index % steps.length]!),
|
|
161
|
+
index % 2 === 0 ? 0.85 : 0.7,
|
|
162
|
+
);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "groove": {
|
|
166
|
+
const voices = grooveVoices(track);
|
|
167
|
+
const eighth = tpb / 2;
|
|
168
|
+
for (let at = 0, index = 0; at < total; at += eighth, index += 1) {
|
|
169
|
+
const beat = index / 2;
|
|
170
|
+
const inBar = beat % score.beatsPerBar;
|
|
171
|
+
if (voices.hat !== undefined) add(at, eighth / 2, voices.hat, 0.55);
|
|
172
|
+
if (Number.isInteger(beat) && inBar % 2 === 0)
|
|
173
|
+
add(at, eighth, voices.kick, 0.95);
|
|
174
|
+
if (
|
|
175
|
+
voices.snare !== undefined &&
|
|
176
|
+
Number.isInteger(beat) &&
|
|
177
|
+
inBar % 2 === 1
|
|
178
|
+
)
|
|
179
|
+
add(at, eighth, voices.snare, 0.85);
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return notes;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function grooveVoices(track: Track): {
|
|
188
|
+
kick: number;
|
|
189
|
+
snare?: number;
|
|
190
|
+
hat?: number;
|
|
191
|
+
} {
|
|
192
|
+
if (isSamplerInstrument(track.instrument) && track.sampler) {
|
|
193
|
+
const slots = [...samplerVoiceSlots(track.sampler).values()];
|
|
194
|
+
const named = (name: string) => rhythmVoicePitch(track, name);
|
|
195
|
+
const kick = named("bd") ?? named("kick") ?? slots[0] ?? 36;
|
|
196
|
+
const snare = named("sd") ?? named("snare") ?? slots[1];
|
|
197
|
+
const hat = named("hh") ?? named("hat") ?? slots[2];
|
|
198
|
+
return {
|
|
199
|
+
kick,
|
|
200
|
+
...(snare !== undefined ? { snare } : {}),
|
|
201
|
+
...(hat !== undefined ? { hat } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
kick: rhythmVoicePitch(track, "kick") ?? 36,
|
|
206
|
+
snare: rhythmVoicePitch(track, "snare") ?? 38,
|
|
207
|
+
hat: rhythmVoicePitch(track, "hat") ?? 42,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const TRACK_LANES = Object.values(AUTOMATION_LANES).map((lane) => lane.field);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Points of a lane over [start, end) moved to start at tick 0. A lane with
|
|
215
|
+
* a point at or before `start` gets one at 0 holding the value there, so the
|
|
216
|
+
* slice starts where the song is, and the first point past `end` is kept so
|
|
217
|
+
* a ramp across the region still ramps; later lanes keep their fallback.
|
|
218
|
+
*/
|
|
219
|
+
export function sliceLane(
|
|
220
|
+
points: readonly AutomationPoint[],
|
|
221
|
+
start: number,
|
|
222
|
+
end: number,
|
|
223
|
+
): AutomationPoint[] {
|
|
224
|
+
if (points.length === 0) return [];
|
|
225
|
+
const out: AutomationPoint[] = [];
|
|
226
|
+
if (start > 0 && points[0]!.tick <= start)
|
|
227
|
+
out.push({ tick: 0, value: interpolateAutomation(points, start, 0) });
|
|
228
|
+
for (const point of points) {
|
|
229
|
+
if (start > 0 ? point.tick <= start : point.tick < 0) continue;
|
|
230
|
+
out.push({ tick: point.tick - start, value: point.value });
|
|
231
|
+
// One point past the end keeps a ramp through the region ramping.
|
|
232
|
+
if (point.tick >= end) break;
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function sliceTrack(track: Track, start: number, end: number): Track {
|
|
238
|
+
const { rhythm: _rhythm, ...rest } = track;
|
|
239
|
+
if (start <= 0 && end === Infinity) return rest;
|
|
240
|
+
const sliced: Record<string, unknown> = { ...rest };
|
|
241
|
+
for (const field of TRACK_LANES) {
|
|
242
|
+
const lane = track[field];
|
|
243
|
+
if (lane && lane.length > 0) sliced[field] = sliceLane(lane, start, end);
|
|
244
|
+
}
|
|
245
|
+
if (track.fxAutomation) {
|
|
246
|
+
const lanes: Record<string, AutomationPoint[]> = {};
|
|
247
|
+
for (const [name, points] of Object.entries(track.fxAutomation))
|
|
248
|
+
if (points && points.length > 0)
|
|
249
|
+
lanes[name] = sliceLane(points, start, end);
|
|
250
|
+
sliced.fxAutomation = lanes;
|
|
251
|
+
}
|
|
252
|
+
return sliced as Track;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The score an audition plays for `trackId`: the region's notes and
|
|
257
|
+
* automation from tick 0, the track soloed (or the full mix with it in
|
|
258
|
+
* `context`), and a default phrase when the track has no notes at all.
|
|
259
|
+
* Undefined when the track does not exist.
|
|
260
|
+
*/
|
|
261
|
+
export function previewScore(
|
|
262
|
+
score: TrackScore,
|
|
263
|
+
trackId: string,
|
|
264
|
+
options: PreviewOptions = {},
|
|
265
|
+
): Preview | undefined {
|
|
266
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
267
|
+
if (!track) return undefined;
|
|
268
|
+
const region = options.region ?? previewRegion(score, trackId, options.beat);
|
|
269
|
+
const bars = Math.max(
|
|
270
|
+
1,
|
|
271
|
+
Math.min(region.bars, score.bars - Math.max(0, region.startBar)),
|
|
272
|
+
);
|
|
273
|
+
const startBar = Math.max(0, Math.min(region.startBar, score.bars - 1));
|
|
274
|
+
const barTicks = score.beatsPerBar * score.ticksPerBeat;
|
|
275
|
+
const start = startBar * barTicks;
|
|
276
|
+
const end = start + bars * barTicks;
|
|
277
|
+
const context = options.context === true;
|
|
278
|
+
const anySolo = score.tracks.some((candidate) => candidate.solo === true);
|
|
279
|
+
const tracks = score.tracks
|
|
280
|
+
.filter((candidate) => context || candidate.id === trackId)
|
|
281
|
+
.map((candidate) => {
|
|
282
|
+
const sliced = sliceTrack(candidate, start, end);
|
|
283
|
+
if (candidate.id !== trackId) return sliced;
|
|
284
|
+
// The focused track always sounds, even when muted or not soloed.
|
|
285
|
+
return {
|
|
286
|
+
...sliced,
|
|
287
|
+
muted: false,
|
|
288
|
+
...(context && anySolo ? { solo: true } : {}),
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
const own = score.notes.some((note) => note.trackId === trackId);
|
|
292
|
+
const notes: NoteInput[] = score.notes
|
|
293
|
+
.filter(
|
|
294
|
+
(note) =>
|
|
295
|
+
(context || note.trackId === trackId) &&
|
|
296
|
+
note.startTick >= start &&
|
|
297
|
+
note.startTick < end,
|
|
298
|
+
)
|
|
299
|
+
.map((note) => ({ ...note, startTick: note.startTick - start }));
|
|
300
|
+
let role: PhraseRole | undefined;
|
|
301
|
+
if (!own) {
|
|
302
|
+
role = phraseRole(track);
|
|
303
|
+
notes.push(...defaultPhrase(score, track, bars, role));
|
|
304
|
+
}
|
|
305
|
+
const preview = new TrackScore({
|
|
306
|
+
tempoBpm: score.tempoBpm,
|
|
307
|
+
beatsPerBar: score.beatsPerBar,
|
|
308
|
+
bars,
|
|
309
|
+
ticksPerBeat: score.ticksPerBeat,
|
|
310
|
+
key: score.key,
|
|
311
|
+
tracks,
|
|
312
|
+
notes,
|
|
313
|
+
});
|
|
314
|
+
return {
|
|
315
|
+
score: preview,
|
|
316
|
+
region: { startBar, bars },
|
|
317
|
+
source: own ? "notes" : "phrase",
|
|
318
|
+
...(role ? { role } : {}),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── analysis ──────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
export type SoundStats = Readonly<{
|
|
325
|
+
/** RMS level of both channels, dBFS (-inf for silence, floored at -120). */
|
|
326
|
+
rmsDb: number;
|
|
327
|
+
/** Highest absolute sample, dBFS. */
|
|
328
|
+
peakDb: number;
|
|
329
|
+
/** Spectral centroid of the mono sum, Hz (0 for silence). */
|
|
330
|
+
centroidHz: number;
|
|
331
|
+
/** Samples at full scale (a clip). */
|
|
332
|
+
clipped: number;
|
|
333
|
+
seconds: number;
|
|
334
|
+
}>;
|
|
335
|
+
|
|
336
|
+
const FLOOR_DB = -120;
|
|
337
|
+
|
|
338
|
+
function toDb(linear: number): number {
|
|
339
|
+
return linear > 0 ? Math.max(FLOOR_DB, 20 * Math.log10(linear)) : FLOOR_DB;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Loudness, peak and brightness of interleaved stereo 16-bit PCM. The
|
|
344
|
+
* centroid averages power spectra of up to 64 Hann-windowed 1024-point
|
|
345
|
+
* frames spread over the clip (a direct DFT on a decimated grid of bins,
|
|
346
|
+
* so it stays cheap and dependency-free).
|
|
347
|
+
*/
|
|
348
|
+
export function analyzePcm(pcm: Int16Array, sampleRate: number): SoundStats {
|
|
349
|
+
const frames = Math.floor(pcm.length / RENDER_CHANNELS);
|
|
350
|
+
return {
|
|
351
|
+
...levelOf(pcm),
|
|
352
|
+
centroidHz: spectralCentroid(pcm, frames, sampleRate),
|
|
353
|
+
seconds: frames / sampleRate,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** RMS and peak dBFS and the clipped sample count (no spectrum; cheap). */
|
|
358
|
+
export type SoundLevel = Pick<SoundStats, "rmsDb" | "peakDb" | "clipped">;
|
|
359
|
+
|
|
360
|
+
export function levelOf(pcm: Int16Array): SoundLevel {
|
|
361
|
+
const samples = Math.floor(pcm.length / RENDER_CHANNELS) * RENDER_CHANNELS;
|
|
362
|
+
let sum = 0;
|
|
363
|
+
let peak = 0;
|
|
364
|
+
let clipped = 0;
|
|
365
|
+
for (let index = 0; index < samples; index += 1) {
|
|
366
|
+
const sample = pcm[index]!;
|
|
367
|
+
const value = sample / 32768;
|
|
368
|
+
sum += value * value;
|
|
369
|
+
const magnitude = Math.abs(value);
|
|
370
|
+
if (magnitude > peak) peak = magnitude;
|
|
371
|
+
if (sample >= 32767 || sample <= -32768) clipped += 1;
|
|
372
|
+
}
|
|
373
|
+
const rms = samples > 0 ? Math.sqrt(sum / samples) : 0;
|
|
374
|
+
return { rmsDb: toDb(rms), peakDb: toDb(peak), clipped };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const FFT_SIZE = 1024;
|
|
378
|
+
const MAX_FRAMES = 64;
|
|
379
|
+
const BIN_STEP = 2;
|
|
380
|
+
|
|
381
|
+
function spectralCentroid(
|
|
382
|
+
pcm: Int16Array,
|
|
383
|
+
frames: number,
|
|
384
|
+
sampleRate: number,
|
|
385
|
+
): number {
|
|
386
|
+
if (frames < FFT_SIZE) return 0;
|
|
387
|
+
const count = Math.min(MAX_FRAMES, Math.floor(frames / FFT_SIZE));
|
|
388
|
+
const hop = Math.floor((frames - FFT_SIZE) / Math.max(1, count - 1));
|
|
389
|
+
const bins = FFT_SIZE / 2;
|
|
390
|
+
const power = new Float64Array(bins);
|
|
391
|
+
const window = new Float64Array(FFT_SIZE);
|
|
392
|
+
for (let index = 0; index < FFT_SIZE; index += 1)
|
|
393
|
+
window[index] = 0.5 - 0.5 * Math.cos((2 * Math.PI * index) / FFT_SIZE);
|
|
394
|
+
const block = new Float64Array(FFT_SIZE);
|
|
395
|
+
const cos = new Float64Array(FFT_SIZE);
|
|
396
|
+
const sin = new Float64Array(FFT_SIZE);
|
|
397
|
+
for (let index = 0; index < FFT_SIZE; index += 1) {
|
|
398
|
+
cos[index] = Math.cos((2 * Math.PI * index) / FFT_SIZE);
|
|
399
|
+
sin[index] = Math.sin((2 * Math.PI * index) / FFT_SIZE);
|
|
400
|
+
}
|
|
401
|
+
for (let frame = 0; frame < count; frame += 1) {
|
|
402
|
+
const offset = frame * hop;
|
|
403
|
+
let energy = 0;
|
|
404
|
+
for (let index = 0; index < FFT_SIZE; index += 1) {
|
|
405
|
+
const at = (offset + index) * RENDER_CHANNELS;
|
|
406
|
+
const mono = (pcm[at]! + pcm[at + 1]!) / 65536;
|
|
407
|
+
block[index] = mono * window[index]!;
|
|
408
|
+
energy += mono * mono;
|
|
409
|
+
}
|
|
410
|
+
if (energy === 0) continue;
|
|
411
|
+
for (let bin = 1; bin < bins; bin += BIN_STEP) {
|
|
412
|
+
let re = 0;
|
|
413
|
+
let im = 0;
|
|
414
|
+
for (let index = 0; index < FFT_SIZE; index += 1) {
|
|
415
|
+
const phase = (bin * index) % FFT_SIZE;
|
|
416
|
+
re += block[index]! * cos[phase]!;
|
|
417
|
+
im -= block[index]! * sin[phase]!;
|
|
418
|
+
}
|
|
419
|
+
power[bin] = power[bin]! + re * re + im * im;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
let weighted = 0;
|
|
423
|
+
let total = 0;
|
|
424
|
+
for (let bin = 1; bin < bins; bin += BIN_STEP) {
|
|
425
|
+
weighted += power[bin]! * ((bin * sampleRate) / FFT_SIZE);
|
|
426
|
+
total += power[bin]!;
|
|
427
|
+
}
|
|
428
|
+
return total > 0 ? weighted / total : 0;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** One line for the agent and the activity card: level, peak, brightness. */
|
|
432
|
+
export function describeSound(stats: SoundStats): string {
|
|
433
|
+
if (stats.rmsDb <= FLOOR_DB + 1) return "silent";
|
|
434
|
+
const loud =
|
|
435
|
+
stats.rmsDb > -10
|
|
436
|
+
? "very loud"
|
|
437
|
+
: stats.rmsDb > -18
|
|
438
|
+
? "loud"
|
|
439
|
+
: stats.rmsDb > -30
|
|
440
|
+
? "moderate"
|
|
441
|
+
: stats.rmsDb > -45
|
|
442
|
+
? "quiet"
|
|
443
|
+
: "very quiet";
|
|
444
|
+
const tone =
|
|
445
|
+
stats.centroidHz < 400
|
|
446
|
+
? "dark"
|
|
447
|
+
: stats.centroidHz < 1200
|
|
448
|
+
? "warm"
|
|
449
|
+
: stats.centroidHz < 2800
|
|
450
|
+
? "balanced"
|
|
451
|
+
: stats.centroidHz < 5000
|
|
452
|
+
? "bright"
|
|
453
|
+
: "very bright";
|
|
454
|
+
const clip =
|
|
455
|
+
stats.clipped > 0 ? ` · clipping (${stats.clipped} samples)` : "";
|
|
456
|
+
return `${loud}, ${tone} · RMS ${stats.rmsDb.toFixed(1)} dBFS · peak ${stats.peakDb.toFixed(1)} dBFS · centroid ${Math.round(stats.centroidHz)} Hz${clip}`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* A level meter `width` cells wide for a dBFS value over -48..0, with `!`
|
|
461
|
+
* in the last cell when the peak clips.
|
|
462
|
+
*/
|
|
463
|
+
export function meterBar(db: number, width: number, clipped = false): string {
|
|
464
|
+
const cells = Math.max(1, width);
|
|
465
|
+
const fill = Math.round(
|
|
466
|
+
Math.max(0, Math.min(1, (db + 48) / 48)) * (clipped ? cells - 1 : cells),
|
|
467
|
+
);
|
|
468
|
+
const bar = "█".repeat(fill) + "·".repeat(Math.max(0, cells - fill));
|
|
469
|
+
return clipped ? `${bar.slice(0, cells - 1)}!` : bar;
|
|
470
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** FNV-1a seeded mulberry32: integer-only, so identical on every platform. */
|
|
2
|
+
export function seededRandom(seed: string): () => number {
|
|
3
|
+
let hash = 0x811c9dc5;
|
|
4
|
+
for (let index = 0; index < seed.length; index += 1) {
|
|
5
|
+
hash ^= seed.charCodeAt(index);
|
|
6
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
7
|
+
}
|
|
8
|
+
let state = hash;
|
|
9
|
+
return () => {
|
|
10
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
11
|
+
let value = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
12
|
+
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
|
|
13
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Off-thread loop renderer for the audio engine. One worker per engine
|
|
3
|
+
* keeps a stem cache across renders, so an edit re-renders only the tracks
|
|
4
|
+
* it touched while the daemon loop keeps serving windows and pumping audio.
|
|
5
|
+
*/
|
|
6
|
+
import { scoreFromJSON } from "../../core/score.ts";
|
|
7
|
+
import { SampleLibrary, hasSamplerTracks } from "./samples.ts";
|
|
8
|
+
import { StemRenderer } from "./wav.ts";
|
|
9
|
+
|
|
10
|
+
export type RenderRequest = Readonly<{
|
|
11
|
+
id: number;
|
|
12
|
+
score: unknown;
|
|
13
|
+
sampleRate: number;
|
|
14
|
+
/** Project root sampler voices resolve under; omitted = samplers silent. */
|
|
15
|
+
projectRoot?: string;
|
|
16
|
+
}>;
|
|
17
|
+
|
|
18
|
+
export type RenderReply =
|
|
19
|
+
| Readonly<{
|
|
20
|
+
id: number;
|
|
21
|
+
ok: true;
|
|
22
|
+
frames: number;
|
|
23
|
+
sampleRate: number;
|
|
24
|
+
pcm: Int16Array;
|
|
25
|
+
/** Milliseconds spent rendering, excluding transfer. */
|
|
26
|
+
renderMs: number;
|
|
27
|
+
}>
|
|
28
|
+
| Readonly<{ id: number; ok: false; error: string }>;
|
|
29
|
+
|
|
30
|
+
declare const self: Worker;
|
|
31
|
+
|
|
32
|
+
const renderer = new StemRenderer();
|
|
33
|
+
let library: SampleLibrary | undefined;
|
|
34
|
+
|
|
35
|
+
self.onmessage = async (event: MessageEvent<RenderRequest>) => {
|
|
36
|
+
const { id, score, sampleRate, projectRoot } = event.data;
|
|
37
|
+
try {
|
|
38
|
+
const started = performance.now();
|
|
39
|
+
const parsed = scoreFromJSON(score);
|
|
40
|
+
let samples;
|
|
41
|
+
if (projectRoot !== undefined && hasSamplerTracks(parsed)) {
|
|
42
|
+
if (library?.projectRoot !== projectRoot)
|
|
43
|
+
library = new SampleLibrary({ projectRoot });
|
|
44
|
+
samples = await library.load(parsed);
|
|
45
|
+
}
|
|
46
|
+
const audio = renderer.render(parsed, {
|
|
47
|
+
sampleRate,
|
|
48
|
+
loop: true,
|
|
49
|
+
samples,
|
|
50
|
+
});
|
|
51
|
+
const reply: RenderReply = {
|
|
52
|
+
id,
|
|
53
|
+
ok: true,
|
|
54
|
+
frames: audio.frames,
|
|
55
|
+
sampleRate: audio.sampleRate,
|
|
56
|
+
pcm: audio.pcm,
|
|
57
|
+
renderMs: performance.now() - started,
|
|
58
|
+
};
|
|
59
|
+
self.postMessage(reply, [audio.pcm.buffer]);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const reply: RenderReply = {
|
|
62
|
+
id,
|
|
63
|
+
ok: false,
|
|
64
|
+
error: error instanceof Error ? error.message : String(error),
|
|
65
|
+
};
|
|
66
|
+
self.postMessage(reply);
|
|
67
|
+
}
|
|
68
|
+
};
|