@hraness/dawg 0.6.0 → 0.6.1
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 +34 -0
- package/DAWG.md +338 -51
- package/README.md +4 -4
- package/core/chords.ts +492 -4
- package/core/diff.ts +5 -0
- package/core/expression.ts +143 -6
- package/core/fx.ts +258 -6
- package/core/granular.ts +91 -0
- package/core/instruments.ts +161 -0
- package/core/keys.ts +573 -4
- package/core/resonators.ts +291 -3
- package/core/score.ts +349 -3
- package/core/sdk/print.ts +78 -3
- package/core/sdk/v1.ts +1320 -36
- package/core/sections.ts +12 -4
- package/core/strings.ts +237 -2
- package/core/winds.ts +652 -0
- package/guides/automation.md +1 -0
- package/guides/chords.md +3 -1
- package/guides/effects.md +3 -2
- package/guides/media.md +1 -1
- package/guides/performance.md +3 -0
- package/guides/resample.md +26 -0
- package/guides/sounds.md +7 -6
- package/package.json +1 -1
- package/src/agent/agent.ts +8 -2
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/ops.ts +4 -1
- package/src/agent/preview-tool.ts +2 -1
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/tools.ts +200 -15
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/arrange.ts +21 -0
- package/src/audio/dsp/envelope.ts +161 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/effects/chain.ts +18 -3
- package/src/audio/effects/common.ts +14 -0
- package/src/audio/effects/convolution.ts +106 -1
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/engine.ts +46 -5
- package/src/audio/fit.ts +45 -0
- package/src/audio/granular.ts +352 -21
- package/src/audio/instrument-check.ts +9 -5
- package/src/audio/instruments.ts +32 -3
- package/src/audio/keys/electric.ts +420 -0
- package/src/audio/keys/engine.ts +143 -19
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +50 -6
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/live-worker.ts +3 -1
- package/src/audio/live.ts +136 -9
- package/src/audio/preview.ts +15 -1
- package/src/audio/resample.ts +285 -0
- package/src/audio/resonators.ts +10 -2
- package/src/audio/sampler.ts +168 -21
- package/src/audio/samples.ts +26 -1
- package/src/audio/strings/body.ts +29 -16
- package/src/audio/strings/bow.ts +656 -0
- package/src/audio/strings/engine.ts +266 -20
- package/src/audio/strings/measure.test-helpers.ts +2 -0
- package/src/audio/wav.ts +119 -19
- package/src/audio/winds/engine.ts +302 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +80 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/voice.ts +479 -0
- package/src/commands/expression.ts +103 -22
- package/src/commands/fx.ts +4 -1
- package/src/commands/granular.ts +7 -2
- package/src/commands/help.ts +91 -8
- package/src/commands/keys.ts +287 -11
- package/src/commands/modal.ts +67 -4
- package/src/commands/resample.ts +281 -0
- package/src/commands/rig.ts +5 -1
- package/src/commands/sample.ts +49 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/string.ts +28 -2
- package/src/commands/strum.ts +473 -0
- package/src/commands/wind.ts +244 -0
- package/src/main.ts +129 -7
- package/src/project/check.ts +5 -1
- package/src/render.ts +16 -0
- package/src/session/presence.ts +16 -3
- package/src/tui/audition.ts +1 -1
- package/src/tui/menu.ts +637 -35
- package/src/tui/modal-menu.ts +34 -7
- package/src/tui/performance-menu.ts +42 -0
- package/src/tui/play-chords.ts +104 -3
- package/src/tui/play-mode.ts +1 -0
- package/src/tui/play-session.ts +34 -9
- package/src/tui/wind-menu.ts +144 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `wind` prompt command (0.6.1): blown waveguides on the focused track
|
|
3
|
+
* (core/winds.ts). The menu and the `set_wind` agent tool build the same
|
|
4
|
+
* edits.
|
|
5
|
+
*
|
|
6
|
+
* wind show the track's preset and overrides
|
|
7
|
+
* wind <preset> play a preset: flute recorder whistle
|
|
8
|
+
* ney shakuhachi panpipe suling bansuri
|
|
9
|
+
* clarinet bassclarinet oboe bassoon sax
|
|
10
|
+
* altosax barisax trumpet harmon plunger
|
|
11
|
+
* trombone tuba horn (and aliases such
|
|
12
|
+
* as saxophone frenchhorn panflute)
|
|
13
|
+
* wind <preset> <param> <value>… a preset with overrides
|
|
14
|
+
* wind preset <name> the same, spelled out
|
|
15
|
+
* wind mute <open|straight|cup|harmon|plunger>
|
|
16
|
+
* wind <param> <value> [<param> <value>…] breath 0.8, players 4 …
|
|
17
|
+
* wind <param> off back to the preset's value
|
|
18
|
+
* wind reset clear overrides (keep the preset)
|
|
19
|
+
* wind off back to the legacy wind tone
|
|
20
|
+
* wind list | presets wind the presets with one-line characters
|
|
21
|
+
*
|
|
22
|
+
* Any edit turns the track into a wind-engine track (`instrument "wind"`
|
|
23
|
+
* plus a `wind` field); a stored `wind` with no field keeps the legacy tone.
|
|
24
|
+
* One `updateTrack` revision and one undo step per command.
|
|
25
|
+
*/
|
|
26
|
+
import { FxValidationError } from "../../core/params.ts";
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_WIND_PRESET,
|
|
29
|
+
normalizeWind,
|
|
30
|
+
WIND_INSTRUMENT,
|
|
31
|
+
WIND_PARAMS,
|
|
32
|
+
WIND_PRESETS,
|
|
33
|
+
WIND_PRESET_NAMES,
|
|
34
|
+
WIND_SIMPLE,
|
|
35
|
+
windParamName,
|
|
36
|
+
windPresetFor,
|
|
37
|
+
type TrackWind,
|
|
38
|
+
} from "../../core/resonators.ts";
|
|
39
|
+
import {
|
|
40
|
+
ScoreValidationError,
|
|
41
|
+
updateTrack,
|
|
42
|
+
type TrackScore,
|
|
43
|
+
} from "../../core/score.ts";
|
|
44
|
+
import { nearestWord } from "../audio/instrument-check.ts";
|
|
45
|
+
import { parseParamValue } from "./fx.ts";
|
|
46
|
+
|
|
47
|
+
export type WindCommand =
|
|
48
|
+
| { type: "wind-show" }
|
|
49
|
+
| { type: "wind-list" }
|
|
50
|
+
| { type: "wind-reset" }
|
|
51
|
+
| { type: "wind-off" }
|
|
52
|
+
| { type: "wind-preset"; preset: string }
|
|
53
|
+
| { type: "wind-usage"; message: string }
|
|
54
|
+
| {
|
|
55
|
+
type: "wind-set";
|
|
56
|
+
/** A preset applied before the values (`wind trumpet mute harmon`). */
|
|
57
|
+
preset?: string;
|
|
58
|
+
/** `null` returns a parameter to the preset's value. */
|
|
59
|
+
values: Readonly<Record<string, number | string | boolean | null>>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const WIND_USAGE =
|
|
63
|
+
"wind <preset> | wind <param> <value> | wind mute <name> | wind reset | wind off | wind presets";
|
|
64
|
+
|
|
65
|
+
function rangeOf(name: string): string {
|
|
66
|
+
const spec = WIND_PARAMS[name]!;
|
|
67
|
+
if (spec.kind === "number") return `${name} is ${spec.min}..${spec.max}`;
|
|
68
|
+
if (spec.kind === "enum") return `${name} is one of ${spec.values.join(" ")}`;
|
|
69
|
+
return `${name} is on or off`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseWindCommand(prompt: string): WindCommand | undefined {
|
|
73
|
+
const words = prompt.trim().toLowerCase().split(/\s+/);
|
|
74
|
+
if (words[0] === "presets" && words[1] === "wind" && words.length === 2)
|
|
75
|
+
return { type: "wind-list" };
|
|
76
|
+
if (words[0] !== "wind") return undefined;
|
|
77
|
+
if (words.length === 1) return { type: "wind-show" };
|
|
78
|
+
if (prompt.length > 512) return undefined;
|
|
79
|
+
const rest = words.slice(1);
|
|
80
|
+
if (rest.length === 1 && rest[0] === "reset") return { type: "wind-reset" };
|
|
81
|
+
if (rest.length === 1 && rest[0] === "off") return { type: "wind-off" };
|
|
82
|
+
if (rest.length === 1 && (rest[0] === "list" || rest[0] === "presets"))
|
|
83
|
+
return { type: "wind-list" };
|
|
84
|
+
if (rest[0] === "preset") {
|
|
85
|
+
const preset = rest.length === 2 ? windPresetFor(rest[1]!) : undefined;
|
|
86
|
+
return preset
|
|
87
|
+
? { type: "wind-preset", preset }
|
|
88
|
+
: {
|
|
89
|
+
type: "wind-usage",
|
|
90
|
+
message: `wind preset is one of ${WIND_PRESET_NAMES.join(" ")}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (rest.length === 1) {
|
|
94
|
+
const preset = windPresetFor(rest[0]!);
|
|
95
|
+
if (preset) return { type: "wind-preset", preset };
|
|
96
|
+
const mute = WIND_PARAMS.mute;
|
|
97
|
+
if (mute?.kind === "enum" && mute.values.includes(rest[0]!))
|
|
98
|
+
return { type: "wind-set", values: { mute: rest[0]! } };
|
|
99
|
+
return { type: "wind-usage", message: WIND_USAGE };
|
|
100
|
+
}
|
|
101
|
+
// `wind <preset> <param> <value>…`: the preset, then its overrides.
|
|
102
|
+
const lead = rest.length % 2 === 1 ? windPresetFor(rest[0]!) : undefined;
|
|
103
|
+
if (lead) rest.shift();
|
|
104
|
+
if (rest.length % 2 !== 0) return { type: "wind-usage", message: WIND_USAGE };
|
|
105
|
+
const values: Record<string, number | string | boolean | null> = {};
|
|
106
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
107
|
+
const name = windParamName(rest[index]!);
|
|
108
|
+
if (!name) {
|
|
109
|
+
const word = rest[index]!.slice(0, 24);
|
|
110
|
+
const near = nearestWord(word, Object.keys(WIND_PARAMS));
|
|
111
|
+
return {
|
|
112
|
+
type: "wind-usage",
|
|
113
|
+
message: `wind has no parameter ${word}${near ? ` · did you mean ${near}?` : ""} · ${WIND_SIMPLE.join(" ")} …`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const word = rest[index + 1]!;
|
|
117
|
+
if (word === "off" && name !== "stopped") {
|
|
118
|
+
values[name] = null;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (word === "unset") {
|
|
122
|
+
values[name] = null;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const value = parseParamValue(WIND_PARAMS[name]!, word);
|
|
126
|
+
if (value === undefined)
|
|
127
|
+
return { type: "wind-usage", message: rangeOf(name) };
|
|
128
|
+
values[name] = value;
|
|
129
|
+
}
|
|
130
|
+
return lead
|
|
131
|
+
? { type: "wind-set", preset: lead, values }
|
|
132
|
+
: { type: "wind-set", values };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `preset sax · breath 0.8 · players 4`, overrides in canonical order. */
|
|
136
|
+
export function describeWind(wind: TrackWind | undefined): string {
|
|
137
|
+
if (!wind) return "not a wind-engine track";
|
|
138
|
+
const preset = wind.preset ?? DEFAULT_WIND_PRESET;
|
|
139
|
+
const overrides = Object.entries(wind)
|
|
140
|
+
.filter(([key]) => key !== "preset")
|
|
141
|
+
.map(([key, value]) => `${key} ${value}`);
|
|
142
|
+
return [`preset ${preset}`, ...overrides].join(" · ");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One line per preset, for `wind list` and the agent brief. */
|
|
146
|
+
export function windListLines(): string[] {
|
|
147
|
+
return WIND_PRESET_NAMES.map(
|
|
148
|
+
(name) => `${name.padEnd(13)} ${WIND_PRESETS[name].doc}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type WindResult = Readonly<{
|
|
153
|
+
ok: boolean;
|
|
154
|
+
message: string;
|
|
155
|
+
next?: TrackScore;
|
|
156
|
+
kind?: string;
|
|
157
|
+
payload?: Record<string, unknown>;
|
|
158
|
+
}>;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The wind field a command leaves on a track (pure; the agent tool shares
|
|
162
|
+
* it). A preset switch keeps the overrides; `reset` keeps the preset.
|
|
163
|
+
*/
|
|
164
|
+
export function nextWind(
|
|
165
|
+
current: TrackWind | undefined,
|
|
166
|
+
command: Extract<
|
|
167
|
+
WindCommand,
|
|
168
|
+
{ type: "wind-reset" | "wind-preset" | "wind-set" }
|
|
169
|
+
>,
|
|
170
|
+
): TrackWind {
|
|
171
|
+
if (command.type === "wind-reset")
|
|
172
|
+
return normalizeWind(current?.preset ? { preset: current.preset } : {})!;
|
|
173
|
+
const base: Record<string, unknown> = { ...current };
|
|
174
|
+
if (command.type === "wind-preset") base.preset = command.preset;
|
|
175
|
+
else {
|
|
176
|
+
if (command.preset) base.preset = command.preset;
|
|
177
|
+
for (const [key, value] of Object.entries(command.values)) {
|
|
178
|
+
if (value === null) delete base[key];
|
|
179
|
+
else base[key] = value;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return normalizeWind(base)!;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function applyWindCommand(
|
|
186
|
+
score: TrackScore,
|
|
187
|
+
trackId: string,
|
|
188
|
+
command: WindCommand,
|
|
189
|
+
): WindResult {
|
|
190
|
+
if (command.type === "wind-usage")
|
|
191
|
+
return { ok: false, message: `wind · ${command.message}` };
|
|
192
|
+
if (command.type === "wind-list")
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
message: `wind presets · ${WIND_PRESET_NAMES.join(" ")}`,
|
|
196
|
+
};
|
|
197
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
198
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
199
|
+
const current = track.instrument === WIND_INSTRUMENT ? track.wind : undefined;
|
|
200
|
+
if (command.type === "wind-show")
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
message: current
|
|
204
|
+
? `wind · ${describeWind(current)}`
|
|
205
|
+
: `wind · ${trackId} plays ${track.instrument} · wind <preset> to switch (${WIND_PRESET_NAMES.join(" ")})`,
|
|
206
|
+
};
|
|
207
|
+
if (track.sampler || track.instrument === "kit")
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
message: `wind · ${trackId} is a ${track.sampler ? "sampler" : "drum"} track`,
|
|
211
|
+
};
|
|
212
|
+
if (command.type === "wind-off") {
|
|
213
|
+
if (!current) return { ok: true, message: "wind · already off" };
|
|
214
|
+
// The stored word stays `wind`, which is the legacy tone without a field.
|
|
215
|
+
const next = updateTrack(score, trackId, { wind: null });
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
message: `wind · off (legacy wind tone) · wind ${current.preset ?? DEFAULT_WIND_PRESET} turns it back on`,
|
|
219
|
+
next,
|
|
220
|
+
kind: "score.wind",
|
|
221
|
+
payload: { trackId, instrument: WIND_INSTRUMENT, wind: null },
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
let wind: TrackWind;
|
|
225
|
+
let next: TrackScore;
|
|
226
|
+
try {
|
|
227
|
+
wind = nextWind(current, command);
|
|
228
|
+
next = updateTrack(score, trackId, { instrument: WIND_INSTRUMENT, wind });
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (
|
|
231
|
+
error instanceof ScoreValidationError ||
|
|
232
|
+
error instanceof FxValidationError
|
|
233
|
+
)
|
|
234
|
+
return { ok: false, message: `wind · ${error.message}` };
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
ok: true,
|
|
239
|
+
message: `wind · ${describeWind(wind)}`,
|
|
240
|
+
next,
|
|
241
|
+
kind: "score.wind",
|
|
242
|
+
payload: { trackId, instrument: WIND_INSTRUMENT, wind },
|
|
243
|
+
};
|
|
244
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -69,11 +69,22 @@ import {
|
|
|
69
69
|
rigTrackFields,
|
|
70
70
|
rigWordPatch,
|
|
71
71
|
} from "./commands/rig.ts";
|
|
72
|
+
import {
|
|
73
|
+
applyGuitarCommand,
|
|
74
|
+
applyStrumCommand,
|
|
75
|
+
parseGuitarCommand,
|
|
76
|
+
parseStrumCommand,
|
|
77
|
+
} from "./commands/strum.ts";
|
|
72
78
|
import {
|
|
73
79
|
applyModalCommand,
|
|
74
80
|
modalListLines,
|
|
75
81
|
parseModalCommand,
|
|
76
82
|
} from "./commands/modal.ts";
|
|
83
|
+
import {
|
|
84
|
+
applyWindCommand,
|
|
85
|
+
parseWindCommand,
|
|
86
|
+
windListLines,
|
|
87
|
+
} from "./commands/wind.ts";
|
|
77
88
|
import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
|
|
78
89
|
import { applyStringCommand, parseStringCommand } from "./commands/string.ts";
|
|
79
90
|
import { instrumentPatchForWord, isModalWord } from "../core/resonators.ts";
|
|
@@ -127,6 +138,8 @@ import {
|
|
|
127
138
|
voiceNameFrom,
|
|
128
139
|
} from "./commands/sample.ts";
|
|
129
140
|
import { applyFitCommand, fitVoice, parseFitCommand } from "./commands/fit.ts";
|
|
141
|
+
import { applyShiftCommand, parseShiftCommand } from "./commands/shift.ts";
|
|
142
|
+
import { parseResampleCommand, runResample } from "./commands/resample.ts";
|
|
130
143
|
import { suggestFitMode } from "./audio/dsp/onset.ts";
|
|
131
144
|
import {
|
|
132
145
|
SampleLibrary,
|
|
@@ -307,11 +320,16 @@ function parsesLocally(text: string): boolean {
|
|
|
307
320
|
parsePackCommand,
|
|
308
321
|
parseSampleCommand,
|
|
309
322
|
parseFitCommand,
|
|
323
|
+
parseShiftCommand,
|
|
324
|
+
parseResampleCommand,
|
|
310
325
|
parseWavetableCommand,
|
|
311
326
|
parseTimeCommand,
|
|
312
327
|
parseTuningCommand,
|
|
313
328
|
parseRigCommand,
|
|
314
329
|
parseModalCommand,
|
|
330
|
+
parseGuitarCommand,
|
|
331
|
+
parseStrumCommand,
|
|
332
|
+
parseWindCommand,
|
|
315
333
|
].some((parse) => parse(text) !== undefined);
|
|
316
334
|
}
|
|
317
335
|
|
|
@@ -487,6 +505,22 @@ const freshWorkspace = !(await stat(join(process.cwd(), ".dawg")).then(
|
|
|
487
505
|
() => true,
|
|
488
506
|
() => false,
|
|
489
507
|
));
|
|
508
|
+
// Decode the import before a session exists, so a bad file is one line.
|
|
509
|
+
let importedScore: TrackScore | undefined;
|
|
510
|
+
if (importPath) {
|
|
511
|
+
try {
|
|
512
|
+
importedScore = decodeLoop(await readLoopFile(importPath));
|
|
513
|
+
} catch (error) {
|
|
514
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
515
|
+
const midi = /\.midi?$/i.test(importPath)
|
|
516
|
+
? " · MIDI files are not imported; --import takes a .track.json loop"
|
|
517
|
+
: "";
|
|
518
|
+
process.stderr.write(
|
|
519
|
+
`dawg: cannot import ${importPath} · ${reason.split("\n")[0]!.slice(0, 160)}${midi}\n`,
|
|
520
|
+
);
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
490
524
|
const session = await ensureSession(initial.toJSON(), sessionOptions);
|
|
491
525
|
// dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
|
|
492
526
|
let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
|
|
@@ -499,9 +533,8 @@ let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
|
|
|
499
533
|
let record: SessionRecord<ReturnType<TrackScore["toJSON"]>> =
|
|
500
534
|
port.mode === "daemon" ? await port.load() : session.record;
|
|
501
535
|
let score = scoreFromJSON(record.composition);
|
|
502
|
-
if (importPath) {
|
|
503
|
-
|
|
504
|
-
score = imported;
|
|
536
|
+
if (importPath && importedScore) {
|
|
537
|
+
score = importedScore;
|
|
505
538
|
record = await port.append(
|
|
506
539
|
record,
|
|
507
540
|
{
|
|
@@ -1656,7 +1689,11 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1656
1689
|
}
|
|
1657
1690
|
const chordsCommand = command.match(/^\/chords(?:\s+(.+))?$/i);
|
|
1658
1691
|
if (chordsCommand) {
|
|
1659
|
-
const result = applyChordsCommand(
|
|
1692
|
+
const result = applyChordsCommand(
|
|
1693
|
+
chordSettings,
|
|
1694
|
+
chordsCommand[1] ?? "",
|
|
1695
|
+
score.tempoBpm,
|
|
1696
|
+
);
|
|
1660
1697
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1661
1698
|
}
|
|
1662
1699
|
const countIn = command.match(/^\/count-?in\s+([0-2])$/i);
|
|
@@ -1731,6 +1768,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1731
1768
|
if (sample) return sampleCommand(sample);
|
|
1732
1769
|
const fit = parseFitCommand(command);
|
|
1733
1770
|
if (fit) return fitCommand(fit);
|
|
1771
|
+
const shift = parseShiftCommand(command);
|
|
1772
|
+
if (shift) {
|
|
1773
|
+
const result = applyShiftCommand(score, requestedTrack, shift);
|
|
1774
|
+
if (!result.ok) return fail(result.message);
|
|
1775
|
+
await commitScore(result.next, "sample.set", { trackId: requestedTrack });
|
|
1776
|
+
await projectSync?.flushScore();
|
|
1777
|
+
return ok(result.message);
|
|
1778
|
+
}
|
|
1779
|
+
const resample = parseResampleCommand(command);
|
|
1780
|
+
if (resample) return resampleCommand(resample);
|
|
1734
1781
|
const pack = parsePackCommand(command);
|
|
1735
1782
|
if (pack) return packCommand(pack);
|
|
1736
1783
|
const pattern = parsePatternCommand(command);
|
|
@@ -1765,6 +1812,25 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1765
1812
|
await commitScore(result.next, result.kind, result.payload);
|
|
1766
1813
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1767
1814
|
}
|
|
1815
|
+
const guitar = parseGuitarCommand(command);
|
|
1816
|
+
if (guitar) {
|
|
1817
|
+
if (guitar.type !== "guitar-show" && guitar.type !== "guitar-hint")
|
|
1818
|
+
await materializeDraft();
|
|
1819
|
+
const result = applyGuitarCommand(score, requestedTrack, guitar);
|
|
1820
|
+
if (result.next && result.kind)
|
|
1821
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1822
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1823
|
+
}
|
|
1824
|
+
const strum = parseStrumCommand(command);
|
|
1825
|
+
if (strum) {
|
|
1826
|
+
if (strum.type === "strum") await materializeDraft();
|
|
1827
|
+
const result = applyStrumCommand(score, requestedTrack, strum, () =>
|
|
1828
|
+
randomUUID().slice(0, 12),
|
|
1829
|
+
);
|
|
1830
|
+
if (result.next && result.kind)
|
|
1831
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1832
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1833
|
+
}
|
|
1768
1834
|
const rig = parseRigCommand(command);
|
|
1769
1835
|
if (rig) {
|
|
1770
1836
|
if (rig.type !== "rig-show" && rig.type !== "rig-hint")
|
|
@@ -1827,6 +1893,17 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1827
1893
|
await commitScore(result.next, result.kind, result.payload);
|
|
1828
1894
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1829
1895
|
}
|
|
1896
|
+
const windCommand = parseWindCommand(command);
|
|
1897
|
+
if (windCommand) {
|
|
1898
|
+
if (windCommand.type === "wind-list")
|
|
1899
|
+
tui.openText("wind presets", windListLines());
|
|
1900
|
+
if (windCommand.type !== "wind-show" && windCommand.type !== "wind-list")
|
|
1901
|
+
await materializeDraft();
|
|
1902
|
+
const result = applyWindCommand(score, requestedTrack, windCommand);
|
|
1903
|
+
if (result.next && result.kind)
|
|
1904
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1905
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1906
|
+
}
|
|
1830
1907
|
const synth = parseSynthCommand(command);
|
|
1831
1908
|
if (synth) {
|
|
1832
1909
|
if (synth.type !== "synth-list") await materializeDraft();
|
|
@@ -2078,6 +2155,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2078
2155
|
// Plain `marimba` keeps the legacy tone; point at the mallet engine.
|
|
2079
2156
|
if (parsed.patch.instrument === "marimba" && !("modal" in parsed.patch))
|
|
2080
2157
|
return `track · ${requestedTrack} · marimba (legacy tone) · modal marimba for the mallet engine`;
|
|
2158
|
+
// Plain `wind` keeps the legacy tone; point at the wind engine.
|
|
2159
|
+
if (parsed.patch.instrument === "wind" && !("wind" in parsed.patch))
|
|
2160
|
+
return `track · ${requestedTrack} · wind (legacy tone) · wind flute (or sax, trumpet …) for the wind engine`;
|
|
2081
2161
|
const sine =
|
|
2082
2162
|
word !== undefined && !("string" in parsed.patch)
|
|
2083
2163
|
? plainSineAdvice(word)
|
|
@@ -2375,6 +2455,36 @@ async function fitCommand(
|
|
|
2375
2455
|
);
|
|
2376
2456
|
}
|
|
2377
2457
|
|
|
2458
|
+
async function resampleCommand(
|
|
2459
|
+
command: NonNullable<ReturnType<typeof parseResampleCommand>>,
|
|
2460
|
+
): Promise<Receipt> {
|
|
2461
|
+
await materializeDraft();
|
|
2462
|
+
// The source's own samples must be loaded for the render.
|
|
2463
|
+
if (hasSamplerTracks(score)) await sampleProblems(score);
|
|
2464
|
+
const result = await runResample({
|
|
2465
|
+
projectRoot: process.cwd(),
|
|
2466
|
+
score,
|
|
2467
|
+
command,
|
|
2468
|
+
...(liveSampleBank ? { samples: liveSampleBank } : {}),
|
|
2469
|
+
});
|
|
2470
|
+
if (!result.ok) return fail(result.message);
|
|
2471
|
+
const problems = (await sampleProblems(result.next)).filter(
|
|
2472
|
+
(problem) =>
|
|
2473
|
+
problem.trackId === result.trackId && problem.level === "error",
|
|
2474
|
+
);
|
|
2475
|
+
if (problems.length > 0) return fail(`resample · ${problems[0]!.message}`);
|
|
2476
|
+
await commitScore(result.next, "resample", {
|
|
2477
|
+
trackId: result.trackId,
|
|
2478
|
+
src: result.src,
|
|
2479
|
+
sha256: result.sha256,
|
|
2480
|
+
});
|
|
2481
|
+
await port.focus(result.trackId);
|
|
2482
|
+
requestedTrack = result.trackId;
|
|
2483
|
+
draftTrack = false;
|
|
2484
|
+
await projectSync?.flushScore();
|
|
2485
|
+
return ok(result.message);
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2378
2488
|
async function sampleCommand(
|
|
2379
2489
|
command: NonNullable<ReturnType<typeof parseSampleCommand>>,
|
|
2380
2490
|
): Promise<Receipt> {
|
|
@@ -3167,7 +3277,11 @@ function stagedChordSettings(): ChordSettings | undefined {
|
|
|
3167
3277
|
if (chords.length === 0) return undefined;
|
|
3168
3278
|
const settings = { ...chordSettings };
|
|
3169
3279
|
for (const command of chords)
|
|
3170
|
-
applyChordsCommand(
|
|
3280
|
+
applyChordsCommand(
|
|
3281
|
+
settings,
|
|
3282
|
+
command.match(CHORDS_COMMAND)![1]!,
|
|
3283
|
+
score.tempoBpm,
|
|
3284
|
+
);
|
|
3171
3285
|
return settings;
|
|
3172
3286
|
}
|
|
3173
3287
|
|
|
@@ -3224,7 +3338,11 @@ async function applyStaged(
|
|
|
3224
3338
|
const chords = command.trim().match(CHORDS_COMMAND);
|
|
3225
3339
|
if (chords) {
|
|
3226
3340
|
// Window state: checked on a copy; kept settings apply on Enter.
|
|
3227
|
-
const result = applyChordsCommand(
|
|
3341
|
+
const result = applyChordsCommand(
|
|
3342
|
+
{ ...chordSettings },
|
|
3343
|
+
chords[1]!,
|
|
3344
|
+
base.tempoBpm,
|
|
3345
|
+
);
|
|
3228
3346
|
return { next: base, ok: result.ok, message: result.message };
|
|
3229
3347
|
}
|
|
3230
3348
|
await prefetchStaged(base, command);
|
|
@@ -3417,7 +3535,11 @@ async function keepStaged(): Promise<Receipt> {
|
|
|
3417
3535
|
CHORDS_COMMAND.test(command),
|
|
3418
3536
|
);
|
|
3419
3537
|
for (const command of chordCommands)
|
|
3420
|
-
applyChordsCommand(
|
|
3538
|
+
applyChordsCommand(
|
|
3539
|
+
chordSettings,
|
|
3540
|
+
command.match(CHORDS_COMMAND)![1]!,
|
|
3541
|
+
score.tempoBpm,
|
|
3542
|
+
);
|
|
3421
3543
|
const scoreCommands = taken.commands.filter(
|
|
3422
3544
|
(command) => !CHORDS_COMMAND.test(command),
|
|
3423
3545
|
);
|
package/src/project/check.ts
CHANGED
|
@@ -8,7 +8,10 @@ import {
|
|
|
8
8
|
formatDiagnostic,
|
|
9
9
|
type Diagnostic,
|
|
10
10
|
} from "../../core/sdk/eval.ts";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
legacyResonatorWarnings,
|
|
13
|
+
modalPairWarnings,
|
|
14
|
+
} from "../../core/resonators.ts";
|
|
12
15
|
import { plainSineWarnings } from "../audio/instrument-check.ts";
|
|
13
16
|
import { isProject } from "./init.ts";
|
|
14
17
|
import { typecheckProject } from "./typecheck.ts";
|
|
@@ -53,6 +56,7 @@ export async function checkProject(
|
|
|
53
56
|
evaluated.ok
|
|
54
57
|
? [
|
|
55
58
|
...legacyResonatorWarnings(evaluated.score.tracks),
|
|
59
|
+
...modalPairWarnings(evaluated.score.tracks),
|
|
56
60
|
...plainSineWarnings(evaluated.score.tracks),
|
|
57
61
|
]
|
|
58
62
|
: [],
|
package/src/render.ts
CHANGED
|
@@ -229,6 +229,15 @@ export async function runRenderCommand(
|
|
|
229
229
|
`rendered · ${target} · ${seconds.toFixed(2)} s · ${wav.byteLength} bytes · ${sha}\n`,
|
|
230
230
|
);
|
|
231
231
|
if (credits) stdout.write(`credits · ${credits}\n`);
|
|
232
|
+
const clipped = clippedSamples(audio.pcm);
|
|
233
|
+
if (clipped > 0)
|
|
234
|
+
stderr.write(
|
|
235
|
+
`warning · ${clipped} ${clipped === 1 ? "sample clips" : "samples clip"} at full scale · ${
|
|
236
|
+
audio.master
|
|
237
|
+
? "add master limiter or lower the master gain"
|
|
238
|
+
: "lower track volumes or add a master with a limiter (master streaming, or master: { limiter: {} } in song.ts)"
|
|
239
|
+
}\n`,
|
|
240
|
+
);
|
|
232
241
|
if (options.has("--measure"))
|
|
233
242
|
stdout.write(
|
|
234
243
|
`loudness · ${measurementLine(measureRendered(audio, false).mix, audio.master)}\n`,
|
|
@@ -238,6 +247,13 @@ export async function runRenderCommand(
|
|
|
238
247
|
return 0;
|
|
239
248
|
}
|
|
240
249
|
|
|
250
|
+
/** Samples at or past 16-bit full scale: the mix bus hit the rails. */
|
|
251
|
+
export function clippedSamples(pcm: Int16Array): number {
|
|
252
|
+
let count = 0;
|
|
253
|
+
for (const value of pcm) if (value >= 32_767 || value <= -32_768) count += 1;
|
|
254
|
+
return count;
|
|
255
|
+
}
|
|
256
|
+
|
|
241
257
|
async function loadScore(
|
|
242
258
|
workspace: string,
|
|
243
259
|
options: ReadonlyMap<string, string>,
|
package/src/session/presence.ts
CHANGED
|
@@ -32,6 +32,8 @@ export class FilePresence {
|
|
|
32
32
|
private readonly lock: string;
|
|
33
33
|
private entry: PresenceEntry;
|
|
34
34
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
35
|
+
private writing: Promise<void> = Promise.resolve();
|
|
36
|
+
private writes = 0;
|
|
35
37
|
|
|
36
38
|
public constructor(paths: SessionPaths, entry: PresenceEntry) {
|
|
37
39
|
this.dir = `${paths.record}.presence`;
|
|
@@ -56,7 +58,8 @@ export class FilePresence {
|
|
|
56
58
|
|
|
57
59
|
public async focus(trackId: string | null): Promise<void> {
|
|
58
60
|
this.entry = { ...this.entry, focusedTrackId: trackId };
|
|
59
|
-
|
|
61
|
+
// Presence is advisory: a failed heartbeat write never refuses a focus change.
|
|
62
|
+
await this.write().catch(() => undefined);
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
public async claim(
|
|
@@ -133,9 +136,19 @@ export class FilePresence {
|
|
|
133
136
|
return join(this.dir, `${this.entry.clientId}.json`);
|
|
134
137
|
}
|
|
135
138
|
|
|
136
|
-
|
|
139
|
+
/** Writes are serialized so the heartbeat and focus() never share a rename. */
|
|
140
|
+
private write(): Promise<void> {
|
|
141
|
+
const next = this.writing.then(
|
|
142
|
+
() => this.writeNow(),
|
|
143
|
+
() => this.writeNow(),
|
|
144
|
+
);
|
|
145
|
+
this.writing = next.catch(() => undefined);
|
|
146
|
+
return next;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async writeNow(): Promise<void> {
|
|
137
150
|
await mkdir(this.dir, { recursive: true });
|
|
138
|
-
const temporary = `${this.file()}.${process.pid}.tmp`;
|
|
151
|
+
const temporary = `${this.file()}.${process.pid}.${++this.writes}.tmp`;
|
|
139
152
|
await writeFile(
|
|
140
153
|
temporary,
|
|
141
154
|
JSON.stringify({ ...this.entry, at: Date.now() }),
|
package/src/tui/audition.ts
CHANGED
|
@@ -528,7 +528,7 @@ export function auditionKey(value: string): AuditionKey | undefined {
|
|
|
528
528
|
* before.
|
|
529
529
|
*/
|
|
530
530
|
const STAGEABLE =
|
|
531
|
-
/^\/?(?:(fx|effects|filter|lowpass|synth|string|modal|wt|wavetable|grain|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid|art|articulation|bend|vibrato|glide|portamento|velcurve|vel-curve|pedal|sustain|humanize|tuning|tune|master|keys|piano)\s+\S|pack\s+use\s+\S|(?:piano|grand|upright|felt|honkytonk|prepared|ballad)(?:\s+piano)?\s*$)/i;
|
|
531
|
+
/^\/?(?:(fx|effects|filter|lowpass|synth|string|bowed|modal|wind|wt|wavetable|grain|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid|art|articulation|bend|vibrato|glide|portamento|velcurve|vel-curve|pedal|sustain|humanize|tuning|tune|master|keys|piano|epiano|rhodes|wurli|wurlitzer|clav|clavinet)\s+\S|pack\s+use\s+\S|(?:piano|grand|upright|felt|honkytonk|prepared|ballad)(?:\s+piano)?\s*$|(?:epiano|rhodes|suitcase|dyno|wurli|wurlitzer|clav|clavinet|funkclav)\s*$|(?:tonewheel|hammond|b3|gospel|jazzorgan|combo|farfisa|vox|pipe|church|pipeorgan|churchorgan|flutes|cornet|reeds|celeste)(?:\s+\S.*)?$|rotary\s+(?:slow|fast|stop)\s*$)/i;
|
|
532
532
|
/** Subcommands that list or show instead of changing the sound. */
|
|
533
533
|
const READ_ONLY = /^\/?\S+\s+(list|show|info|help|measure|meter|presets?)\s*$/i;
|
|
534
534
|
/** `master <unit>` alone only shows the unit. */
|