@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
package/src/commands/resample.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* track (the `cloud` preset) holding one note across the range. The source
|
|
9
9
|
* stays as it is; mute it to hear only the resample.
|
|
10
10
|
*/
|
|
11
|
+
import { newId } from "../../core/ids.ts";
|
|
11
12
|
import { mkdir, realpath, rename, writeFile } from "node:fs/promises";
|
|
12
13
|
import { join } from "node:path";
|
|
13
14
|
import { MAX_ORBIT } from "../../core/fx.ts";
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
TrackScore,
|
|
19
20
|
addTrack,
|
|
20
21
|
type SampleRef,
|
|
22
|
+
requirePinnedRef,
|
|
21
23
|
type TrackInput,
|
|
22
24
|
} from "../../core/score.ts";
|
|
23
25
|
import { trackDirectories } from "../../core/sdk/print.ts";
|
|
@@ -155,13 +157,16 @@ export function addResampleTrack(
|
|
|
155
157
|
trackId: string,
|
|
156
158
|
src: string,
|
|
157
159
|
): TrackScore {
|
|
158
|
-
const ref: SampleRef =
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
const ref: SampleRef = requirePinnedRef(
|
|
161
|
+
{
|
|
162
|
+
src,
|
|
163
|
+
sha256: render.sha256,
|
|
164
|
+
from: render.from,
|
|
165
|
+
// A bounce starts at full level: no anti-click fade-in.
|
|
166
|
+
...(command.grain ? {} : { gain: RESAMPLE_GAIN, fadeInTime: 0 }),
|
|
167
|
+
},
|
|
168
|
+
"resample",
|
|
169
|
+
);
|
|
165
170
|
const track: TrackInput = command.grain
|
|
166
171
|
? {
|
|
167
172
|
id: trackId,
|
|
@@ -183,15 +188,13 @@ export function addResampleTrack(
|
|
|
183
188
|
// plays the source at its own pitch.
|
|
184
189
|
const pitch = command.grain ? 60 : 36;
|
|
185
190
|
const notes = next.notes;
|
|
186
|
-
let n = 1;
|
|
187
|
-
while (notes.some((note) => note.id === `${trackId}-${n}`)) n += 1;
|
|
188
191
|
const ticks = Math.min(render.durationTicks, next.bars * barTicks(next));
|
|
189
192
|
next = new TrackScore({
|
|
190
193
|
...next.toJSON(),
|
|
191
194
|
notes: [
|
|
192
195
|
...notes,
|
|
193
196
|
{
|
|
194
|
-
id:
|
|
197
|
+
id: newId(trackId),
|
|
195
198
|
trackId,
|
|
196
199
|
startTick: render.startTick,
|
|
197
200
|
durationTicks: Math.max(1, ticks),
|
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
|
|
package/src/commands/rig.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
type FxCommand,
|
|
46
46
|
type FxResult,
|
|
47
47
|
} from "./fx.ts";
|
|
48
|
+
import { nearest } from "./nearest.ts";
|
|
48
49
|
|
|
49
50
|
export type RigCommand =
|
|
50
51
|
| { type: "rig-show" }
|
|
@@ -67,33 +68,11 @@ function isStage(word: string): word is RigStage {
|
|
|
67
68
|
|
|
68
69
|
/** `unknown rig crunh · did you mean crunch? · rigs clean crunch …` */
|
|
69
70
|
function unknownRigMessage(name: string): string {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const distance = editDistance(name, candidate);
|
|
73
|
-
if (!best || distance < best.distance) best = { name: candidate, distance };
|
|
74
|
-
}
|
|
75
|
-
const near =
|
|
76
|
-
best && best.distance <= Math.max(2, Math.floor(name.length / 3))
|
|
77
|
-
? ` · did you mean ${best.name}?`
|
|
78
|
-
: "";
|
|
71
|
+
const match = nearest(name, RIG_PRESET_NAMES);
|
|
72
|
+
const near = match ? ` · did you mean ${match}?` : "";
|
|
79
73
|
return `unknown rig ${name}${near} · rigs ${RIG_PRESET_NAMES.join(" ")} reset`;
|
|
80
74
|
}
|
|
81
75
|
|
|
82
|
-
function editDistance(a: string, b: string): number {
|
|
83
|
-
let row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
84
|
-
for (let i = 1; i <= a.length; i++) {
|
|
85
|
-
const next = [i];
|
|
86
|
-
for (let j = 1; j <= b.length; j++)
|
|
87
|
-
next[j] = Math.min(
|
|
88
|
-
row[j]! + 1,
|
|
89
|
-
next[j - 1]! + 1,
|
|
90
|
-
row[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
91
|
-
);
|
|
92
|
-
row = next;
|
|
93
|
-
}
|
|
94
|
-
return row[b.length]!;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
76
|
export function parseRigCommand(prompt: string): RigCommand | undefined {
|
|
98
77
|
if (prompt.length > 1_024) return undefined;
|
|
99
78
|
const words = prompt.trim().replace(/^\//, "").toLowerCase().split(/\s+/);
|
package/src/commands/sample.ts
CHANGED
|
@@ -24,6 +24,9 @@ import {
|
|
|
24
24
|
samplerVoiceSlots,
|
|
25
25
|
type Sampler,
|
|
26
26
|
type SampleRef,
|
|
27
|
+
type PinnedSampleRef,
|
|
28
|
+
requirePinnedRef,
|
|
29
|
+
ScoreValidationError,
|
|
27
30
|
type Track,
|
|
28
31
|
} from "../../core/score.ts";
|
|
29
32
|
import { trackDirectories } from "../../core/sdk/print.ts";
|
|
@@ -317,8 +320,15 @@ export function addSampleVoice(
|
|
|
317
320
|
score: TrackScore,
|
|
318
321
|
trackId: string,
|
|
319
322
|
voice: string,
|
|
320
|
-
ref:
|
|
323
|
+
ref: PinnedSampleRef,
|
|
321
324
|
): AddVoiceResult {
|
|
325
|
+
try {
|
|
326
|
+
requirePinnedRef(ref, `sample ${voice}`);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (error instanceof ScoreValidationError)
|
|
329
|
+
return { ok: false, message: `sample · ${error.message}` };
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
322
332
|
if (!VOICE.test(voice))
|
|
323
333
|
return {
|
|
324
334
|
ok: false,
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sing` prompt command (0.7): the built-in singing voice on the
|
|
3
|
+
* focused track (core/sing.ts). The menu and the `set_sing` / `set_vowels`
|
|
4
|
+
* agent tools build the same edits.
|
|
5
|
+
*
|
|
6
|
+
* sing show the track's preset and overrides
|
|
7
|
+
* sing <preset> aah ooh choir oohchoir chorale airy
|
|
8
|
+
* glass lament soprano basso drone
|
|
9
|
+
* khoomei sygyt kargyraa
|
|
10
|
+
* sing <preset> <param> <value>… a preset with overrides
|
|
11
|
+
* sing <param> <value> [<param> <value>…] vowel o, voices 6, voice bass
|
|
12
|
+
* sing drone <D3|50> throat mode on the current preset
|
|
13
|
+
* sing harmonics 6-12 the overtone range a throat track picks
|
|
14
|
+
* sing <param> off back to the preset's value
|
|
15
|
+
* sing vowels a e i o>u … the track's notes' vowels, in order, cycled
|
|
16
|
+
* sing reset | sing off | sing list
|
|
17
|
+
* note vowel <a|a>u|off> [target] the sung vowel of some notes
|
|
18
|
+
*
|
|
19
|
+
* Any edit makes the track a sing track (`instrument "sing"` plus a `sing`
|
|
20
|
+
* field). One `updateTrack` revision and one undo step per command.
|
|
21
|
+
*/
|
|
22
|
+
import { newId } from "../../core/ids.ts";
|
|
23
|
+
import { isGuideInstrument } from "../../core/clips.ts";
|
|
24
|
+
import { parseKey } from "../../core/chords.ts";
|
|
25
|
+
import { FxValidationError } from "../../core/params.ts";
|
|
26
|
+
import {
|
|
27
|
+
DEFAULT_SING_PRESET,
|
|
28
|
+
isSingPreset,
|
|
29
|
+
normalizeSing,
|
|
30
|
+
normalizeVowel,
|
|
31
|
+
parseDrone,
|
|
32
|
+
resolveSing,
|
|
33
|
+
SING_INSTRUMENT,
|
|
34
|
+
SING_PARAMS,
|
|
35
|
+
SING_PRESETS,
|
|
36
|
+
SING_PRESET_NAMES,
|
|
37
|
+
singNoteName,
|
|
38
|
+
singParamName,
|
|
39
|
+
singSummary,
|
|
40
|
+
type SingPreset,
|
|
41
|
+
type TrackSing,
|
|
42
|
+
} from "../../core/sing.ts";
|
|
43
|
+
import {
|
|
44
|
+
addNote,
|
|
45
|
+
ScoreValidationError,
|
|
46
|
+
updateTrack,
|
|
47
|
+
type TrackScore,
|
|
48
|
+
} from "../../core/score.ts";
|
|
49
|
+
import { nearest } from "./nearest.ts";
|
|
50
|
+
import {
|
|
51
|
+
describeTarget,
|
|
52
|
+
parseNoteTarget,
|
|
53
|
+
patchNotes,
|
|
54
|
+
targetNotes,
|
|
55
|
+
type NoteTarget,
|
|
56
|
+
} from "./expression.ts";
|
|
57
|
+
import { parseParamValue } from "./fx.ts";
|
|
58
|
+
|
|
59
|
+
export type SingValue = number | string | readonly [number, number] | null;
|
|
60
|
+
|
|
61
|
+
export type SingCommand =
|
|
62
|
+
| { type: "sing-show" }
|
|
63
|
+
| { type: "sing-list" }
|
|
64
|
+
| { type: "sing-reset" }
|
|
65
|
+
| { type: "sing-off" }
|
|
66
|
+
| { type: "sing-usage"; message: string }
|
|
67
|
+
| { type: "sing-vowels"; vowels: readonly string[] }
|
|
68
|
+
| { type: "note-vowel"; vowel: string | null; target: NoteTarget }
|
|
69
|
+
| {
|
|
70
|
+
type: "sing-set";
|
|
71
|
+
/** A preset applied before the values (`sing khoomei drone D3`). */
|
|
72
|
+
preset?: SingPreset;
|
|
73
|
+
/** `null` returns a parameter to the preset's value. */
|
|
74
|
+
values: Readonly<Record<string, SingValue>>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const SING_USAGE =
|
|
78
|
+
"sing <preset> | sing <param> <value> | sing drone <D3> | sing vowels a e i … | sing reset | sing off | sing list";
|
|
79
|
+
|
|
80
|
+
const PARAM_NAMES = [...Object.keys(SING_PARAMS), "harmonics"];
|
|
81
|
+
|
|
82
|
+
function rangeOf(name: string): string {
|
|
83
|
+
if (name === "vowel") return "vowel is a e i o u or a morph like a>o";
|
|
84
|
+
if (name === "drone")
|
|
85
|
+
return "drone is a note name C2..G4 (D3) or MIDI 36..67";
|
|
86
|
+
if (name === "harmonics") return "harmonics is a range like 6-12 (2..24)";
|
|
87
|
+
const spec = SING_PARAMS[name]!;
|
|
88
|
+
if (spec.kind === "number") return `${name} is ${spec.min}..${spec.max}`;
|
|
89
|
+
if (spec.kind === "enum") return `${name} is one of ${spec.values.join(" ")}`;
|
|
90
|
+
return `${name} is on or off`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function presetFor(word: string): SingPreset | undefined {
|
|
94
|
+
const lower = word.toLowerCase();
|
|
95
|
+
return isSingPreset(lower) ? lower : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** One value word for `name`; undefined when it is out of range. */
|
|
99
|
+
function valueOf(name: string, word: string): SingValue | undefined {
|
|
100
|
+
try {
|
|
101
|
+
if (name === "vowel") return normalizeVowel(word);
|
|
102
|
+
if (name === "drone") return parseDrone(word);
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
if (name === "harmonics") {
|
|
107
|
+
const match = /^(\d+)[-,:](\d+)$/.exec(word);
|
|
108
|
+
if (!match) return undefined;
|
|
109
|
+
const lo = Number(match[1]);
|
|
110
|
+
const hi = Number(match[2]);
|
|
111
|
+
return lo >= 2 && hi <= 24 && lo < hi
|
|
112
|
+
? (Object.freeze([lo, hi]) as readonly [number, number])
|
|
113
|
+
: undefined;
|
|
114
|
+
}
|
|
115
|
+
const spec = SING_PARAMS[name];
|
|
116
|
+
if (!spec) return undefined;
|
|
117
|
+
const value = parseParamValue(spec, word);
|
|
118
|
+
return typeof value === "boolean" ? undefined : value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseNoteVowel(words: readonly string[]): SingCommand | undefined {
|
|
122
|
+
const [first, ...rest] = words;
|
|
123
|
+
if (!first)
|
|
124
|
+
return { type: "sing-usage", message: "note vowel <a|a>u|off> [target]" };
|
|
125
|
+
let vowel: string | null;
|
|
126
|
+
if (first === "off" || first === "unset") vowel = null;
|
|
127
|
+
else {
|
|
128
|
+
try {
|
|
129
|
+
vowel = normalizeVowel(first);
|
|
130
|
+
} catch {
|
|
131
|
+
return {
|
|
132
|
+
type: "sing-usage",
|
|
133
|
+
message: "note vowel is a e i o u or a morph like a>u",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const target = parseNoteTarget(rest);
|
|
138
|
+
if (!target)
|
|
139
|
+
return {
|
|
140
|
+
type: "sing-usage",
|
|
141
|
+
message: "note vowel <v> [all|last|bar <n>|bars <a>-<b>|<ids>]",
|
|
142
|
+
};
|
|
143
|
+
return { type: "note-vowel", vowel, target };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function parseSingCommand(prompt: string): SingCommand | undefined {
|
|
147
|
+
const words = prompt.trim().replace(/^\//, "").toLowerCase().split(/\s+/);
|
|
148
|
+
if (words[0] === "note" && words[1] === "vowel") {
|
|
149
|
+
if (prompt.length > 1_024) return undefined;
|
|
150
|
+
return parseNoteVowel(words.slice(2));
|
|
151
|
+
}
|
|
152
|
+
if (words[0] === "presets" && words[1] === "sing" && words.length === 2)
|
|
153
|
+
return { type: "sing-list" };
|
|
154
|
+
if (words[0] !== "sing") return undefined;
|
|
155
|
+
if (words.length === 1) return { type: "sing-show" };
|
|
156
|
+
if (prompt.length > 1_024) return undefined;
|
|
157
|
+
const rest = words.slice(1);
|
|
158
|
+
if (rest.length === 1 && rest[0] === "reset") return { type: "sing-reset" };
|
|
159
|
+
if (rest.length === 1 && rest[0] === "off") return { type: "sing-off" };
|
|
160
|
+
if (rest.length === 1 && (rest[0] === "list" || rest[0] === "presets"))
|
|
161
|
+
return { type: "sing-list" };
|
|
162
|
+
if (rest[0] === "vowels") {
|
|
163
|
+
const vowels: string[] = [];
|
|
164
|
+
for (const word of rest.slice(1)) {
|
|
165
|
+
try {
|
|
166
|
+
vowels.push(normalizeVowel(word));
|
|
167
|
+
} catch {
|
|
168
|
+
return {
|
|
169
|
+
type: "sing-usage",
|
|
170
|
+
message: `sing vowels · ${word.slice(0, 16)} is not a e i o u or a morph like a>o`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return vowels.length > 0
|
|
175
|
+
? { type: "sing-vowels", vowels }
|
|
176
|
+
: { type: "sing-usage", message: "sing vowels a e i o u (cycled)" };
|
|
177
|
+
}
|
|
178
|
+
if (rest[0] === "preset") {
|
|
179
|
+
const preset = rest.length === 2 ? presetFor(rest[1]!) : undefined;
|
|
180
|
+
return preset
|
|
181
|
+
? { type: "sing-set", preset, values: {} }
|
|
182
|
+
: {
|
|
183
|
+
type: "sing-usage",
|
|
184
|
+
message: `sing preset is one of ${SING_PRESET_NAMES.join(" ")}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (rest.length === 1) {
|
|
188
|
+
const preset = presetFor(rest[0]!);
|
|
189
|
+
if (preset) return { type: "sing-set", preset, values: {} };
|
|
190
|
+
const near = nearest(rest[0]!.slice(0, 24), SING_PRESET_NAMES);
|
|
191
|
+
return {
|
|
192
|
+
type: "sing-usage",
|
|
193
|
+
message: near ? `sing · did you mean ${near}?` : SING_USAGE,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// `harmonics 6 12` is the range `harmonics 6-12`.
|
|
197
|
+
for (let index = 0; index + 2 < rest.length; index += 1)
|
|
198
|
+
if (
|
|
199
|
+
rest[index] === "harmonics" &&
|
|
200
|
+
/^\d+$/.test(rest[index + 1]!) &&
|
|
201
|
+
/^\d+$/.test(rest[index + 2]!)
|
|
202
|
+
)
|
|
203
|
+
rest.splice(index + 1, 2, `${rest[index + 1]}-${rest[index + 2]}`);
|
|
204
|
+
// `sing <preset> <param> <value>…`: the preset, then its overrides.
|
|
205
|
+
const lead = rest.length % 2 === 1 ? presetFor(rest[0]!) : undefined;
|
|
206
|
+
if (lead) rest.shift();
|
|
207
|
+
if (rest.length % 2 !== 0) return { type: "sing-usage", message: SING_USAGE };
|
|
208
|
+
const values: Record<string, SingValue> = {};
|
|
209
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
210
|
+
const name = singParamName(rest[index]!);
|
|
211
|
+
if (!name) {
|
|
212
|
+
const word = rest[index]!.slice(0, 24);
|
|
213
|
+
const near = nearest(word, PARAM_NAMES);
|
|
214
|
+
return {
|
|
215
|
+
type: "sing-usage",
|
|
216
|
+
message: `sing has no parameter ${word}${near ? ` · did you mean ${near}?` : ""} · vowel voices bright breath vib drone …`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const word = rest[index + 1]!;
|
|
220
|
+
if (word === "off" || word === "unset") {
|
|
221
|
+
values[name] = null;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const value = valueOf(name, word);
|
|
225
|
+
if (value === undefined)
|
|
226
|
+
return { type: "sing-usage", message: rangeOf(name) };
|
|
227
|
+
values[name] = value;
|
|
228
|
+
}
|
|
229
|
+
return lead
|
|
230
|
+
? { type: "sing-set", preset: lead, values }
|
|
231
|
+
: { type: "sing-set", values };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** `preset khoomei · drone D3 · voices 4`, overrides in canonical order. */
|
|
235
|
+
export function describeSing(sing: TrackSing | undefined): string {
|
|
236
|
+
if (!sing) return "not a sing track";
|
|
237
|
+
const preset = sing.preset ?? DEFAULT_SING_PRESET;
|
|
238
|
+
const overrides = Object.entries(sing)
|
|
239
|
+
.filter(([key]) => key !== "preset")
|
|
240
|
+
.map(([key, value]) =>
|
|
241
|
+
key === "drone"
|
|
242
|
+
? `drone ${singNoteName(value as number)}`
|
|
243
|
+
: Array.isArray(value)
|
|
244
|
+
? `${key} ${value.join("-")}`
|
|
245
|
+
: `${key} ${value}`,
|
|
246
|
+
);
|
|
247
|
+
return [`preset ${preset}`, ...overrides].join(" · ");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** One line per preset, for `sing list` and the agent brief. */
|
|
251
|
+
export function singListLines(): string[] {
|
|
252
|
+
return SING_PRESET_NAMES.map(
|
|
253
|
+
(name) => `${name.padEnd(9)} ${SING_PRESETS[name].doc}`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export type SingResult = Readonly<{
|
|
258
|
+
ok: boolean;
|
|
259
|
+
message: string;
|
|
260
|
+
next?: TrackScore;
|
|
261
|
+
kind?: string;
|
|
262
|
+
payload?: Record<string, unknown>;
|
|
263
|
+
}>;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The sing field a command leaves on a track (pure; the agent tool shares
|
|
267
|
+
* it). A preset switch keeps the overrides; `reset` keeps the preset.
|
|
268
|
+
*/
|
|
269
|
+
export function nextSing(
|
|
270
|
+
current: TrackSing | undefined,
|
|
271
|
+
command: Extract<SingCommand, { type: "sing-reset" | "sing-set" }>,
|
|
272
|
+
): TrackSing {
|
|
273
|
+
if (command.type === "sing-reset")
|
|
274
|
+
return normalizeSing(current?.preset ? { preset: current.preset } : {})!;
|
|
275
|
+
const base: Record<string, unknown> = { ...current };
|
|
276
|
+
if (command.preset) base.preset = command.preset;
|
|
277
|
+
for (const [key, value] of Object.entries(command.values)) {
|
|
278
|
+
if (value === null) delete base[key];
|
|
279
|
+
else base[key] = value;
|
|
280
|
+
}
|
|
281
|
+
return normalizeSing(base)!;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Set the vowels of a track's notes in time order, cycling `vowels`
|
|
286
|
+
* (`sing vowels a e i o u`). Pure; the `set_vowels` tool shares it.
|
|
287
|
+
* `noteIds` limits it to those notes (still in time order).
|
|
288
|
+
*/
|
|
289
|
+
export function assignVowels(
|
|
290
|
+
score: TrackScore,
|
|
291
|
+
trackId: string,
|
|
292
|
+
vowels: readonly string[],
|
|
293
|
+
noteIds?: readonly string[],
|
|
294
|
+
): { next: TrackScore; count: number } {
|
|
295
|
+
const ids = noteIds ? new Set(noteIds) : undefined;
|
|
296
|
+
const notes = score.notes
|
|
297
|
+
.filter((note) => (ids ? ids.has(note.id) : note.trackId === trackId))
|
|
298
|
+
.sort((a, b) => a.startTick - b.startTick || a.pitch - b.pitch);
|
|
299
|
+
if (notes.length === 0 || vowels.length === 0)
|
|
300
|
+
return { next: score, count: 0 };
|
|
301
|
+
const canonical = vowels.map((vowel) => normalizeVowel(vowel));
|
|
302
|
+
let next = score;
|
|
303
|
+
// Group notes by vowel so one patchNotes call per vowel builds the score.
|
|
304
|
+
const byVowel = new Map<string, Set<string>>();
|
|
305
|
+
notes.forEach((note, index) => {
|
|
306
|
+
const vowel = canonical[index % canonical.length]!;
|
|
307
|
+
const set = byVowel.get(vowel) ?? new Set<string>();
|
|
308
|
+
set.add(note.id);
|
|
309
|
+
byVowel.set(vowel, set);
|
|
310
|
+
});
|
|
311
|
+
for (const [vowel, set] of byVowel) next = patchNotes(next, set, { vowel });
|
|
312
|
+
return { next, count: notes.length };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The throat demo line in semitones over the drone (formant.md 4.4). */
|
|
316
|
+
export const THROAT_DEMO_STEPS = Object.freeze([
|
|
317
|
+
12, 14, 16, 19, 21, 19, 16, 14,
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Write the throat demo melody on an empty throat track: one note per
|
|
322
|
+
* two beats, one octave above the drone that plays, inside the song.
|
|
323
|
+
* Undefined when the voice has no drone. Pure.
|
|
324
|
+
*/
|
|
325
|
+
export function throatDemo(
|
|
326
|
+
score: TrackScore,
|
|
327
|
+
trackId: string,
|
|
328
|
+
sing: TrackSing,
|
|
329
|
+
): { next: TrackScore; count: number } | undefined {
|
|
330
|
+
const drone = resolveSing(sing, keyRootOf(score)).drone;
|
|
331
|
+
if (drone === undefined) return undefined;
|
|
332
|
+
const step = score.ticksPerBeat * 2;
|
|
333
|
+
const total = score.ticksPerBeat * score.beatsPerBar * score.bars;
|
|
334
|
+
let next = score;
|
|
335
|
+
let count = 0;
|
|
336
|
+
for (const [index, offset] of THROAT_DEMO_STEPS.entries()) {
|
|
337
|
+
const startTick = index * step;
|
|
338
|
+
if (startTick + step > total) break;
|
|
339
|
+
next = addNote(next, {
|
|
340
|
+
id: newId(`${trackId}-demo`),
|
|
341
|
+
trackId,
|
|
342
|
+
startTick,
|
|
343
|
+
durationTicks: step,
|
|
344
|
+
pitch: Math.min(127, drone + offset),
|
|
345
|
+
velocity: 0.8,
|
|
346
|
+
});
|
|
347
|
+
count += 1;
|
|
348
|
+
}
|
|
349
|
+
return count > 0 ? { next, count } : undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const keyRootOf = (score: TrackScore): number | undefined =>
|
|
353
|
+
parseKey(score.key)?.tonic;
|
|
354
|
+
|
|
355
|
+
/** The instrument a track plays after `sing off` (the default track voice). */
|
|
356
|
+
export const SING_OFF_INSTRUMENT = "sine";
|
|
357
|
+
|
|
358
|
+
export function applySingCommand(
|
|
359
|
+
score: TrackScore,
|
|
360
|
+
trackId: string,
|
|
361
|
+
command: SingCommand,
|
|
362
|
+
): SingResult {
|
|
363
|
+
if (command.type === "sing-usage")
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
message: command.message.startsWith("note")
|
|
367
|
+
? command.message
|
|
368
|
+
: `sing · ${command.message}`,
|
|
369
|
+
};
|
|
370
|
+
if (command.type === "sing-list")
|
|
371
|
+
return {
|
|
372
|
+
ok: true,
|
|
373
|
+
message: `sing presets · ${SING_PRESET_NAMES.join(" ")}`,
|
|
374
|
+
};
|
|
375
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
376
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
377
|
+
if (command.type === "note-vowel") {
|
|
378
|
+
const notes = targetNotes(score, trackId, command.target);
|
|
379
|
+
if (notes.length === 0)
|
|
380
|
+
return {
|
|
381
|
+
ok: false,
|
|
382
|
+
message: `note vowel · no notes in ${describeTarget(command.target)}`,
|
|
383
|
+
};
|
|
384
|
+
const next = patchNotes(score, new Set(notes.map((note) => note.id)), {
|
|
385
|
+
vowel: command.vowel,
|
|
386
|
+
});
|
|
387
|
+
return {
|
|
388
|
+
ok: true,
|
|
389
|
+
message: `note vowel · ${command.vowel ?? "off"} · ${notes.length} note${notes.length === 1 ? "" : "s"}`,
|
|
390
|
+
next,
|
|
391
|
+
kind: "score.notes.vowel",
|
|
392
|
+
payload: {
|
|
393
|
+
trackId,
|
|
394
|
+
vowel: command.vowel,
|
|
395
|
+
noteIds: notes.map((note) => note.id),
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
if (command.type === "sing-vowels") {
|
|
400
|
+
const { next, count } = assignVowels(score, trackId, command.vowels);
|
|
401
|
+
if (count === 0)
|
|
402
|
+
return { ok: false, message: `sing vowels · ${trackId} has no notes` };
|
|
403
|
+
return {
|
|
404
|
+
ok: true,
|
|
405
|
+
message: `sing vowels · ${command.vowels.join(" ")} over ${count} note${count === 1 ? "" : "s"}`,
|
|
406
|
+
next,
|
|
407
|
+
kind: "score.notes.vowel",
|
|
408
|
+
payload: { trackId, vowels: command.vowels },
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const current = track.instrument === SING_INSTRUMENT ? track.sing : undefined;
|
|
412
|
+
if (command.type === "sing-show")
|
|
413
|
+
return {
|
|
414
|
+
ok: true,
|
|
415
|
+
message: current
|
|
416
|
+
? `sing · ${singSummary(current, keyRootOf(score))} · ${describeSing(current)}`
|
|
417
|
+
: track.instrument === SING_INSTRUMENT
|
|
418
|
+
? `sing · off · sing <preset> turns it on (${SING_PRESET_NAMES.join(" ")})`
|
|
419
|
+
: isGuideInstrument(track.instrument)
|
|
420
|
+
? `${trackId} sings with the vocal guide · try sing choir`
|
|
421
|
+
: `sing · ${trackId} plays ${track.instrument} · sing <preset> to switch (${SING_PRESET_NAMES.join(" ")})`,
|
|
422
|
+
};
|
|
423
|
+
if (track.sampler || track.instrument === "kit")
|
|
424
|
+
return {
|
|
425
|
+
ok: false,
|
|
426
|
+
message: `sing · ${trackId} is a ${track.sampler ? "sampler" : "drum"} track`,
|
|
427
|
+
};
|
|
428
|
+
if (command.type === "sing-off") {
|
|
429
|
+
if (!current) return { ok: true, message: "sing · already off" };
|
|
430
|
+
// Off is a plain sine, never instrument "sing" without its field: the
|
|
431
|
+
// word `sing` reads back as the default voice, so a field-less sing
|
|
432
|
+
// track would sound again after a save and reload.
|
|
433
|
+
const next = updateTrack(score, trackId, {
|
|
434
|
+
instrument: SING_OFF_INSTRUMENT,
|
|
435
|
+
sing: null,
|
|
436
|
+
});
|
|
437
|
+
return {
|
|
438
|
+
ok: true,
|
|
439
|
+
message: `sing · off (${SING_OFF_INSTRUMENT}) · sing ${current.preset ?? DEFAULT_SING_PRESET} turns it back on`,
|
|
440
|
+
next,
|
|
441
|
+
kind: "score.sing",
|
|
442
|
+
payload: { trackId, instrument: SING_OFF_INSTRUMENT, sing: null },
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
let sing: TrackSing;
|
|
446
|
+
let next: TrackScore;
|
|
447
|
+
try {
|
|
448
|
+
sing = nextSing(current, command);
|
|
449
|
+
next = updateTrack(score, trackId, { instrument: SING_INSTRUMENT, sing });
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (
|
|
452
|
+
error instanceof ScoreValidationError ||
|
|
453
|
+
error instanceof FxValidationError
|
|
454
|
+
)
|
|
455
|
+
return { ok: false, message: `sing · ${error.message}` };
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
// A throat style needs a run of notes to steer the drone's overtones:
|
|
459
|
+
// on an empty track, write a short demo line so one step is audible.
|
|
460
|
+
const demo =
|
|
461
|
+
command.type === "sing-set" &&
|
|
462
|
+
command.preset !== undefined &&
|
|
463
|
+
!score.notes.some((note) => note.trackId === trackId)
|
|
464
|
+
? throatDemo(next, trackId, sing)
|
|
465
|
+
: undefined;
|
|
466
|
+
if (demo) next = demo.next;
|
|
467
|
+
return {
|
|
468
|
+
ok: true,
|
|
469
|
+
message: `sing · ${singSummary(sing, keyRootOf(next))}${demo ? ` · ${demo.count} demo notes (^z removes them; clear before writing your own)` : ""}`,
|
|
470
|
+
next,
|
|
471
|
+
kind: "score.sing",
|
|
472
|
+
payload: { trackId, instrument: SING_INSTRUMENT, sing },
|
|
473
|
+
};
|
|
474
|
+
}
|
package/src/commands/strum.ts
CHANGED
|
@@ -41,6 +41,7 @@ function parsePitch(text: string): number | undefined {
|
|
|
41
41
|
}
|
|
42
42
|
import {
|
|
43
43
|
normalizeGuitar,
|
|
44
|
+
SCORE_LIMITS,
|
|
44
45
|
ScoreValidationError,
|
|
45
46
|
updateTrack,
|
|
46
47
|
type Note,
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
} from "../../core/score.ts";
|
|
51
52
|
import { resolveTuning, snapToTuning } from "../../core/tuning.ts";
|
|
52
53
|
import { applyScoreOperations } from "../../core/diff.ts";
|
|
54
|
+
import { barTicks } from "../../core/sections.ts";
|
|
53
55
|
|
|
54
56
|
export type GuitarCommand =
|
|
55
57
|
| { type: "guitar-show" }
|
|
@@ -462,10 +464,20 @@ export function applyStrumCommand(
|
|
|
462
464
|
return { ok: false, message: `strum · ${error.message}` };
|
|
463
465
|
throw error;
|
|
464
466
|
}
|
|
467
|
+
// Chords that run past the song's end grow it, so they are heard.
|
|
468
|
+
const end = next.notes.reduce(
|
|
469
|
+
(max, note) => Math.max(max, note.startTick + note.durationTicks),
|
|
470
|
+
0,
|
|
471
|
+
);
|
|
472
|
+
const perBar = barTicks(next);
|
|
473
|
+
const needed = Math.min(SCORE_LIMITS.maxBars, Math.ceil(end / perBar));
|
|
474
|
+
const grew = needed > next.bars;
|
|
475
|
+
if (grew)
|
|
476
|
+
next = applyScoreOperations(next, [{ type: "setBars", bars: needed }]);
|
|
465
477
|
const strings = guitarStrings(track.guitar?.tune).length;
|
|
466
478
|
return {
|
|
467
479
|
ok: true,
|
|
468
|
-
message: `strum · ${plan.names.join(" ")} · ${command.options.strokes ?? "down"} · ${plan.noteCount} notes on ${strings} strings`,
|
|
480
|
+
message: `strum · ${plan.names.join(" ")} · ${command.options.strokes ?? "down"} · ${plan.noteCount} notes on ${strings} strings${grew ? ` · song now ${needed} bars` : ""}`,
|
|
469
481
|
next,
|
|
470
482
|
kind: "score.notes",
|
|
471
483
|
payload: { trackId, strum: plan.names },
|