@hraness/dawg 0.6.0 → 0.7.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 +126 -0
- package/DAWG.md +655 -141
- package/README.md +27 -25
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +759 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +182 -99
- package/core/expression.ts +158 -6
- package/core/fx.ts +354 -32
- package/core/granular.ts +91 -0
- package/core/instruments.ts +180 -0
- package/core/keys.ts +573 -4
- package/core/loop.ts +5 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/resonators.ts +306 -4
- package/core/routing.ts +165 -0
- package/core/score.ts +1181 -16
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +354 -6
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +3160 -69
- package/core/sections.ts +298 -24
- package/core/sing.ts +815 -0
- package/core/strings.ts +237 -2
- 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/core/winds.ts +652 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +30 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -6
- package/guides/chords.md +16 -14
- package/guides/effects.md +17 -15
- 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 +12 -6
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/performance.md +15 -10
- package/guides/play.md +21 -13
- package/guides/project.md +25 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +32 -0
- package/guides/rhythm.md +17 -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 -12
- package/guides/styles.md +31 -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 +5 -2
- package/src/agent/agent.ts +131 -13
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +369 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +15 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +13 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +5 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/resample-tool.ts +131 -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 +307 -37
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +16 -6
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +58 -3
- 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/doctor.ts +86 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +171 -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/dsp/shift.ts +256 -0
- package/src/audio/effects/chain.ts +21 -4
- package/src/audio/effects/common.ts +57 -0
- package/src/audio/effects/convolution.ts +113 -5
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/engine.ts +206 -33
- package/src/audio/fit.ts +80 -3
- package/src/audio/granular.ts +352 -21
- package/src/audio/instrument-check.ts +67 -47
- package/src/audio/instruments.ts +36 -3
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +427 -0
- package/src/audio/keys/engine.ts +156 -20
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +72 -8
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/kits.ts +135 -6
- package/src/audio/live-worker.ts +3 -1
- package/src/audio/live.ts +247 -26
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +45 -3
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +286 -0
- package/src/audio/resonators.ts +10 -2
- package/src/audio/sampler.ts +252 -24
- package/src/audio/samples.ts +46 -3
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/body.ts +29 -16
- package/src/audio/strings/bow.ts +699 -0
- package/src/audio/strings/engine.ts +274 -22
- package/src/audio/strings/measure.test-helpers.ts +2 -0
- 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 +608 -85
- package/src/audio/winds/engine.ts +306 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +104 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +492 -0
- 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 +44 -29
- 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 +104 -23
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +105 -33
- package/src/commands/grammar.ts +558 -0
- package/src/commands/granular.ts +7 -2
- package/src/commands/help.ts +652 -339
- package/src/commands/history.ts +18 -0
- package/src/commands/keys.ts +293 -17
- package/src/commands/modal.ts +67 -4
- 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 +130 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/resample.ts +281 -0
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +8 -25
- package/src/commands/sample.ts +49 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/sing.ts +478 -0
- package/src/commands/string.ts +28 -2
- package/src/commands/strum.ts +485 -0
- 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 +616 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +244 -0
- package/src/fs/durable.ts +50 -0
- package/src/lang/glossary.ts +493 -0
- package/src/launch-args.ts +163 -0
- package/src/main.ts +1521 -255
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +26 -3
- 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 +36 -1
- package/src/session/daemon.ts +3 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +154 -0
- package/src/session/port.ts +22 -4
- package/src/session/presence.ts +50 -7
- package/src/session/protocol.ts +5 -1
- package/src/session/rebase.ts +18 -5
- package/src/session/receipt.ts +258 -0
- package/src/session/store.ts +65 -43
- package/src/tui/arrange-menu.ts +65 -31
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +18 -13
- package/src/tui/fader.ts +228 -41
- package/src/tui/granular-menu.ts +2 -4
- 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 +1377 -193
- package/src/tui/modal-menu.ts +38 -11
- package/src/tui/performance-menu.ts +46 -1
- package/src/tui/play-chords.ts +108 -5
- package/src/tui/play-mode.ts +16 -1
- package/src/tui/play-session.ts +81 -16
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +144 -0
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +274 -40
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +144 -0
- package/tui/drawer.ts +70 -22
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +112 -63
- package/tui/guide.ts +42 -4
- package/tui/highway.ts +269 -25
- package/tui/hints.ts +192 -0
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/play-strip.ts +68 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +144 -14
- package/tui/theme.ts +27 -0
package/src/commands/pack.ts
CHANGED
|
@@ -124,10 +124,10 @@ export type KitCommand =
|
|
|
124
124
|
|
|
125
125
|
/** `/kit [bank]` (default kit) and `/kit list`; undefined for other commands. */
|
|
126
126
|
export function parseKitCommand(command: string): KitCommand | undefined {
|
|
127
|
-
const match = command.trim().match(
|
|
127
|
+
const match = command.trim().match(/^\/?kits?(?:\s+(\S+))?\s*$/i);
|
|
128
128
|
if (!match) return undefined;
|
|
129
129
|
const bank = match[1];
|
|
130
|
-
if (bank && /^(list|ls)$/i.test(bank)) return { kind: "list" };
|
|
130
|
+
if (bank && /^(list|ls|presets)$/i.test(bank)) return { kind: "list" };
|
|
131
131
|
return { kind: "set", bank: bank ?? DEFAULT_KIT };
|
|
132
132
|
}
|
|
133
133
|
|
|
@@ -389,6 +389,8 @@ export function kitListLines(aliases: BankAliases): string[] {
|
|
|
389
389
|
export type CacheReport = Readonly<{
|
|
390
390
|
packs: CacheUsage & { max: number };
|
|
391
391
|
assets: CacheUsage & { max: number };
|
|
392
|
+
/** Pitch curves in `.dawg/analysis` (0.7); omitted when empty. */
|
|
393
|
+
analysis?: CacheUsage & { max: number };
|
|
392
394
|
freed?: Readonly<{ files: number; bytes: number }>;
|
|
393
395
|
}>;
|
|
394
396
|
|
|
@@ -408,6 +410,11 @@ export function cacheLines(report: CacheReport): string[] {
|
|
|
408
410
|
: []),
|
|
409
411
|
row("pack downloads", report.packs, "DAWG_PACKS_CACHE_MAX"),
|
|
410
412
|
row("decoded audio (this project)", report.assets, "DAWG_ASSETS_CACHE_MAX"),
|
|
413
|
+
...(report.analysis?.files
|
|
414
|
+
? [
|
|
415
|
+
`pitch analysis (this project) · ${formatBytes(report.analysis.bytes)} of ${formatBytes(report.analysis.max)} · ${report.analysis.files} curve${report.analysis.files === 1 ? "" : "s"} · .dawg/analysis, re-tracked on demand`,
|
|
416
|
+
]
|
|
417
|
+
: []),
|
|
411
418
|
"least recently used files go first; this project's sounds are never evicted",
|
|
412
419
|
"evicted sounds re-fetch by their pinned sha256 on next use",
|
|
413
420
|
"/pack cache prune [size] · /pack cache clear",
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `✗ synth lpf 99999 · lpf takes 20…20000 Hz · synth lpf 1200`: the range
|
|
3
|
+
* card for a synth or effect parameter, built from the same specs that
|
|
4
|
+
* validate it (core/params), so the card and the check never disagree.
|
|
5
|
+
*/
|
|
6
|
+
import type { ParamSpec } from "../../core/params.ts";
|
|
7
|
+
import { SYNTH_PARAMS, synthParamName } from "../../core/synth.ts";
|
|
8
|
+
import { effectSpec } from "../../core/fx.ts";
|
|
9
|
+
import { parseEffectName, parseParamName } from "./fx.ts";
|
|
10
|
+
import { usageError } from "./grammar.ts";
|
|
11
|
+
|
|
12
|
+
/** The range card for the first out-of-range number in the line, if any. */
|
|
13
|
+
export function paramRangeError(line: string): string | undefined {
|
|
14
|
+
const words = line.trim().replace(/^\//, "").split(/\s+/);
|
|
15
|
+
const verb = words[0]?.toLowerCase();
|
|
16
|
+
if (verb === "synth")
|
|
17
|
+
return firstOutOfRange(line, words.slice(1), "synth", (word) => {
|
|
18
|
+
const name = synthParamName(word);
|
|
19
|
+
return name ? [name, SYNTH_PARAMS[name]] : undefined;
|
|
20
|
+
});
|
|
21
|
+
if (verb === "fx" && words[1]) {
|
|
22
|
+
const effect = parseEffectName(words[1]);
|
|
23
|
+
if (!effect) return undefined;
|
|
24
|
+
const params = effectSpec(effect).params;
|
|
25
|
+
return firstOutOfRange(line, words.slice(2), `fx ${effect}`, (word) => {
|
|
26
|
+
const name = parseParamName(effect, word);
|
|
27
|
+
return name ? [name, params[name]] : undefined;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function firstOutOfRange(
|
|
34
|
+
line: string,
|
|
35
|
+
rest: readonly string[],
|
|
36
|
+
prefix: string,
|
|
37
|
+
lookup: (word: string) => [string, ParamSpec | undefined] | undefined,
|
|
38
|
+
): string | undefined {
|
|
39
|
+
for (let index = 0; index + 1 < rest.length; index += 2) {
|
|
40
|
+
const found = lookup(rest[index]!);
|
|
41
|
+
if (!found) continue;
|
|
42
|
+
const [name, spec] = found;
|
|
43
|
+
if (spec?.kind !== "number") continue;
|
|
44
|
+
const value = Number(rest[index + 1]);
|
|
45
|
+
if (!Number.isFinite(value)) continue;
|
|
46
|
+
if (value >= spec.min && value <= spec.max) continue;
|
|
47
|
+
return usageError(line.trim(), {
|
|
48
|
+
command: name,
|
|
49
|
+
min: spec.min,
|
|
50
|
+
max: spec.max,
|
|
51
|
+
unit: spec.unit ?? "",
|
|
52
|
+
example: `${prefix} ${name} ${spec.default}`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a prompt line is a command the human prompt bar runs locally (no
|
|
3
|
+
* model call): every command parser the prompt tries, in one list. The TUI
|
|
4
|
+
* uses it for typo fixes; show-me (src/agent/show-me.ts) uses it in tests to
|
|
5
|
+
* prove every command it ghost-types is one a human can type.
|
|
6
|
+
*/
|
|
7
|
+
import type { TrackScore } from "../../core/score.ts";
|
|
8
|
+
import { parsePrompt } from "../agent/ops.ts";
|
|
9
|
+
import { parseSectionCommand } from "./arrange.ts";
|
|
10
|
+
import { parseAutotuneCommand } from "./autotune.ts";
|
|
11
|
+
import { parseClipCommand, parseLyricsCommand } from "./clips.ts";
|
|
12
|
+
import { parsePatternCommand } from "./drums.ts";
|
|
13
|
+
import { parseProgressionCommand } from "./progression.ts";
|
|
14
|
+
import { parseEditCommand } from "./edit.ts";
|
|
15
|
+
import { parseExpressionCommand } from "./expression.ts";
|
|
16
|
+
import { parseFitCommand } from "./fit.ts";
|
|
17
|
+
import { parseFormantCommand, parseVowelCommand } from "./formant.ts";
|
|
18
|
+
import { parseFxCommand } from "./fx.ts";
|
|
19
|
+
import { parseGranularCommand } from "./granular.ts";
|
|
20
|
+
import { parseKeysCommand } from "./keys.ts";
|
|
21
|
+
import { parseMasterCommand } from "./master.ts";
|
|
22
|
+
import { parseModalCommand } from "./modal.ts";
|
|
23
|
+
import { parseMusicCommand } from "./music.ts";
|
|
24
|
+
import { parseKitCommand, parsePackCommand } from "./pack.ts";
|
|
25
|
+
import { parseResampleCommand } from "./resample.ts";
|
|
26
|
+
import { parseRhythmCommand } from "./rhythm.ts";
|
|
27
|
+
import { parseRigCommand } from "./rig.ts";
|
|
28
|
+
import { parseSampleCommand } from "./sample.ts";
|
|
29
|
+
import { parseShiftCommand } from "./shift.ts";
|
|
30
|
+
import { parseSingCommand } from "./sing.ts";
|
|
31
|
+
import { parseStringCommand } from "./string.ts";
|
|
32
|
+
import { parseGuitarCommand, parseStrumCommand } from "./strum.ts";
|
|
33
|
+
import { parseSynthCommand } from "./synth.ts";
|
|
34
|
+
import { parseTimeCommand } from "./time.ts";
|
|
35
|
+
import { parseTuningCommand } from "./tuning.ts";
|
|
36
|
+
import { parseVocalCommand } from "./vocal.ts";
|
|
37
|
+
import { parseVocoderCommand } from "./vocoder.ts";
|
|
38
|
+
import { parseWavetableCommand } from "./wavetable.ts";
|
|
39
|
+
import { parseWindCommand } from "./wind.ts";
|
|
40
|
+
import {
|
|
41
|
+
candidates,
|
|
42
|
+
parseExportCommand,
|
|
43
|
+
parseLoopCommand,
|
|
44
|
+
parseTrackEdit,
|
|
45
|
+
} from "./grammar.ts";
|
|
46
|
+
|
|
47
|
+
/** One parser's reading of a line: its name and what it parsed. */
|
|
48
|
+
export type ParsedCommand = Readonly<{ parser: string; value: unknown }>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The first parser that takes `text` exactly as typed, with its result
|
|
52
|
+
* (no grammar rewrites): what the prompt bar would run for this spelling.
|
|
53
|
+
*/
|
|
54
|
+
export function parseExact(
|
|
55
|
+
text: string,
|
|
56
|
+
score: TrackScore,
|
|
57
|
+
): ParsedCommand | undefined {
|
|
58
|
+
for (const parse of parsers(score)) {
|
|
59
|
+
const value = parse(text);
|
|
60
|
+
if (value !== undefined)
|
|
61
|
+
return { parser: parse.name || "anonymous", value };
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What `text` runs as: the exact spelling first, then the grammar's other
|
|
68
|
+
* readings (`/x` ≡ `x`, aliases, remove and list words) in order.
|
|
69
|
+
*/
|
|
70
|
+
export function parseCommand(
|
|
71
|
+
text: string,
|
|
72
|
+
score: TrackScore,
|
|
73
|
+
): ParsedCommand | undefined {
|
|
74
|
+
for (const line of [text, ...candidates(text)]) {
|
|
75
|
+
const parsed = parseExact(line, score);
|
|
76
|
+
if (parsed) return parsed;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether a line runs locally, in any spelling the grammar accepts. */
|
|
82
|
+
export function commandParses(text: string, score: TrackScore): boolean {
|
|
83
|
+
return parseCommand(text, score) !== undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parsers(score: TrackScore): ((text: string) => unknown)[] {
|
|
87
|
+
return [
|
|
88
|
+
parsePrompt,
|
|
89
|
+
parseMusicCommand,
|
|
90
|
+
parseEditCommand,
|
|
91
|
+
parseRhythmCommand,
|
|
92
|
+
parseFxCommand,
|
|
93
|
+
parseSynthCommand,
|
|
94
|
+
parseStringCommand,
|
|
95
|
+
parseGranularCommand,
|
|
96
|
+
parseKeysCommand,
|
|
97
|
+
parseExpressionCommand,
|
|
98
|
+
parseMasterCommand,
|
|
99
|
+
function parseSection(value: string) {
|
|
100
|
+
return parseSectionCommand(value, score);
|
|
101
|
+
},
|
|
102
|
+
parsePatternCommand,
|
|
103
|
+
parseKitCommand,
|
|
104
|
+
parsePackCommand,
|
|
105
|
+
parseSampleCommand,
|
|
106
|
+
parseFitCommand,
|
|
107
|
+
parseShiftCommand,
|
|
108
|
+
parseResampleCommand,
|
|
109
|
+
parseWavetableCommand,
|
|
110
|
+
parseTimeCommand,
|
|
111
|
+
parseTuningCommand,
|
|
112
|
+
parseRigCommand,
|
|
113
|
+
parseModalCommand,
|
|
114
|
+
parseGuitarCommand,
|
|
115
|
+
parseStrumCommand,
|
|
116
|
+
parseProgressionCommand,
|
|
117
|
+
parseWindCommand,
|
|
118
|
+
parseAutotuneCommand,
|
|
119
|
+
parseSingCommand,
|
|
120
|
+
parseVocoderCommand,
|
|
121
|
+
parseVocalCommand,
|
|
122
|
+
parseFormantCommand,
|
|
123
|
+
parseVowelCommand,
|
|
124
|
+
parseClipCommand,
|
|
125
|
+
parseLyricsCommand,
|
|
126
|
+
parseExportCommand,
|
|
127
|
+
parseLoopCommand,
|
|
128
|
+
parseTrackEdit,
|
|
129
|
+
];
|
|
130
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `progression …`: sustained, voice-led block chords on the focused track,
|
|
3
|
+
* the prompt form of the SDK's `progression()` and the agent's write_chords.
|
|
4
|
+
*
|
|
5
|
+
* progression i7 IV7 i7 IV7 each 8 roman numerals in the song key
|
|
6
|
+
* progression Am7 D9 at 16 bass chord symbols, a root under each
|
|
7
|
+
*
|
|
8
|
+
* Chords run core/chords.ts renderProgression in `block` mode with voice
|
|
9
|
+
* leading, so a carrier for a vocoder or talkbox holds each chord for its
|
|
10
|
+
* whole span. Existing notes stay; chords past the end grow the song.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
chordName,
|
|
14
|
+
keyUsesFlats,
|
|
15
|
+
parseKey,
|
|
16
|
+
renderProgression,
|
|
17
|
+
resolveChord,
|
|
18
|
+
type Chord,
|
|
19
|
+
type Key,
|
|
20
|
+
} from "../../core/chords.ts";
|
|
21
|
+
import { applyScoreOperations } from "../../core/diff.ts";
|
|
22
|
+
import {
|
|
23
|
+
SCORE_LIMITS,
|
|
24
|
+
ScoreValidationError,
|
|
25
|
+
type ScoreOperation,
|
|
26
|
+
type TrackScore,
|
|
27
|
+
} from "../../core/score.ts";
|
|
28
|
+
import { barTicks } from "../../core/sections.ts";
|
|
29
|
+
import { resolveTuning, snapToTuning } from "../../core/tuning.ts";
|
|
30
|
+
import type { CommandResult } from "./strum.ts";
|
|
31
|
+
|
|
32
|
+
export const PROGRESSION_USAGE =
|
|
33
|
+
"progression <chords> [each <beats>] [at <beat>] [bass] · progression i7 IV7 each 8 · progression Am7 D9 bass";
|
|
34
|
+
|
|
35
|
+
export type ProgressionOptions = Readonly<{
|
|
36
|
+
chords: readonly string[];
|
|
37
|
+
each?: number;
|
|
38
|
+
at?: number;
|
|
39
|
+
bass?: boolean;
|
|
40
|
+
}>;
|
|
41
|
+
|
|
42
|
+
export type ProgressionCommand =
|
|
43
|
+
| Readonly<{ type: "progression"; options: ProgressionOptions }>
|
|
44
|
+
| Readonly<{ type: "progression-hint"; message: string }>;
|
|
45
|
+
|
|
46
|
+
/** `progression …`, or undefined when the prompt is not one. */
|
|
47
|
+
export function parseProgressionCommand(
|
|
48
|
+
prompt: string,
|
|
49
|
+
): ProgressionCommand | undefined {
|
|
50
|
+
if (prompt.length > 1_024) return undefined;
|
|
51
|
+
const words = prompt.trim().replace(/^\//, "").split(/\s+/);
|
|
52
|
+
const head = words[0]?.toLowerCase();
|
|
53
|
+
if (head !== "progression" && head !== "prog") return undefined;
|
|
54
|
+
const chords: string[] = [];
|
|
55
|
+
let each: number | undefined;
|
|
56
|
+
let at: number | undefined;
|
|
57
|
+
let bass = false;
|
|
58
|
+
for (let i = 1; i < words.length; i += 1) {
|
|
59
|
+
const word = words[i]!;
|
|
60
|
+
const lower = word.toLowerCase();
|
|
61
|
+
if (lower === "each" || lower === "at") {
|
|
62
|
+
const value = Number(words[i + 1]);
|
|
63
|
+
if (
|
|
64
|
+
!Number.isFinite(value) ||
|
|
65
|
+
value < 0 ||
|
|
66
|
+
(lower === "each" && value <= 0)
|
|
67
|
+
)
|
|
68
|
+
return { type: "progression-hint", message: PROGRESSION_USAGE };
|
|
69
|
+
if (lower === "each") each = value;
|
|
70
|
+
else at = value;
|
|
71
|
+
i += 1;
|
|
72
|
+
} else if (lower === "bass") bass = true;
|
|
73
|
+
else chords.push(word);
|
|
74
|
+
}
|
|
75
|
+
if (chords.length === 0)
|
|
76
|
+
return { type: "progression-hint", message: PROGRESSION_USAGE };
|
|
77
|
+
if (chords.length > 64)
|
|
78
|
+
return {
|
|
79
|
+
type: "progression-hint",
|
|
80
|
+
message: "progression takes up to 64 chords",
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
type: "progression",
|
|
84
|
+
options: {
|
|
85
|
+
chords,
|
|
86
|
+
...(each !== undefined ? { each } : {}),
|
|
87
|
+
...(at !== undefined ? { at } : {}),
|
|
88
|
+
...(bass ? { bass } : {}),
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Writes a progression command's chords onto `trackId`. */
|
|
94
|
+
export function applyProgressionCommand(
|
|
95
|
+
score: TrackScore,
|
|
96
|
+
trackId: string,
|
|
97
|
+
command: ProgressionCommand,
|
|
98
|
+
newId: (index: number) => string,
|
|
99
|
+
): CommandResult {
|
|
100
|
+
if (command.type === "progression-hint")
|
|
101
|
+
return { ok: false, message: command.message };
|
|
102
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
103
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
104
|
+
const { options } = command;
|
|
105
|
+
const key: Key = parseKey(score.key) ?? { tonic: 0, mode: "major" };
|
|
106
|
+
const chords: Chord[] = [];
|
|
107
|
+
for (const text of options.chords) {
|
|
108
|
+
const chord = resolveChord(key, text);
|
|
109
|
+
if (!chord)
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
message: `unknown chord ${JSON.stringify(text.slice(0, 32))} (a symbol like Am7 or a numeral like ii7)`,
|
|
113
|
+
};
|
|
114
|
+
chords.push(chord);
|
|
115
|
+
}
|
|
116
|
+
const each = options.each ?? score.beatsPerBar;
|
|
117
|
+
const rendered = renderProgression({
|
|
118
|
+
key,
|
|
119
|
+
chords,
|
|
120
|
+
beatsPerChord: each,
|
|
121
|
+
start: options.at ?? 0,
|
|
122
|
+
bass: options.bass === true,
|
|
123
|
+
lead: true,
|
|
124
|
+
perform: { mode: "block" },
|
|
125
|
+
});
|
|
126
|
+
const performed = [...rendered.notes, ...rendered.bass];
|
|
127
|
+
if (performed.length > 4096)
|
|
128
|
+
return { ok: false, message: "progression would write over 4096 notes" };
|
|
129
|
+
const tpb = score.ticksPerBeat;
|
|
130
|
+
const table = resolveTuning(score.tuning, track.tuning, score.key);
|
|
131
|
+
const operations: ScoreOperation[] = performed.map((note, index) => ({
|
|
132
|
+
type: "addNote",
|
|
133
|
+
note: {
|
|
134
|
+
id: newId(index),
|
|
135
|
+
trackId,
|
|
136
|
+
startTick: Math.round(note.start * tpb),
|
|
137
|
+
durationTicks: Math.max(1, Math.round(note.length * tpb)),
|
|
138
|
+
pitch: snapToTuning(note.pitch, table),
|
|
139
|
+
velocity: note.velocity,
|
|
140
|
+
},
|
|
141
|
+
}));
|
|
142
|
+
let next: TrackScore;
|
|
143
|
+
try {
|
|
144
|
+
next = applyScoreOperations(score, operations);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error instanceof ScoreValidationError)
|
|
147
|
+
return { ok: false, message: `progression · ${error.message}` };
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
const end = next.notes.reduce(
|
|
151
|
+
(max, note) => Math.max(max, note.startTick + note.durationTicks),
|
|
152
|
+
0,
|
|
153
|
+
);
|
|
154
|
+
const needed = Math.min(
|
|
155
|
+
SCORE_LIMITS.maxBars,
|
|
156
|
+
Math.ceil(end / barTicks(next)),
|
|
157
|
+
);
|
|
158
|
+
const grew = needed > next.bars;
|
|
159
|
+
if (grew)
|
|
160
|
+
next = applyScoreOperations(next, [{ type: "setBars", bars: needed }]);
|
|
161
|
+
const flats = keyUsesFlats(key);
|
|
162
|
+
const names = chords.map((chord) => chordName(chord, flats));
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
message: `progression · ${names.join(" ")} · ${each} beats each · ${performed.length} notes${grew ? ` · song now ${needed} bars` : ""}`,
|
|
166
|
+
next,
|
|
167
|
+
kind: "score.notes",
|
|
168
|
+
payload: { trackId, progression: names },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resample <track> | orbit <n> | master [section <name> | bars a-b] [post]
|
|
3
|
+
* [grain] [as <id>]` (0.6.1): render a track, an orbit or the mix to
|
|
4
|
+
* `tracks/<slug>/samples/<name>.wav`, pin its sha256, record where it came
|
|
5
|
+
* from (`from`: source, section or bars, the source score's sha256) and add
|
|
6
|
+
* a track that plays it: a one-shot sampler track whose single note starts
|
|
7
|
+
* where the range starts and lasts the range, or with `grain` a granular
|
|
8
|
+
* track (the `cloud` preset) holding one note across the range. The source
|
|
9
|
+
* stays as it is; mute it to hear only the resample.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdir, realpath, rename, writeFile } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { MAX_ORBIT } from "../../core/fx.ts";
|
|
14
|
+
import { GRANULAR_INSTRUMENT } from "../../core/granular.ts";
|
|
15
|
+
import {
|
|
16
|
+
ScoreValidationError,
|
|
17
|
+
SCORE_LIMITS,
|
|
18
|
+
TrackScore,
|
|
19
|
+
addTrack,
|
|
20
|
+
type SampleRef,
|
|
21
|
+
type TrackInput,
|
|
22
|
+
} from "../../core/score.ts";
|
|
23
|
+
import { trackDirectories } from "../../core/sdk/print.ts";
|
|
24
|
+
import { barTicks } from "../../core/sections.ts";
|
|
25
|
+
import {
|
|
26
|
+
RESAMPLE_GAIN,
|
|
27
|
+
renderResample,
|
|
28
|
+
type ResampleRender,
|
|
29
|
+
type ResampleRequest,
|
|
30
|
+
} from "../audio/resample.ts";
|
|
31
|
+
import type { SampleBank } from "../audio/samples.ts";
|
|
32
|
+
|
|
33
|
+
export type ResampleCommand = ResampleRequest &
|
|
34
|
+
Readonly<{
|
|
35
|
+
/** Make a granular track instead of a sampler track. */
|
|
36
|
+
grain: boolean;
|
|
37
|
+
/** The new track's id (default `<source>-rs` or `<source>-grain`). */
|
|
38
|
+
as?: string;
|
|
39
|
+
}>;
|
|
40
|
+
|
|
41
|
+
export const RESAMPLE_USAGE =
|
|
42
|
+
"resample <track>|orbit <n>|master [section <name>|bars a-b] [post] [grain] [as <id>]";
|
|
43
|
+
|
|
44
|
+
const ID = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
45
|
+
|
|
46
|
+
/** Parse `resample ...` (with or without the slash). */
|
|
47
|
+
export function parseResampleCommand(
|
|
48
|
+
command: string,
|
|
49
|
+
): ResampleCommand | undefined {
|
|
50
|
+
const words = command.trim().split(/\s+/);
|
|
51
|
+
const head = words[0]?.toLowerCase().replace(/^\//, "");
|
|
52
|
+
if (head !== "resample" && head !== "bounce") return undefined;
|
|
53
|
+
let index = 1;
|
|
54
|
+
const next = () => words[index++];
|
|
55
|
+
const peek = () => words[index]?.toLowerCase();
|
|
56
|
+
let source: ResampleRequest["source"] | undefined;
|
|
57
|
+
const first = next();
|
|
58
|
+
if (first === undefined) return undefined;
|
|
59
|
+
if (first.toLowerCase() === "orbit") {
|
|
60
|
+
const n = Number(next());
|
|
61
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_ORBIT) return undefined;
|
|
62
|
+
source = { kind: "orbit", orbit: n };
|
|
63
|
+
} else if (first.toLowerCase() === "master" || first.toLowerCase() === "mix")
|
|
64
|
+
source = { kind: "master" };
|
|
65
|
+
else if (first.toLowerCase() === "track") {
|
|
66
|
+
const id = next();
|
|
67
|
+
if (!id) return undefined;
|
|
68
|
+
source = { kind: "track", trackId: id };
|
|
69
|
+
} else source = { kind: "track", trackId: first };
|
|
70
|
+
let range: ResampleRequest["range"] = { kind: "song" };
|
|
71
|
+
let post = false;
|
|
72
|
+
let grain = false;
|
|
73
|
+
let as: string | undefined;
|
|
74
|
+
while (index < words.length) {
|
|
75
|
+
const word = peek();
|
|
76
|
+
index += 1;
|
|
77
|
+
if (word === "section") {
|
|
78
|
+
// A section name runs to the next keyword.
|
|
79
|
+
const parts: string[] = [];
|
|
80
|
+
while (
|
|
81
|
+
index < words.length &&
|
|
82
|
+
!["post", "grain", "granular", "as", "bars"].includes(peek()!)
|
|
83
|
+
)
|
|
84
|
+
parts.push(next()!);
|
|
85
|
+
if (parts.length === 0) return undefined;
|
|
86
|
+
range = { kind: "section", name: parts.join(" ") };
|
|
87
|
+
} else if (word === "bars" || word === "bar") {
|
|
88
|
+
const match = /^(\d{1,4})(?:-(\d{1,4}))?$/.exec(next() ?? "");
|
|
89
|
+
if (!match) return undefined;
|
|
90
|
+
const from = Number(match[1]);
|
|
91
|
+
range = { kind: "bars", from, to: Number(match[2] ?? from) };
|
|
92
|
+
} else if (word === "post" || word === "master") post = true;
|
|
93
|
+
else if (word === "pre") post = false;
|
|
94
|
+
else if (word === "grain" || word === "granular") grain = true;
|
|
95
|
+
else if (word === "as") {
|
|
96
|
+
as = next()?.toLowerCase();
|
|
97
|
+
if (!as || !ID.test(as)) return undefined;
|
|
98
|
+
} else return undefined;
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
source,
|
|
102
|
+
range,
|
|
103
|
+
...(post ? { post } : {}),
|
|
104
|
+
grain,
|
|
105
|
+
...(as ? { as } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A free track id near `base`: `base`, `base-2`, … */
|
|
110
|
+
export function freeTrackId(score: TrackScore, base: string): string {
|
|
111
|
+
const stem = base.slice(0, 56);
|
|
112
|
+
const used = new Set(score.tracks.map((track) => track.id));
|
|
113
|
+
if (!used.has(stem)) return stem;
|
|
114
|
+
for (let n = 2; ; n += 1)
|
|
115
|
+
if (!used.has(`${stem}-${n}`)) return `${stem}-${n}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sourceStem(command: ResampleCommand): string {
|
|
119
|
+
const source = command.source;
|
|
120
|
+
const base =
|
|
121
|
+
source.kind === "track"
|
|
122
|
+
? source.trackId.toLowerCase().replace(/[^a-z0-9_-]/g, "")
|
|
123
|
+
: source.kind === "orbit"
|
|
124
|
+
? `orbit${source.orbit}`
|
|
125
|
+
: "mix";
|
|
126
|
+
return /^[a-z]/.test(base) ? base : `t${base}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The sample's file name (without extension) from source and range. */
|
|
130
|
+
export function resampleFileName(command: ResampleCommand): string {
|
|
131
|
+
const range = command.range;
|
|
132
|
+
const suffix =
|
|
133
|
+
range.kind === "section"
|
|
134
|
+
? `-${range.name
|
|
135
|
+
.toLowerCase()
|
|
136
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
137
|
+
.replace(/^-|-$/g, "")}`
|
|
138
|
+
: range.kind === "bars"
|
|
139
|
+
? `-b${range.from}-${range.to}`
|
|
140
|
+
: "";
|
|
141
|
+
return `${sourceStem(command)}${suffix}`.slice(0, 48) || "resample";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The score with the new track: a one-shot sampler voice (or a granular
|
|
146
|
+
* source) at `src`, and its one note across the range.
|
|
147
|
+
*/
|
|
148
|
+
export function addResampleTrack(
|
|
149
|
+
score: TrackScore,
|
|
150
|
+
command: ResampleCommand,
|
|
151
|
+
render: Pick<
|
|
152
|
+
ResampleRender,
|
|
153
|
+
"sha256" | "startTick" | "durationTicks" | "from"
|
|
154
|
+
>,
|
|
155
|
+
trackId: string,
|
|
156
|
+
src: string,
|
|
157
|
+
): TrackScore {
|
|
158
|
+
const ref: SampleRef = {
|
|
159
|
+
src,
|
|
160
|
+
sha256: render.sha256,
|
|
161
|
+
from: render.from,
|
|
162
|
+
// A bounce starts at full level: no anti-click fade-in.
|
|
163
|
+
...(command.grain ? {} : { gain: RESAMPLE_GAIN, fadeInTime: 0 }),
|
|
164
|
+
};
|
|
165
|
+
const track: TrackInput = command.grain
|
|
166
|
+
? {
|
|
167
|
+
id: trackId,
|
|
168
|
+
name: trackId,
|
|
169
|
+
instrument: GRANULAR_INSTRUMENT,
|
|
170
|
+
granular: { src: ref, preset: "cloud" },
|
|
171
|
+
}
|
|
172
|
+
: {
|
|
173
|
+
id: trackId,
|
|
174
|
+
name: trackId,
|
|
175
|
+
instrument: "sampler",
|
|
176
|
+
sampler: {
|
|
177
|
+
mode: "oneshot",
|
|
178
|
+
voices: { [trackId.replace(/-/g, "_").slice(0, 32)]: ref },
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
let next = addTrack(score, track as TrackInput);
|
|
182
|
+
// One-shot slot 36 plays the only voice; a granular note at its root (60)
|
|
183
|
+
// plays the source at its own pitch.
|
|
184
|
+
const pitch = command.grain ? 60 : 36;
|
|
185
|
+
const notes = next.notes;
|
|
186
|
+
let n = 1;
|
|
187
|
+
while (notes.some((note) => note.id === `${trackId}-${n}`)) n += 1;
|
|
188
|
+
const ticks = Math.min(render.durationTicks, next.bars * barTicks(next));
|
|
189
|
+
next = new TrackScore({
|
|
190
|
+
...next.toJSON(),
|
|
191
|
+
notes: [
|
|
192
|
+
...notes,
|
|
193
|
+
{
|
|
194
|
+
id: `${trackId}-${n}`,
|
|
195
|
+
trackId,
|
|
196
|
+
startTick: render.startTick,
|
|
197
|
+
durationTicks: Math.max(1, ticks),
|
|
198
|
+
pitch,
|
|
199
|
+
velocity: 1,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
} as never);
|
|
203
|
+
return next;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type ResampleResult =
|
|
207
|
+
| Readonly<{
|
|
208
|
+
ok: true;
|
|
209
|
+
next: TrackScore;
|
|
210
|
+
trackId: string;
|
|
211
|
+
src: string;
|
|
212
|
+
sha256: string;
|
|
213
|
+
message: string;
|
|
214
|
+
}>
|
|
215
|
+
| Readonly<{ ok: false; message: string }>;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Render, write the file under the new track's directory and return the
|
|
219
|
+
* score with the new track. `samples` is the loaded bank of `score`.
|
|
220
|
+
*/
|
|
221
|
+
export async function runResample(
|
|
222
|
+
options: Readonly<{
|
|
223
|
+
projectRoot: string;
|
|
224
|
+
score: TrackScore;
|
|
225
|
+
command: ResampleCommand;
|
|
226
|
+
samples?: SampleBank;
|
|
227
|
+
}>,
|
|
228
|
+
): Promise<ResampleResult> {
|
|
229
|
+
const { score, command } = options;
|
|
230
|
+
if (score.tracks.length >= SCORE_LIMITS.maxTracks)
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
message: `resample · the song already has ${SCORE_LIMITS.maxTracks} tracks`,
|
|
234
|
+
};
|
|
235
|
+
let render: ResampleRender;
|
|
236
|
+
try {
|
|
237
|
+
render = renderResample(score, command, options.samples);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error instanceof ScoreValidationError)
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
message: `resample · ${error.message} · ${RESAMPLE_USAGE}`,
|
|
243
|
+
};
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
const trackId =
|
|
247
|
+
command.as ??
|
|
248
|
+
freeTrackId(
|
|
249
|
+
score,
|
|
250
|
+
`${sourceStem(command)}-${command.grain ? "grain" : "rs"}`,
|
|
251
|
+
);
|
|
252
|
+
if (score.tracks.some((track) => track.id === trackId))
|
|
253
|
+
return { ok: false, message: `resample · track ${trackId} already exists` };
|
|
254
|
+
// The directory follows the score that will hold the track.
|
|
255
|
+
const probe = addTrack(score, { id: trackId, name: trackId });
|
|
256
|
+
const slug = trackDirectories(probe).get(trackId) ?? trackId;
|
|
257
|
+
const root = await realpath(options.projectRoot);
|
|
258
|
+
const dir = join(root, "tracks", slug, "samples");
|
|
259
|
+
await mkdir(dir, { recursive: true });
|
|
260
|
+
const name = `${resampleFileName(command)}.wav`;
|
|
261
|
+
const target = join(dir, name);
|
|
262
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
263
|
+
await writeFile(temporary, render.wav);
|
|
264
|
+
await rename(temporary, target);
|
|
265
|
+
const src = `samples/${name}`;
|
|
266
|
+
const next = addResampleTrack(score, command, render, trackId, src);
|
|
267
|
+
const what =
|
|
268
|
+
command.source.kind === "track"
|
|
269
|
+
? command.source.trackId
|
|
270
|
+
: command.source.kind === "orbit"
|
|
271
|
+
? `orbit ${command.source.orbit}`
|
|
272
|
+
: "the mix";
|
|
273
|
+
return {
|
|
274
|
+
ok: true,
|
|
275
|
+
next,
|
|
276
|
+
trackId,
|
|
277
|
+
src: `tracks/${slug}/${src}`,
|
|
278
|
+
sha256: render.sha256,
|
|
279
|
+
message: `resampled ${what} · ${render.seconds.toFixed(2)} s → tracks/${slug}/${src} · ${render.sha256.slice(0, 12)} · new ${command.grain ? "granular" : "sampler"} track ${trackId}${command.source.kind === "track" ? ` · mute ${command.source.trackId} to hear only the resample` : ""}`,
|
|
280
|
+
};
|
|
281
|
+
}
|
package/src/commands/rhythm.ts
CHANGED
|
@@ -120,6 +120,9 @@ export function parseRhythmCommand(prompt: string): RhythmCommand | undefined {
|
|
|
120
120
|
fields[key] = Number(raw);
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
+
// `euclid hat` alone names a row to edit: the window opens the editor on
|
|
124
|
+
// it (`/euclid hat`), so it is not an empty edit here.
|
|
125
|
+
if (Object.keys(fields).length === 0) return undefined;
|
|
123
126
|
return { type: "rhythm-set", voice: lowerDrum(voice), fields };
|
|
124
127
|
}
|
|
125
128
|
|