@hraness/dawg 0.6.1 → 0.8.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 +118 -0
- package/DAWG.md +454 -140
- package/README.md +30 -26
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +184 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/ids.ts +52 -0
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +8 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/range.ts +581 -0
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +955 -14
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +287 -5
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +2132 -46
- package/core/sections.ts +480 -35
- package/core/sing.ts +815 -0
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +31 -0
- package/guides/audio.md +31 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -7
- package/guides/chords.md +15 -15
- package/guides/effects.md +17 -16
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +15 -7
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/panes.md +32 -0
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +26 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +18 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -13
- package/guides/styles.md +31 -0
- package/guides/tape.md +32 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +6 -2
- package/src/agent/agent.ts +130 -18
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +378 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +1 -1
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +27 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +25 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +4 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +110 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +13 -10
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +49 -5
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +157 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +10 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/effects/chain.ts +3 -1
- package/src/audio/effects/common.ts +43 -0
- package/src/audio/effects/convolution.ts +7 -4
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/engine.ts +360 -40
- package/src/audio/fit.ts +35 -3
- package/src/audio/instrument-check.ts +59 -43
- package/src/audio/instruments.ts +4 -0
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +8 -1
- package/src/audio/keys/engine.ts +13 -1
- package/src/audio/keys/piano.ts +22 -2
- package/src/audio/kits.ts +135 -6
- package/src/audio/live.ts +114 -20
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +30 -2
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +2 -1
- package/src/audio/sampler.ts +85 -4
- package/src/audio/samples.ts +20 -2
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/bow.ts +48 -5
- package/src/audio/strings/engine.ts +8 -2
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +500 -77
- package/src/audio/winds/engine.ts +5 -1
- package/src/audio/winds/trim.ts +28 -4
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +15 -2
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +82 -32
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +1 -1
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +101 -32
- package/src/commands/grammar.ts +560 -0
- package/src/commands/help.ts +774 -366
- package/src/commands/history.ts +139 -10
- package/src/commands/keys.ts +25 -8
- package/src/commands/modal.ts +1 -1
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +135 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +474 -0
- package/src/commands/strum.ts +13 -1
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +617 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +2 -2
- package/src/fs/durable.ts +50 -0
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +511 -0
- package/src/launch-args.ts +267 -0
- package/src/main.ts +2508 -308
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +21 -2
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +20 -1
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +199 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +174 -0
- package/src/session/port.ts +109 -14
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +365 -7
- package/src/session/rebase.ts +63 -12
- package/src/session/receipt.ts +265 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +126 -45
- package/src/tui/arrange-menu.ts +225 -23
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +113 -27
- package/src/tui/fader.ts +282 -42
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +987 -179
- package/src/tui/modal-menu.ts +6 -6
- package/src/tui/performance-menu.ts +5 -2
- package/src/tui/play-chords.ts +4 -2
- package/src/tui/play-mode.ts +15 -1
- package/src/tui/play-session.ts +243 -28
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +3 -3
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +491 -84
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +193 -0
- package/tui/drawer.ts +233 -27
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +143 -65
- package/tui/guide.ts +50 -5
- package/tui/highway.ts +309 -30
- package/tui/hints.ts +197 -0
- package/tui/hits.ts +7 -1
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +80 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +151 -18
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +46 -1
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio clip and lyric commands (0.7 clips lane): `/vocal import|stem|
|
|
3
|
+
* setups`, `/clip` and `/lyrics`. Commands take 1-based bars (`5.3` is bar
|
|
4
|
+
* 5 beat 3) and dB; the score stores ticks and linear gain (sources.md 4).
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
8
|
+
import { basename, extname, join } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
assignLyrics,
|
|
11
|
+
CLIP_GAIN_MAX_DB,
|
|
12
|
+
CLIP_GAIN_MIN_DB,
|
|
13
|
+
clipGainDb,
|
|
14
|
+
clipLength,
|
|
15
|
+
clipSongTick,
|
|
16
|
+
dbToClipGain,
|
|
17
|
+
importGain,
|
|
18
|
+
nextClipId,
|
|
19
|
+
repeatClip,
|
|
20
|
+
splitClip,
|
|
21
|
+
vocalChainPatch,
|
|
22
|
+
} from "../../core/clips.ts";
|
|
23
|
+
import {
|
|
24
|
+
SCORE_LIMITS,
|
|
25
|
+
setClips,
|
|
26
|
+
updateNote,
|
|
27
|
+
updateTrack,
|
|
28
|
+
type AudioClip,
|
|
29
|
+
type Note,
|
|
30
|
+
type Track,
|
|
31
|
+
type TrackScore,
|
|
32
|
+
} from "../../core/score.ts";
|
|
33
|
+
import { trackSlug } from "../../core/slug.ts";
|
|
34
|
+
import { barStartTick } from "../../core/tempo.ts";
|
|
35
|
+
import { importSample } from "../media/import.ts";
|
|
36
|
+
import {
|
|
37
|
+
ensureDir,
|
|
38
|
+
projectPath,
|
|
39
|
+
resolveInput,
|
|
40
|
+
samplesDir,
|
|
41
|
+
} from "../media/paths.ts";
|
|
42
|
+
import type { MediaRunContext } from "../media/types.ts";
|
|
43
|
+
import { parseWav } from "../media/vendor/wav.ts";
|
|
44
|
+
import type { VocalContext, VocalResult, VocalVerb } from "./vocal.ts";
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Bars
|
|
48
|
+
|
|
49
|
+
/** Tick for a 1-based bar like `9` or `5.3` (bar 5, beat 3). */
|
|
50
|
+
export function userBarTick(score: TrackScore, text: string): number {
|
|
51
|
+
const match = text.trim().match(/^(\d+)(?:\.(\d+))?$/);
|
|
52
|
+
if (!match) throw new Error(`"${text}" is not a bar (try 9 or 5.3)`);
|
|
53
|
+
const bar = Number(match[1]);
|
|
54
|
+
const beat = match[2] === undefined ? 1 : Number(match[2]);
|
|
55
|
+
if (bar < 1) throw new Error("bars start at 1");
|
|
56
|
+
if (beat < 1 || beat > score.beatsPerBar)
|
|
57
|
+
throw new Error(`beat ${beat} is outside a ${score.beatsPerBar}-beat bar`);
|
|
58
|
+
return barStartTick(score, bar - 1) + (beat - 1) * score.ticksPerBeat;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `5` or `5.3` for a tick (inverse of `userBarTick` on whole beats). */
|
|
62
|
+
export function userBarLabel(score: TrackScore, tick: number): string {
|
|
63
|
+
let bar = 0;
|
|
64
|
+
while (barStartTick(score, bar + 1) <= tick && bar < 100_000) bar += 1;
|
|
65
|
+
const beat = Math.floor(
|
|
66
|
+
(tick - barStartTick(score, bar)) / score.ticksPerBeat,
|
|
67
|
+
);
|
|
68
|
+
return beat === 0 ? `${bar + 1}` : `${bar + 1}.${beat + 1}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Audio files
|
|
73
|
+
|
|
74
|
+
/** Seconds and peak (linear) of a project wav, or undefined when unreadable. */
|
|
75
|
+
export async function wavInfo(
|
|
76
|
+
path: string,
|
|
77
|
+
): Promise<{ seconds: number; peak: number } | undefined> {
|
|
78
|
+
try {
|
|
79
|
+
const info = await stat(path);
|
|
80
|
+
if (!info.isFile() || info.size > SCORE_LIMITS.maxSampleFileBytes)
|
|
81
|
+
return undefined;
|
|
82
|
+
const wav = parseWav(new Uint8Array(await readFile(path)), {
|
|
83
|
+
maximumBytes: SCORE_LIMITS.maxSampleFileBytes,
|
|
84
|
+
maximumDurationSeconds: SCORE_LIMITS.maxClipSeconds + 1,
|
|
85
|
+
maximumChannels: 2,
|
|
86
|
+
});
|
|
87
|
+
let peak = 0;
|
|
88
|
+
for (let frame = 0; frame < wav.sampleCount; frame += 1)
|
|
89
|
+
for (let channel = 0; channel < wav.channels; channel += 1) {
|
|
90
|
+
const value = Math.abs(wav.sample(frame, channel));
|
|
91
|
+
if (value > peak) peak = value;
|
|
92
|
+
}
|
|
93
|
+
return { seconds: wav.sampleCount / wav.sampleRate, peak };
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Track or create the clip track a verb acts on. */
|
|
100
|
+
function focusedTrack(score: TrackScore, trackId: string): Track {
|
|
101
|
+
const track = score.tracks.find((t) => t.id === trackId);
|
|
102
|
+
if (!track) throw new Error("no track is focused; /track vocal makes one");
|
|
103
|
+
return track;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Score and track with a new clip placed; vocal tracks keep their chain. */
|
|
107
|
+
export function placeClip(
|
|
108
|
+
score: TrackScore,
|
|
109
|
+
trackId: string,
|
|
110
|
+
clip: Omit<AudioClip, "id"> & { id?: string },
|
|
111
|
+
): { score: TrackScore; clip: AudioClip } {
|
|
112
|
+
const track = focusedTrack(score, trackId);
|
|
113
|
+
const clips = track.clips ?? [];
|
|
114
|
+
if (clips.length >= SCORE_LIMITS.maxClipsPerTrack)
|
|
115
|
+
throw new Error(
|
|
116
|
+
`${track.name} already has ${SCORE_LIMITS.maxClipsPerTrack} clips`,
|
|
117
|
+
);
|
|
118
|
+
const base = clip.id ?? trackSlug(basename(clip.src, extname(clip.src)));
|
|
119
|
+
const placed: AudioClip = { ...clip, id: nextClipId(track, base || "clip") };
|
|
120
|
+
return { score: setClips(score, trackId, [...clips, placed]), clip: placed };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type ImportDeps = Readonly<{
|
|
124
|
+
/** Media services (runner, env); absent in tests that stub `importFile`. */
|
|
125
|
+
media?: Omit<MediaRunContext, "projectRoot" | "trackSlug">;
|
|
126
|
+
/** Copies `file` into tracks/<slug>/samples/<name>.wav (48 kHz mono s16). */
|
|
127
|
+
importFile?: (
|
|
128
|
+
file: string,
|
|
129
|
+
name: string,
|
|
130
|
+
context: VocalContext,
|
|
131
|
+
slug: string,
|
|
132
|
+
) => Promise<{ src: string; sha256: string }>;
|
|
133
|
+
}>;
|
|
134
|
+
|
|
135
|
+
let importDeps: ImportDeps = {};
|
|
136
|
+
/** Main wires the media runner here once; tests inject a stub. */
|
|
137
|
+
export function setClipImportDeps(deps: ImportDeps): void {
|
|
138
|
+
importDeps = deps;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Copy `file` into tracks/<slug>/samples/<name>.wav when it is already a
|
|
143
|
+
* 48 kHz mono 16-bit PCM WAV; undefined when it needs converting.
|
|
144
|
+
*/
|
|
145
|
+
export async function copyReadyWav(
|
|
146
|
+
file: string,
|
|
147
|
+
name: string,
|
|
148
|
+
cwd: string,
|
|
149
|
+
slug: string,
|
|
150
|
+
): Promise<{ src: string; sha256: string } | undefined> {
|
|
151
|
+
const host = { projectRoot: cwd, trackSlug: slug };
|
|
152
|
+
let input;
|
|
153
|
+
try {
|
|
154
|
+
input = await resolveInput(host, file);
|
|
155
|
+
} catch {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
if (!/\.wav$/i.test(input.absolute)) return undefined;
|
|
159
|
+
if (input.size > SCORE_LIMITS.maxSampleFileBytes) return undefined;
|
|
160
|
+
const bytes = new Uint8Array(await readFile(input.absolute));
|
|
161
|
+
try {
|
|
162
|
+
const wav = parseWav(bytes, {
|
|
163
|
+
maximumBytes: SCORE_LIMITS.maxSampleFileBytes,
|
|
164
|
+
maximumDurationSeconds: SCORE_LIMITS.maxClipSeconds + 1,
|
|
165
|
+
maximumChannels: 2,
|
|
166
|
+
});
|
|
167
|
+
if (
|
|
168
|
+
wav.sampleRate !== 48_000 ||
|
|
169
|
+
wav.channels !== 1 ||
|
|
170
|
+
wav.encoding !== "pcm16"
|
|
171
|
+
)
|
|
172
|
+
return undefined;
|
|
173
|
+
} catch {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
const dir = await ensureDir(samplesDir(host));
|
|
177
|
+
const target = join(dir, `${name}.wav`);
|
|
178
|
+
const temp = `${target}.part.wav`;
|
|
179
|
+
await writeFile(temp, bytes);
|
|
180
|
+
await rename(temp, target);
|
|
181
|
+
return {
|
|
182
|
+
src: projectPath(cwd, target),
|
|
183
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function importIntoProject(
|
|
188
|
+
file: string,
|
|
189
|
+
name: string,
|
|
190
|
+
context: VocalContext,
|
|
191
|
+
slug: string,
|
|
192
|
+
): Promise<{ src: string; sha256: string }> {
|
|
193
|
+
if (importDeps.importFile)
|
|
194
|
+
return importDeps.importFile(file, name, context, slug);
|
|
195
|
+
// A WAV already in the project format (48 kHz mono 16-bit PCM) is copied
|
|
196
|
+
// byte for byte: no ffmpeg needed.
|
|
197
|
+
const ready = await copyReadyWav(file, name, context.cwd, slug);
|
|
198
|
+
if (ready) return ready;
|
|
199
|
+
if (!importDeps.media) throw new Error("audio import is not available here");
|
|
200
|
+
const result = await importSample(
|
|
201
|
+
{ file, name, channels: 1 },
|
|
202
|
+
{ ...importDeps.media, projectRoot: context.cwd, trackSlug: slug },
|
|
203
|
+
);
|
|
204
|
+
const sample = result.content.sample as { path: string; sha256: string };
|
|
205
|
+
return { src: sample.path, sha256: sample.sha256 };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** `/vocal import <file> [at] [bar]`: copy, pin and place at a bar. */
|
|
209
|
+
export async function importClip(
|
|
210
|
+
file: string,
|
|
211
|
+
bar: string | undefined,
|
|
212
|
+
context: VocalContext,
|
|
213
|
+
label = "import",
|
|
214
|
+
): Promise<VocalResult> {
|
|
215
|
+
const track = focusedTrack(context.score, context.trackId);
|
|
216
|
+
const slug = trackSlug(track.name);
|
|
217
|
+
const name = trackSlug(basename(file, extname(file))).slice(0, 48) || "vocal";
|
|
218
|
+
const startTick = bar ? userBarTick(context.score, bar) : 0;
|
|
219
|
+
const copied = await importIntoProject(file, name, context, slug);
|
|
220
|
+
const info = await wavInfo(join(context.cwd, copied.src));
|
|
221
|
+
const gain = info ? importGain(info.peak) : undefined;
|
|
222
|
+
// A fresh track (no notes, the default sine) becomes a `vocal` track, so
|
|
223
|
+
// its notes are silent guides; the vocal chain stays opt-in.
|
|
224
|
+
const fresh =
|
|
225
|
+
!context.score.notes.some((n) => n.trackId === track.id) &&
|
|
226
|
+
track.instrument === "sine";
|
|
227
|
+
const base = fresh
|
|
228
|
+
? updateTrack(context.score, context.trackId, { instrument: "vocal" })
|
|
229
|
+
: context.score;
|
|
230
|
+
const { score, clip } = placeClip(base, context.trackId, {
|
|
231
|
+
id: name,
|
|
232
|
+
src: copied.src,
|
|
233
|
+
sha256: copied.sha256,
|
|
234
|
+
startTick,
|
|
235
|
+
...(gain !== undefined ? { gain } : {}),
|
|
236
|
+
});
|
|
237
|
+
const seconds = info ? ` · ${info.seconds.toFixed(1)} s` : "";
|
|
238
|
+
const db =
|
|
239
|
+
gain !== undefined
|
|
240
|
+
? ` · gain ${formatDb(clipGainDb(gain))} (peak -6 dBFS)`
|
|
241
|
+
: "";
|
|
242
|
+
return {
|
|
243
|
+
ok: true,
|
|
244
|
+
message: `vocal ${label}: ${clip.id} at bar ${userBarLabel(score, startTick)} on ${track.name}${seconds}${db}${fresh ? " · instrument vocal" : ""} · /clip to edit`,
|
|
245
|
+
next: score,
|
|
246
|
+
kind: "clip.place",
|
|
247
|
+
payload: { clipId: clip.id, src: clip.src },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The newest `vocals.wav` under `downloads/**.stems/` in the project. */
|
|
252
|
+
export async function latestVocalStem(
|
|
253
|
+
cwd: string,
|
|
254
|
+
): Promise<string | undefined> {
|
|
255
|
+
let best: { path: string; at: number } | undefined;
|
|
256
|
+
const tracks = join(cwd, "tracks");
|
|
257
|
+
let slugs: string[] = [];
|
|
258
|
+
try {
|
|
259
|
+
slugs = await readdir(tracks);
|
|
260
|
+
} catch {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
for (const slug of slugs) {
|
|
264
|
+
const downloads = join(tracks, slug, "downloads");
|
|
265
|
+
let entries: string[] = [];
|
|
266
|
+
try {
|
|
267
|
+
entries = await readdir(downloads);
|
|
268
|
+
} catch {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
if (!entry.endsWith(".stems")) continue;
|
|
273
|
+
const path = join(downloads, entry, "vocals.wav");
|
|
274
|
+
try {
|
|
275
|
+
const info = await stat(path);
|
|
276
|
+
if (!best || info.mtimeMs > best.at)
|
|
277
|
+
best = {
|
|
278
|
+
path: `tracks/${slug}/downloads/${entry}/vocals.wav`,
|
|
279
|
+
at: info.mtimeMs,
|
|
280
|
+
};
|
|
281
|
+
} catch {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return best?.path;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Setups (VOCAL_SOURCES, sources.md 5)
|
|
291
|
+
|
|
292
|
+
export type VocalSetup = Readonly<{
|
|
293
|
+
name: string;
|
|
294
|
+
summary: string;
|
|
295
|
+
/** Fields the setup sets beyond the vocal chain. */
|
|
296
|
+
patch?: Readonly<Partial<Pick<Track, "delay" | "fx" | "autotune">>>;
|
|
297
|
+
/** Other lanes' fields it would set; reported when absent here. */
|
|
298
|
+
needs?: readonly string[];
|
|
299
|
+
}>;
|
|
300
|
+
|
|
301
|
+
export const VOCAL_SETUPS: readonly VocalSetup[] = [
|
|
302
|
+
{
|
|
303
|
+
name: "hyper",
|
|
304
|
+
summary: "hard-tuned hyperpop lead: formant up, slapback, light distortion",
|
|
305
|
+
patch: {
|
|
306
|
+
autotune: { preset: "hard" },
|
|
307
|
+
delay: { beats: 0.125, feedback: 0.1, mix: 0.18 },
|
|
308
|
+
fx: {
|
|
309
|
+
formant: { shift: 3.5 } as never,
|
|
310
|
+
distort: { drive: 1, tone: 6000, mix: 0.25 } as never,
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
needs: ["harmony", "record"],
|
|
314
|
+
},
|
|
315
|
+
{ name: "take", summary: "a lead with plate reverb", needs: ["record"] },
|
|
316
|
+
{ name: "stack", summary: "three passes panned wide", needs: ["record"] },
|
|
317
|
+
{ name: "import", summary: "place a vocal file at a bar (/vocal import)" },
|
|
318
|
+
{ name: "stem", summary: "place the vocals stem (/vocal stem)" },
|
|
319
|
+
{ name: "robot", summary: "TTS sung on the notes", needs: ["say"] },
|
|
320
|
+
{
|
|
321
|
+
name: "android",
|
|
322
|
+
summary: "TTS sung, formant up",
|
|
323
|
+
patch: { fx: { formant: { shift: 3.5 } as never } },
|
|
324
|
+
needs: ["say"],
|
|
325
|
+
},
|
|
326
|
+
{ name: "whisper", summary: "whisper voice, reverse swells", needs: ["say"] },
|
|
327
|
+
{
|
|
328
|
+
name: "choir",
|
|
329
|
+
summary: "TTS across four voices",
|
|
330
|
+
needs: ["say", "harmony"],
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
name: "speech",
|
|
334
|
+
summary: "TTS on the grid, /clip repeat for loops",
|
|
335
|
+
needs: ["say"],
|
|
336
|
+
},
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
/** Whether this build's Track carries `field` (a later lane's field). */
|
|
340
|
+
export type FieldProbe = (field: string) => boolean;
|
|
341
|
+
|
|
342
|
+
const knownTrackFields = new Set<string>(["harmony", "record", "say"]);
|
|
343
|
+
/** Lanes register the fields they add so setups can use them. */
|
|
344
|
+
export function registerVocalSetupField(field: string): void {
|
|
345
|
+
knownTrackFields.add(`+${field}`);
|
|
346
|
+
}
|
|
347
|
+
const fieldPresent: FieldProbe = (field) => knownTrackFields.has(`+${field}`);
|
|
348
|
+
|
|
349
|
+
/** Apply a setup's chain to the focused track; report what this build lacks. */
|
|
350
|
+
export function applyVocalSetup(
|
|
351
|
+
score: TrackScore,
|
|
352
|
+
trackId: string,
|
|
353
|
+
name: string,
|
|
354
|
+
present: FieldProbe = fieldPresent,
|
|
355
|
+
): VocalResult {
|
|
356
|
+
const setup = VOCAL_SETUPS.find(
|
|
357
|
+
(s) => s.name === name || (name === "auto" && s.name === "hyper"),
|
|
358
|
+
);
|
|
359
|
+
if (!setup)
|
|
360
|
+
return {
|
|
361
|
+
ok: false,
|
|
362
|
+
message: `vocal setups: no setup ${name} · ${VOCAL_SETUPS.map((s) => s.name).join(", ")}`,
|
|
363
|
+
};
|
|
364
|
+
const track = focusedTrack(score, trackId);
|
|
365
|
+
const chain = vocalChainPatch(track);
|
|
366
|
+
const fx = { ...(chain.fx ?? track.fx ?? {}), ...(setup.patch?.fx ?? {}) };
|
|
367
|
+
const patch = {
|
|
368
|
+
instrument: "vocal",
|
|
369
|
+
...chain,
|
|
370
|
+
...(setup.patch?.delay ? { delay: setup.patch.delay } : {}),
|
|
371
|
+
...(setup.patch?.autotune ? { autotune: setup.patch.autotune } : {}),
|
|
372
|
+
...(setup.patch?.fx ? { fx } : {}),
|
|
373
|
+
};
|
|
374
|
+
const next = updateTrack(score, trackId, patch as never);
|
|
375
|
+
const missing = (setup.needs ?? []).filter((field) => !present(field));
|
|
376
|
+
const lacks = missing.length
|
|
377
|
+
? ` · not in this build yet: ${missing.join(", ")}`
|
|
378
|
+
: "";
|
|
379
|
+
return {
|
|
380
|
+
ok: true,
|
|
381
|
+
message: `vocal ${setup.name}: ${track.name} set up (${setup.summary})${lacks}`,
|
|
382
|
+
next,
|
|
383
|
+
kind: "vocal.setup",
|
|
384
|
+
payload: { setup: setup.name },
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// /vocal verbs
|
|
390
|
+
|
|
391
|
+
export const CLIP_VOCAL_VERBS: readonly VocalVerb[] = [
|
|
392
|
+
{
|
|
393
|
+
verb: "import",
|
|
394
|
+
usage: "import <file> [at] [bar]",
|
|
395
|
+
summary: "copy an audio file into the track (48 kHz mono) and place it",
|
|
396
|
+
lane: "clips",
|
|
397
|
+
run: async (args, context) => {
|
|
398
|
+
const match = args.match(/^(.+?)(?:\s+(?:at\s+)?(\d+(?:\.\d+)?))?$/i);
|
|
399
|
+
const file = match?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
400
|
+
if (!file)
|
|
401
|
+
return { ok: false, message: "usage: /vocal import <file> [at <bar>]" };
|
|
402
|
+
return importClip(file, match?.[2], context);
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
verb: "stem",
|
|
407
|
+
usage: "stem [at] [bar]",
|
|
408
|
+
summary: "place the vocals stem from the last split_stems",
|
|
409
|
+
lane: "clips",
|
|
410
|
+
run: async (args, context) => {
|
|
411
|
+
const bar = args.replace(/^at\s+/i, "").trim() || undefined;
|
|
412
|
+
const stem = await latestVocalStem(context.cwd);
|
|
413
|
+
if (!stem)
|
|
414
|
+
return {
|
|
415
|
+
ok: false,
|
|
416
|
+
message:
|
|
417
|
+
"vocal stem: no vocals stem yet · download a song and run split_stems first",
|
|
418
|
+
};
|
|
419
|
+
return importClip(stem, bar, context, "stem");
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
verb: "setups",
|
|
424
|
+
usage: "setups [name]",
|
|
425
|
+
summary: "list or apply a vocal setup (hyper, take, stack, ...)",
|
|
426
|
+
lane: "clips",
|
|
427
|
+
run: async (args, context) => {
|
|
428
|
+
const name = args.trim().toLowerCase();
|
|
429
|
+
if (!name)
|
|
430
|
+
return {
|
|
431
|
+
ok: true,
|
|
432
|
+
message: [
|
|
433
|
+
"vocal setups:",
|
|
434
|
+
...VOCAL_SETUPS.map(
|
|
435
|
+
(s) =>
|
|
436
|
+
` ${s.name.padEnd(8)} ${s.summary}${
|
|
437
|
+
s.needs?.some((f) => !fieldPresent(f))
|
|
438
|
+
? ` (needs ${s.needs.filter((f) => !fieldPresent(f)).join(", ")})`
|
|
439
|
+
: ""
|
|
440
|
+
}`,
|
|
441
|
+
),
|
|
442
|
+
].join("\n"),
|
|
443
|
+
};
|
|
444
|
+
return applyVocalSetup(context.score, context.trackId, name);
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
];
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// /clip
|
|
451
|
+
|
|
452
|
+
export type ClipEdit =
|
|
453
|
+
| Readonly<{ kind: "list" }>
|
|
454
|
+
| Readonly<{ kind: "gain"; db: number; relative: boolean }>
|
|
455
|
+
| Readonly<{ kind: "fade"; in?: number | null; out?: number | null }>
|
|
456
|
+
| Readonly<{ kind: "move"; bar: string }>
|
|
457
|
+
| Readonly<{ kind: "split"; bar: string }>
|
|
458
|
+
| Readonly<{ kind: "rev" }>
|
|
459
|
+
| Readonly<{ kind: "mute" }>
|
|
460
|
+
| Readonly<{ kind: "rm" }>
|
|
461
|
+
| Readonly<{ kind: "trim"; offset?: number; dur?: number | null }>
|
|
462
|
+
| Readonly<{ kind: "repeat"; every: number; until: string }>;
|
|
463
|
+
|
|
464
|
+
export type ClipCommand = Readonly<{ clipId?: string; edit: ClipEdit }>;
|
|
465
|
+
|
|
466
|
+
const CLIP_USAGE =
|
|
467
|
+
"/clip [id] gain -3 | gain by -3 | fade .01 .2 | fade in .01 | fade out default | move 9 | split 7 | trim [offset s] [dur s|end] | rev | repeat 2 [to 32] | mute | rm";
|
|
468
|
+
|
|
469
|
+
/** Parse `/clip ...`; undefined when it is not a /clip command. */
|
|
470
|
+
export function parseClipCommand(
|
|
471
|
+
command: string,
|
|
472
|
+
): ClipCommand | { error: string } | undefined {
|
|
473
|
+
const match = command.trim().match(/^\/clip(?:\s+(.*))?$/i);
|
|
474
|
+
if (!match) return undefined;
|
|
475
|
+
const words = (match[1] ?? "").trim().split(/\s+/).filter(Boolean);
|
|
476
|
+
if (words.length === 0) return { edit: { kind: "list" } };
|
|
477
|
+
const verbs = new Set([
|
|
478
|
+
"gain",
|
|
479
|
+
"fade",
|
|
480
|
+
"move",
|
|
481
|
+
"split",
|
|
482
|
+
"rev",
|
|
483
|
+
"mute",
|
|
484
|
+
"rm",
|
|
485
|
+
"trim",
|
|
486
|
+
"repeat",
|
|
487
|
+
]);
|
|
488
|
+
let clipId: string | undefined;
|
|
489
|
+
if (!verbs.has(words[0]!.toLowerCase())) clipId = words.shift();
|
|
490
|
+
const verb = words.shift()?.toLowerCase();
|
|
491
|
+
const num = (text: string | undefined) => {
|
|
492
|
+
const value = Number(text);
|
|
493
|
+
return text !== undefined && Number.isFinite(value) ? value : undefined;
|
|
494
|
+
};
|
|
495
|
+
const bad = { error: `usage: ${CLIP_USAGE}` };
|
|
496
|
+
switch (verb) {
|
|
497
|
+
case undefined:
|
|
498
|
+
return { clipId, edit: { kind: "list" } };
|
|
499
|
+
case "gain": {
|
|
500
|
+
// `gain by -3` nudges; `gain -3` sets.
|
|
501
|
+
const relative = words[0]?.toLowerCase() === "by";
|
|
502
|
+
const text = relative ? words[1] : words[0];
|
|
503
|
+
const db = num(text?.replace(/db$/i, ""));
|
|
504
|
+
if (db === undefined) return bad;
|
|
505
|
+
return { clipId, edit: { kind: "gain", db, relative } };
|
|
506
|
+
}
|
|
507
|
+
case "fade": {
|
|
508
|
+
// `fade in <s>` / `fade out <s>` edit one side (`default` drops the
|
|
509
|
+
// field); `fade <in> [out]` sets both.
|
|
510
|
+
const side = words[0]?.toLowerCase();
|
|
511
|
+
if (side === "in" || side === "out") {
|
|
512
|
+
const text = words[1]?.toLowerCase().replace(/s$/, "");
|
|
513
|
+
const value = text === "default" ? null : num(text);
|
|
514
|
+
if (value === undefined) return bad;
|
|
515
|
+
return {
|
|
516
|
+
clipId,
|
|
517
|
+
edit:
|
|
518
|
+
side === "in"
|
|
519
|
+
? { kind: "fade", in: value }
|
|
520
|
+
: { kind: "fade", out: value },
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
const fadeIn = num(words[0]);
|
|
524
|
+
const fadeOut = num(words[1] ?? words[0]);
|
|
525
|
+
if (fadeIn === undefined) return bad;
|
|
526
|
+
return { clipId, edit: { kind: "fade", in: fadeIn, out: fadeOut } };
|
|
527
|
+
}
|
|
528
|
+
case "move":
|
|
529
|
+
case "split":
|
|
530
|
+
if (!words[0]) return bad;
|
|
531
|
+
return { clipId, edit: { kind: verb, bar: words[0] } };
|
|
532
|
+
case "rev":
|
|
533
|
+
case "mute":
|
|
534
|
+
case "rm":
|
|
535
|
+
return { clipId, edit: { kind: verb } };
|
|
536
|
+
case "trim": {
|
|
537
|
+
const edit: { kind: "trim"; offset?: number; dur?: number | null } = {
|
|
538
|
+
kind: "trim",
|
|
539
|
+
};
|
|
540
|
+
for (let i = 0; i < words.length; i += 2) {
|
|
541
|
+
const key = words[i]?.toLowerCase();
|
|
542
|
+
// `dur end` plays to the end of the file (drops dur).
|
|
543
|
+
if (key === "dur" && words[i + 1]?.toLowerCase() === "end") {
|
|
544
|
+
edit.dur = null;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const value = num(words[i + 1]?.replace(/s$/, ""));
|
|
548
|
+
if (value === undefined || (key !== "offset" && key !== "dur"))
|
|
549
|
+
return bad;
|
|
550
|
+
edit[key] = value;
|
|
551
|
+
}
|
|
552
|
+
return { clipId, edit };
|
|
553
|
+
}
|
|
554
|
+
case "repeat": {
|
|
555
|
+
// `repeat 4`, `repeat every 4`, `repeat every 4 to 32`; no `to`
|
|
556
|
+
// repeats to the song's end.
|
|
557
|
+
const everyAt = words.indexOf("every");
|
|
558
|
+
const every = num(words[everyAt >= 0 ? everyAt + 1 : 0]);
|
|
559
|
+
const toAt = words.indexOf("to");
|
|
560
|
+
const until = toAt >= 0 ? words[toAt + 1] : "end";
|
|
561
|
+
if (every === undefined || !(every > 0) || !until) return bad;
|
|
562
|
+
return { clipId, edit: { kind: "repeat", every, until } };
|
|
563
|
+
}
|
|
564
|
+
default:
|
|
565
|
+
return bad;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function formatDb(db: number): string {
|
|
570
|
+
if (!Number.isFinite(db)) return "-inf dB";
|
|
571
|
+
return `${db > 0 ? "+" : ""}${db.toFixed(1)} dB`;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** One line per clip for `/clip` and the menu. */
|
|
575
|
+
export function clipLines(score: TrackScore, track: Track): string[] {
|
|
576
|
+
const clips = track.clips ?? [];
|
|
577
|
+
if (clips.length === 0)
|
|
578
|
+
return [`${track.name} has no clips · vocal import <file> [bar]`];
|
|
579
|
+
return clips.map((clip) => {
|
|
580
|
+
const flags = [
|
|
581
|
+
clip.gain !== undefined ? formatDb(clipGainDb(clip.gain)) : "",
|
|
582
|
+
clip.rev ? "rev" : "",
|
|
583
|
+
clip.mute ? "muted" : "",
|
|
584
|
+
clip.take ? `take ${clip.take}` : "",
|
|
585
|
+
clip.dur !== undefined ? `${clip.dur.toFixed(2)} s` : "",
|
|
586
|
+
].filter(Boolean);
|
|
587
|
+
return ` ${clip.id.padEnd(12)} bar ${userBarLabel(score, clip.startTick).padEnd(6)} ${basename(clip.src)}${flags.length ? ` · ${flags.join(" · ")}` : ""}`;
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Pick the clip an edit names, else the one sounding at `cursorTick`, else the last. */
|
|
592
|
+
function pickClip(
|
|
593
|
+
track: Track,
|
|
594
|
+
id: string | undefined,
|
|
595
|
+
cursorTick?: number,
|
|
596
|
+
): AudioClip {
|
|
597
|
+
const clips = track.clips ?? [];
|
|
598
|
+
if (clips.length === 0)
|
|
599
|
+
throw new Error(`${track.name} has no clips · vocal import <file> [bar]`);
|
|
600
|
+
if (id !== undefined) {
|
|
601
|
+
const clip = clips.find((c) => c.id === id);
|
|
602
|
+
if (!clip)
|
|
603
|
+
throw new Error(`no clip ${id} · ${clips.map((c) => c.id).join(", ")}`);
|
|
604
|
+
return clip;
|
|
605
|
+
}
|
|
606
|
+
if (cursorTick !== undefined) {
|
|
607
|
+
const before = clips.filter((c) => c.startTick <= cursorTick);
|
|
608
|
+
if (before.length > 0) return before.at(-1)!;
|
|
609
|
+
}
|
|
610
|
+
return clips.at(-1)!;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Apply a `/clip` edit. Never throws: failures come back as text. */
|
|
614
|
+
export async function runClipCommand(
|
|
615
|
+
command: ClipCommand,
|
|
616
|
+
context: VocalContext & Readonly<{ cursorTick?: number }>,
|
|
617
|
+
): Promise<VocalResult> {
|
|
618
|
+
try {
|
|
619
|
+
const { score } = context;
|
|
620
|
+
const track = focusedTrack(score, context.trackId);
|
|
621
|
+
const { edit } = command;
|
|
622
|
+
if (edit.kind === "list")
|
|
623
|
+
return {
|
|
624
|
+
ok: true,
|
|
625
|
+
message: [`clips on ${track.name}:`, ...clipLines(score, track)].join(
|
|
626
|
+
"\n",
|
|
627
|
+
),
|
|
628
|
+
};
|
|
629
|
+
const clip = pickClip(track, command.clipId, context.cursorTick);
|
|
630
|
+
const clips = track.clips!;
|
|
631
|
+
const replace = (
|
|
632
|
+
next: readonly AudioClip[],
|
|
633
|
+
what: string,
|
|
634
|
+
kind = "clip.edit",
|
|
635
|
+
): VocalResult => ({
|
|
636
|
+
ok: true,
|
|
637
|
+
message: `clip ${clip.id}: ${what}`,
|
|
638
|
+
next: setClips(score, track.id, next.length > 0 ? next : null),
|
|
639
|
+
kind,
|
|
640
|
+
payload: { clipId: clip.id },
|
|
641
|
+
});
|
|
642
|
+
const swap = (patch: Partial<AudioClip>, drop: (keyof AudioClip)[] = []) =>
|
|
643
|
+
clips.map((c) => {
|
|
644
|
+
if (c !== clip) return c;
|
|
645
|
+
const out: Record<string, unknown> = { ...c, ...patch };
|
|
646
|
+
for (const key of drop) delete out[key];
|
|
647
|
+
return out as AudioClip;
|
|
648
|
+
});
|
|
649
|
+
const fileSeconds = async () =>
|
|
650
|
+
(await wavInfo(join(context.cwd, clip.src)))?.seconds;
|
|
651
|
+
switch (edit.kind) {
|
|
652
|
+
case "gain": {
|
|
653
|
+
const db = edit.relative
|
|
654
|
+
? clipGainDb(clip.gain ?? 1) + edit.db
|
|
655
|
+
: edit.db;
|
|
656
|
+
const gain = dbToClipGain(
|
|
657
|
+
Math.max(CLIP_GAIN_MIN_DB, Math.min(CLIP_GAIN_MAX_DB, db)),
|
|
658
|
+
);
|
|
659
|
+
return replace(
|
|
660
|
+
Math.abs(gain - 1) < 1e-6
|
|
661
|
+
? swap({}, ["gain"])
|
|
662
|
+
: swap({ gain: Math.round(gain * 1e4) / 1e4 }),
|
|
663
|
+
`gain ${formatDb(db)}`,
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
case "fade": {
|
|
667
|
+
const max = SCORE_LIMITS.maxClipFadeSeconds;
|
|
668
|
+
const clamp = (value: number) => Math.max(0, Math.min(max, value));
|
|
669
|
+
const patch: { fadeInTime?: number; fadeTime?: number } = {};
|
|
670
|
+
const drop: (keyof AudioClip)[] = [];
|
|
671
|
+
if (edit.in === null) drop.push("fadeInTime");
|
|
672
|
+
else if (edit.in !== undefined) patch.fadeInTime = clamp(edit.in);
|
|
673
|
+
if (edit.out === null) drop.push("fadeTime");
|
|
674
|
+
else if (edit.out !== undefined) patch.fadeTime = clamp(edit.out);
|
|
675
|
+
const next = swap(patch, drop);
|
|
676
|
+
const edited = next.find((c) => c.id === clip.id)!;
|
|
677
|
+
const shown = (value: number | undefined) =>
|
|
678
|
+
value === undefined ? "default" : `${value} s`;
|
|
679
|
+
return replace(
|
|
680
|
+
next,
|
|
681
|
+
`fades ${shown(edited.fadeInTime)} in, ${shown(edited.fadeTime)} out (equal-power)`,
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
case "move": {
|
|
685
|
+
const startTick = userBarTick(score, edit.bar);
|
|
686
|
+
return replace(swap({ startTick }), `moved to bar ${edit.bar}`);
|
|
687
|
+
}
|
|
688
|
+
case "split": {
|
|
689
|
+
const tick = userBarTick(score, edit.bar);
|
|
690
|
+
const cut = splitClip(score, track, clip, tick, await fileSeconds());
|
|
691
|
+
if (typeof cut === "string")
|
|
692
|
+
return {
|
|
693
|
+
ok: false,
|
|
694
|
+
message: cut.replace("there", `at bar ${edit.bar}`),
|
|
695
|
+
};
|
|
696
|
+
const next = clips.flatMap((c) =>
|
|
697
|
+
c === clip ? [cut.head, cut.tail] : [c],
|
|
698
|
+
);
|
|
699
|
+
return replace(
|
|
700
|
+
next,
|
|
701
|
+
`split at bar ${edit.bar} into ${clip.id} and ${cut.tail.id}`,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
case "trim": {
|
|
705
|
+
const seconds = (await fileSeconds()) ?? SCORE_LIMITS.maxClipSeconds;
|
|
706
|
+
const offset = Math.max(
|
|
707
|
+
0,
|
|
708
|
+
Math.min(seconds, edit.offset ?? clip.offset ?? 0),
|
|
709
|
+
);
|
|
710
|
+
const dur =
|
|
711
|
+
edit.dur === null
|
|
712
|
+
? undefined
|
|
713
|
+
: edit.dur !== undefined
|
|
714
|
+
? Math.max(0.001, Math.min(seconds - offset, edit.dur))
|
|
715
|
+
: clip.dur;
|
|
716
|
+
return replace(
|
|
717
|
+
swap(
|
|
718
|
+
{
|
|
719
|
+
...(offset > 0 ? { offset } : {}),
|
|
720
|
+
...(dur !== undefined ? { dur } : {}),
|
|
721
|
+
},
|
|
722
|
+
[
|
|
723
|
+
...(offset > 0 ? [] : (["offset"] as const)),
|
|
724
|
+
...(dur === undefined ? (["dur"] as const) : []),
|
|
725
|
+
],
|
|
726
|
+
),
|
|
727
|
+
`plays ${offset.toFixed(3)} s for ${dur === undefined ? "the rest of the file" : `${dur.toFixed(3)} s`}`,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
case "rev":
|
|
731
|
+
return replace(
|
|
732
|
+
clip.rev ? swap({}, ["rev"]) : swap({ rev: true }),
|
|
733
|
+
clip.rev ? "forwards" : "reversed",
|
|
734
|
+
);
|
|
735
|
+
case "mute":
|
|
736
|
+
return replace(
|
|
737
|
+
clip.mute ? swap({}, ["mute"]) : swap({ mute: true }),
|
|
738
|
+
clip.mute ? "unmuted" : "muted",
|
|
739
|
+
);
|
|
740
|
+
case "rm":
|
|
741
|
+
return replace(
|
|
742
|
+
clips.filter((c) => c !== clip),
|
|
743
|
+
"removed",
|
|
744
|
+
"clip.remove",
|
|
745
|
+
);
|
|
746
|
+
case "repeat": {
|
|
747
|
+
const every = Math.round(
|
|
748
|
+
edit.every * score.beatsPerBar * score.ticksPerBeat,
|
|
749
|
+
);
|
|
750
|
+
const until =
|
|
751
|
+
edit.until === "end"
|
|
752
|
+
? barStartTick(score, score.bars)
|
|
753
|
+
: userBarTick(score, edit.until);
|
|
754
|
+
const copies = repeatClip(
|
|
755
|
+
clip,
|
|
756
|
+
every,
|
|
757
|
+
until,
|
|
758
|
+
new Set(clips.map((c) => c.id)),
|
|
759
|
+
);
|
|
760
|
+
if (clips.length + copies.length > SCORE_LIMITS.maxClipsPerTrack)
|
|
761
|
+
return {
|
|
762
|
+
ok: false,
|
|
763
|
+
message: `clip ${clip.id}: that is more than ${SCORE_LIMITS.maxClipsPerTrack} clips`,
|
|
764
|
+
};
|
|
765
|
+
return replace(
|
|
766
|
+
[...clips, ...copies],
|
|
767
|
+
`${copies.length} repeat${copies.length === 1 ? "" : "s"} every ${edit.every} bar${edit.every === 1 ? "" : "s"} to ${edit.until === "end" ? "the end" : `bar ${edit.until}`}`,
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
} catch (error) {
|
|
772
|
+
return {
|
|
773
|
+
ok: false,
|
|
774
|
+
message: error instanceof Error ? error.message : String(error),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ---------------------------------------------------------------------------
|
|
780
|
+
// /lyrics
|
|
781
|
+
|
|
782
|
+
export type LyricsCommand =
|
|
783
|
+
| Readonly<{ kind: "show" }>
|
|
784
|
+
| Readonly<{ kind: "clear"; bar?: string }>
|
|
785
|
+
| Readonly<{ kind: "set"; bar?: string; text: string }>;
|
|
786
|
+
|
|
787
|
+
/** `/lyrics [bar] <text>`, `/lyrics clear [bar]`, bare `/lyrics` shows. */
|
|
788
|
+
export function parseLyricsCommand(command: string): LyricsCommand | undefined {
|
|
789
|
+
const match = command.trim().match(/^\/lyrics?(?:\s+(.*))?$/i);
|
|
790
|
+
if (!match) return undefined;
|
|
791
|
+
const rest = (match[1] ?? "").trim();
|
|
792
|
+
if (!rest) return { kind: "show" };
|
|
793
|
+
const clear = rest.match(/^clear(?:\s+(\d+(?:\.\d+)?))?$/i);
|
|
794
|
+
if (clear) return { kind: "clear", ...(clear[1] ? { bar: clear[1] } : {}) };
|
|
795
|
+
const bar = rest.match(/^(\d+(?:\.\d+)?)\s+(.+)$/);
|
|
796
|
+
if (bar)
|
|
797
|
+
return {
|
|
798
|
+
kind: "set",
|
|
799
|
+
bar: bar[1],
|
|
800
|
+
text: bar[2]!.replace(/^["']|["']$/g, ""),
|
|
801
|
+
};
|
|
802
|
+
return { kind: "set", text: rest.replace(/^["']|["']$/g, "") };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Notes on a track from `fromTick`, in sung order. */
|
|
806
|
+
function sungNotes(
|
|
807
|
+
score: TrackScore,
|
|
808
|
+
trackId: string,
|
|
809
|
+
fromTick: number,
|
|
810
|
+
): Note[] {
|
|
811
|
+
return score.notes
|
|
812
|
+
.filter((note) => note.trackId === trackId && note.startTick >= fromTick)
|
|
813
|
+
.sort((a, b) => a.startTick - b.startTick || b.pitch - a.pitch);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** Put lyrics on a track's notes (or clear them). */
|
|
817
|
+
export function applyLyrics(
|
|
818
|
+
score: TrackScore,
|
|
819
|
+
trackId: string,
|
|
820
|
+
command: LyricsCommand,
|
|
821
|
+
): VocalResult {
|
|
822
|
+
try {
|
|
823
|
+
const track = focusedTrack(score, trackId);
|
|
824
|
+
const from =
|
|
825
|
+
command.kind !== "show" && command.bar
|
|
826
|
+
? userBarTick(score, command.bar)
|
|
827
|
+
: 0;
|
|
828
|
+
const notes = sungNotes(score, trackId, from);
|
|
829
|
+
if (command.kind === "show") {
|
|
830
|
+
const sung = notes.filter((n) => n.lyric !== undefined);
|
|
831
|
+
return {
|
|
832
|
+
ok: true,
|
|
833
|
+
message: sung.length
|
|
834
|
+
? `lyrics on ${track.name}: ${sung.map((n) => n.lyric).join(" ")}`
|
|
835
|
+
: `${track.name} has no lyrics · /lyrics sun-lit morn-ing`,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
if (notes.length === 0)
|
|
839
|
+
return {
|
|
840
|
+
ok: false,
|
|
841
|
+
message: `lyrics: ${track.name} has no notes${from ? " from there" : ""} to sing on`,
|
|
842
|
+
};
|
|
843
|
+
let next = score;
|
|
844
|
+
if (command.kind === "clear") {
|
|
845
|
+
for (const note of notes)
|
|
846
|
+
if (note.lyric !== undefined)
|
|
847
|
+
next = updateNote(next, note.id, { lyric: null });
|
|
848
|
+
return {
|
|
849
|
+
ok: true,
|
|
850
|
+
message: `lyrics cleared on ${track.name}`,
|
|
851
|
+
next,
|
|
852
|
+
kind: "lyrics.set",
|
|
853
|
+
payload: {},
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
// Chords sing one syllable on their top note.
|
|
857
|
+
const tops = notes.filter(
|
|
858
|
+
(note, index) =>
|
|
859
|
+
index === 0 || note.startTick !== notes[index - 1]!.startTick,
|
|
860
|
+
);
|
|
861
|
+
const { lyrics, dropped, split } = assignLyrics(command.text, tops);
|
|
862
|
+
for (const note of tops) {
|
|
863
|
+
const lyric = lyrics.get(note.id);
|
|
864
|
+
if (lyric !== note.lyric)
|
|
865
|
+
next = updateNote(next, note.id, { lyric: lyric ?? null });
|
|
866
|
+
}
|
|
867
|
+
const shown = tops.map((note) => lyrics.get(note.id) ?? "~").join(" ");
|
|
868
|
+
const extra = dropped.length
|
|
869
|
+
? ` · ${dropped.length} syllable${dropped.length === 1 ? "" : "s"} left over: ${dropped.join(" ")}`
|
|
870
|
+
: "";
|
|
871
|
+
const auto = split.length ? ` · split ${split.join(", ")}` : "";
|
|
872
|
+
return {
|
|
873
|
+
ok: true,
|
|
874
|
+
message: `lyrics on ${track.name}: ${shown}${auto}${extra}`,
|
|
875
|
+
next,
|
|
876
|
+
kind: "lyrics.set",
|
|
877
|
+
payload: { notes: tops.length },
|
|
878
|
+
};
|
|
879
|
+
} catch (error) {
|
|
880
|
+
return {
|
|
881
|
+
ok: false,
|
|
882
|
+
message: `lyrics: ${error instanceof Error ? error.message : String(error)}`,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
export { clipLength, clipSongTick };
|