@hraness/dawg 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -0
- package/DAWG.md +454 -140
- package/README.md +30 -26
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +184 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/ids.ts +52 -0
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +8 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/range.ts +581 -0
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +955 -14
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +287 -5
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +2132 -46
- package/core/sections.ts +480 -35
- package/core/sing.ts +815 -0
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +31 -0
- package/guides/audio.md +31 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -7
- package/guides/chords.md +15 -15
- package/guides/effects.md +17 -16
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +15 -7
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/panes.md +32 -0
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +26 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +18 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -13
- package/guides/styles.md +31 -0
- package/guides/tape.md +32 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +6 -2
- package/src/agent/agent.ts +130 -18
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +378 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +1 -1
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +27 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +25 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +4 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +110 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +13 -10
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +49 -5
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +157 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +10 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/effects/chain.ts +3 -1
- package/src/audio/effects/common.ts +43 -0
- package/src/audio/effects/convolution.ts +7 -4
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/engine.ts +360 -40
- package/src/audio/fit.ts +35 -3
- package/src/audio/instrument-check.ts +59 -43
- package/src/audio/instruments.ts +4 -0
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +8 -1
- package/src/audio/keys/engine.ts +13 -1
- package/src/audio/keys/piano.ts +22 -2
- package/src/audio/kits.ts +135 -6
- package/src/audio/live.ts +114 -20
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +30 -2
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +2 -1
- package/src/audio/sampler.ts +85 -4
- package/src/audio/samples.ts +20 -2
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/bow.ts +48 -5
- package/src/audio/strings/engine.ts +8 -2
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +500 -77
- package/src/audio/winds/engine.ts +5 -1
- package/src/audio/winds/trim.ts +28 -4
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +15 -2
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +82 -32
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +1 -1
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +101 -32
- package/src/commands/grammar.ts +560 -0
- package/src/commands/help.ts +774 -366
- package/src/commands/history.ts +139 -10
- package/src/commands/keys.ts +25 -8
- package/src/commands/modal.ts +1 -1
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +135 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +474 -0
- package/src/commands/strum.ts +13 -1
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +617 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +2 -2
- package/src/fs/durable.ts +50 -0
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +511 -0
- package/src/launch-args.ts +267 -0
- package/src/main.ts +2508 -308
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +21 -2
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +20 -1
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +199 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +174 -0
- package/src/session/port.ts +109 -14
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +365 -7
- package/src/session/rebase.ts +63 -12
- package/src/session/receipt.ts +265 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +126 -45
- package/src/tui/arrange-menu.ts +225 -23
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +113 -27
- package/src/tui/fader.ts +282 -42
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +987 -179
- package/src/tui/modal-menu.ts +6 -6
- package/src/tui/performance-menu.ts +5 -2
- package/src/tui/play-chords.ts +4 -2
- package/src/tui/play-mode.ts +15 -1
- package/src/tui/play-session.ts +243 -28
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +3 -3
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +491 -84
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +193 -0
- package/tui/drawer.ts +233 -27
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +143 -65
- package/tui/guide.ts +50 -5
- package/tui/highway.ts +309 -30
- package/tui/hints.ts +197 -0
- package/tui/hits.ts +7 -1
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +80 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +151 -18
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +46 -1
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Range commands (op1-ux §6.4): copy, move, clear, paste and reverse bars,
|
|
3
|
+
* bars insert and remove, loop next and prev, and jump. Bars are numbered
|
|
4
|
+
* from 1 here, as the ruler shows them; core/range.ts stores them from 0.
|
|
5
|
+
*
|
|
6
|
+
* copy [<track>|all] [<range>] [to <bar>] [x<N>] [insert|merge]
|
|
7
|
+
* move [<track>|all] [<range>] to <bar> [insert]
|
|
8
|
+
* clear [<track>|all] [<range>] (bare `clear` empties this track, as before)
|
|
9
|
+
* paste [at <bar>] [x<N>] [insert|merge]
|
|
10
|
+
* reverse [<track>|all] [<range>]
|
|
11
|
+
* bars insert <n> at <bar> | bars remove <range>
|
|
12
|
+
* loop next | loop prev
|
|
13
|
+
* jump <bar>[.<beat>] | <section>
|
|
14
|
+
*
|
|
15
|
+
* A `<range>` is `5-6`, `5` or a section name. Without one a command acts
|
|
16
|
+
* on the loop range, else the section under the playhead, else that bar
|
|
17
|
+
* (`rangeOf`), so the typed and keyed forms behave the same. `copy` without
|
|
18
|
+
* `to` fills the clipboard that `paste` lays down.
|
|
19
|
+
*/
|
|
20
|
+
import { ScoreValidationError, type TrackScore } from "../../core/score.ts";
|
|
21
|
+
import { resolveTrackRef } from "../../core/routing.ts";
|
|
22
|
+
import {
|
|
23
|
+
deleteBars,
|
|
24
|
+
findSection,
|
|
25
|
+
formSegments,
|
|
26
|
+
insertBars,
|
|
27
|
+
} from "../../core/sections.ts";
|
|
28
|
+
import { barStartTick } from "../../core/tempo.ts";
|
|
29
|
+
import {
|
|
30
|
+
barsWord,
|
|
31
|
+
clearRange,
|
|
32
|
+
clipNoteCount,
|
|
33
|
+
copyRange,
|
|
34
|
+
extractRange,
|
|
35
|
+
moveRange,
|
|
36
|
+
notesInRange,
|
|
37
|
+
placeRange,
|
|
38
|
+
rangeLabel,
|
|
39
|
+
rangeOf,
|
|
40
|
+
reverseRange,
|
|
41
|
+
steppedLoop,
|
|
42
|
+
type BarRange,
|
|
43
|
+
type PasteMode,
|
|
44
|
+
type RangeClip,
|
|
45
|
+
} from "../../core/range.ts";
|
|
46
|
+
|
|
47
|
+
/** Which tracks: one id, every track, or the focused one. */
|
|
48
|
+
export type RangeTracks =
|
|
49
|
+
| Readonly<{ kind: "track"; id: string }>
|
|
50
|
+
| Readonly<{ kind: "all" }>
|
|
51
|
+
| Readonly<{ kind: "focused" }>;
|
|
52
|
+
|
|
53
|
+
/** A typed range: bars (0-based), a section, or the gesture range. */
|
|
54
|
+
export type RangeRef =
|
|
55
|
+
| Readonly<{ kind: "bars"; range: BarRange }>
|
|
56
|
+
| Readonly<{ kind: "section"; name: string }>
|
|
57
|
+
| Readonly<{ kind: "implicit" }>;
|
|
58
|
+
|
|
59
|
+
export type RangeCommand =
|
|
60
|
+
| Readonly<{
|
|
61
|
+
type: "range-copy";
|
|
62
|
+
tracks: RangeTracks;
|
|
63
|
+
range: RangeRef;
|
|
64
|
+
/** 0-based; absent copies to the clipboard. */
|
|
65
|
+
to?: number;
|
|
66
|
+
times: number;
|
|
67
|
+
mode: PasteMode;
|
|
68
|
+
}>
|
|
69
|
+
| Readonly<{
|
|
70
|
+
type: "range-move";
|
|
71
|
+
tracks: RangeTracks;
|
|
72
|
+
range: RangeRef;
|
|
73
|
+
to: number;
|
|
74
|
+
insert: boolean;
|
|
75
|
+
}>
|
|
76
|
+
| Readonly<{ type: "range-clear"; tracks: RangeTracks; range: RangeRef }>
|
|
77
|
+
| Readonly<{ type: "range-reverse"; tracks: RangeTracks; range: RangeRef }>
|
|
78
|
+
| Readonly<{
|
|
79
|
+
type: "range-paste";
|
|
80
|
+
at?: number;
|
|
81
|
+
times: number;
|
|
82
|
+
mode: PasteMode;
|
|
83
|
+
}>
|
|
84
|
+
| Readonly<{ type: "bars-insert"; count: number; at: number }>
|
|
85
|
+
| Readonly<{ type: "bars-remove"; range: RangeRef }>
|
|
86
|
+
| Readonly<{ type: "loop-step"; direction: 1 | -1 }>
|
|
87
|
+
| Readonly<{ type: "jump-bar"; bar: number; beat: number }>
|
|
88
|
+
| Readonly<{ type: "jump-section"; name: string }>
|
|
89
|
+
/** A range verb whose words do not parse: answered locally. */
|
|
90
|
+
| Readonly<{ type: "range-usage"; verb: string; message: string }>;
|
|
91
|
+
|
|
92
|
+
export const RANGE_USAGE: Readonly<Record<string, string>> = {
|
|
93
|
+
copy: "copy [<track>|all] [<a>-<b>|<section>] [to <bar>] [x<N>] [insert|merge] · copy bass 5-6 to 7 x2",
|
|
94
|
+
move: "move [<track>|all] [<a>-<b>|<section>] to <bar> [insert] · move bass 5-6 to 9",
|
|
95
|
+
clear:
|
|
96
|
+
"clear [<track>|all] [<a>-<b>|<section>] · clear bass 5-6 · bare clear empties this track",
|
|
97
|
+
paste:
|
|
98
|
+
"paste [at <bar>] [x<N>] [insert|merge] · paste at 9 · copy fills the clipboard",
|
|
99
|
+
reverse: "reverse [<track>|all] [<a>-<b>|<section>] · reverse bass 5-6",
|
|
100
|
+
bars: "bars <count> | bars insert <n> at <bar> | bars remove <a>-<b> · bars insert 2 at 3",
|
|
101
|
+
jump: "jump <bar>[.<beat>] | <section> · jump 5 · jump 5.3 · jump chorus",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** The clipboard: what was copied and where from (per window, not saved). */
|
|
105
|
+
export type RangeClipboard = Readonly<{
|
|
106
|
+
clip: RangeClip;
|
|
107
|
+
/** The source, for receipts: a track id or `all`, and the range. */
|
|
108
|
+
source: string;
|
|
109
|
+
range: BarRange;
|
|
110
|
+
}>;
|
|
111
|
+
|
|
112
|
+
export type RangeContext = Readonly<{
|
|
113
|
+
/** The focused track. */
|
|
114
|
+
trackId: string;
|
|
115
|
+
/** The bar under the playhead, 0-based on the score. */
|
|
116
|
+
playheadBar: number;
|
|
117
|
+
clipboard?: RangeClipboard;
|
|
118
|
+
}>;
|
|
119
|
+
|
|
120
|
+
export type RangeResult = Readonly<{
|
|
121
|
+
ok: boolean;
|
|
122
|
+
message: string;
|
|
123
|
+
next?: TrackScore;
|
|
124
|
+
kind?: string;
|
|
125
|
+
payload?: Record<string, unknown>;
|
|
126
|
+
/** A new clipboard (copy without `to`). */
|
|
127
|
+
clipboard?: RangeClipboard;
|
|
128
|
+
/** Transport beat to seek to (`jump`). */
|
|
129
|
+
seekBeat?: number;
|
|
130
|
+
/** Run this command instead (`jump chorus` is `section jump chorus`). */
|
|
131
|
+
delegate?: string;
|
|
132
|
+
}>;
|
|
133
|
+
|
|
134
|
+
const VERBS = new Set([
|
|
135
|
+
"copy",
|
|
136
|
+
"move",
|
|
137
|
+
"clear",
|
|
138
|
+
"paste",
|
|
139
|
+
"reverse",
|
|
140
|
+
"bars",
|
|
141
|
+
"jump",
|
|
142
|
+
"loop",
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
const BARS = /^(\d{1,4})(?:(?:-|\.\.|–)(\d{1,4}))?$/u;
|
|
146
|
+
|
|
147
|
+
function stripSlash(line: string): string {
|
|
148
|
+
return line.trim().replace(/^\//u, "");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `5-6` or `5` → a 0-based range, else undefined. */
|
|
152
|
+
function parseBarsWord(word: string): BarRange | undefined {
|
|
153
|
+
const match = BARS.exec(word);
|
|
154
|
+
if (!match) return undefined;
|
|
155
|
+
const from = Number(match[1]);
|
|
156
|
+
const to = match[2] === undefined ? from : Number(match[2]);
|
|
157
|
+
if (from < 1 || to < from) return undefined;
|
|
158
|
+
return { startBar: from - 1, bars: to - from + 1 };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A section named by the leading words (longest match), with the rest. */
|
|
162
|
+
function leadingSection(
|
|
163
|
+
score: TrackScore,
|
|
164
|
+
words: readonly string[],
|
|
165
|
+
): { name: string; rest: readonly string[] } | undefined {
|
|
166
|
+
for (let count = Math.min(words.length, 4); count >= 1; count -= 1) {
|
|
167
|
+
const section = findSection(score, words.slice(0, count).join(" "));
|
|
168
|
+
if (section) return { name: section.name, rest: words.slice(count) };
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function leadingTracks(
|
|
174
|
+
score: TrackScore,
|
|
175
|
+
words: readonly string[],
|
|
176
|
+
): { tracks: RangeTracks; rest: readonly string[]; shadowed?: string } {
|
|
177
|
+
const first = words[0];
|
|
178
|
+
if (first === undefined) return { tracks: { kind: "focused" }, rest: words };
|
|
179
|
+
if (first.toLowerCase() === "all")
|
|
180
|
+
return { tracks: { kind: "all" }, rest: words.slice(1) };
|
|
181
|
+
if (BARS.test(first)) return { tracks: { kind: "focused" }, rest: words };
|
|
182
|
+
const track = resolveTrackRef(score, first);
|
|
183
|
+
if (track) {
|
|
184
|
+
return {
|
|
185
|
+
tracks: { kind: "track", id: track.id },
|
|
186
|
+
rest: words.slice(1),
|
|
187
|
+
// A name that is both resolves to the track; the receipt says so.
|
|
188
|
+
...(findSection(score, first) ? { shadowed: first } : {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return { tracks: { kind: "focused" }, rest: words };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The range words up to the first keyword, then the rest. */
|
|
195
|
+
function leadingRange(
|
|
196
|
+
score: TrackScore,
|
|
197
|
+
words: readonly string[],
|
|
198
|
+
): { range: RangeRef; rest: readonly string[] } | undefined {
|
|
199
|
+
const first = words[0];
|
|
200
|
+
if (first === undefined || isKeyword(first))
|
|
201
|
+
return { range: { kind: "implicit" }, rest: words };
|
|
202
|
+
const bars = parseBarsWord(first);
|
|
203
|
+
if (bars)
|
|
204
|
+
return { range: { kind: "bars", range: bars }, rest: words.slice(1) };
|
|
205
|
+
const section = leadingSection(score, words);
|
|
206
|
+
if (section)
|
|
207
|
+
return {
|
|
208
|
+
range: { kind: "section", name: section.name },
|
|
209
|
+
rest: section.rest,
|
|
210
|
+
};
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isKeyword(word: string): boolean {
|
|
215
|
+
return /^(to|at|insert|merge|x\d{1,2}|×\d{1,2})$/iu.test(word);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** `to 7` / `to chorus` / `at 7`: a 0-based bar. */
|
|
219
|
+
function barTarget(
|
|
220
|
+
score: TrackScore,
|
|
221
|
+
words: readonly string[],
|
|
222
|
+
): number | undefined {
|
|
223
|
+
if (words.length === 0) return undefined;
|
|
224
|
+
if (words.length === 1 && /^\d{1,4}$/u.test(words[0]!)) {
|
|
225
|
+
const bar = Number(words[0]);
|
|
226
|
+
return bar >= 1 ? bar - 1 : undefined;
|
|
227
|
+
}
|
|
228
|
+
const section = findSection(score, words.join(" "));
|
|
229
|
+
return section?.startBar;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
type Tail = { to?: number; times: number; mode: PasteMode; bad: boolean };
|
|
233
|
+
|
|
234
|
+
/** `to <bar>`, `x<N>`, `insert`, `merge` in any order. */
|
|
235
|
+
function parseTail(
|
|
236
|
+
score: TrackScore,
|
|
237
|
+
words: readonly string[],
|
|
238
|
+
word = "to",
|
|
239
|
+
): Tail {
|
|
240
|
+
const tail: Tail = { times: 1, mode: "overwrite", bad: false };
|
|
241
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
242
|
+
const lower = words[index]!.toLowerCase();
|
|
243
|
+
if (lower === word) {
|
|
244
|
+
// The target runs to the next keyword.
|
|
245
|
+
let end = index + 1;
|
|
246
|
+
while (end < words.length && !isKeyword(words[end]!)) end += 1;
|
|
247
|
+
const to = barTarget(score, words.slice(index + 1, end));
|
|
248
|
+
if (to === undefined || tail.to !== undefined) tail.bad = true;
|
|
249
|
+
else tail.to = to;
|
|
250
|
+
index = end - 1;
|
|
251
|
+
} else if (/^[x×]\d{1,2}$/u.test(lower)) {
|
|
252
|
+
tail.times = Number(lower.slice(1));
|
|
253
|
+
if (tail.times < 1) tail.bad = true;
|
|
254
|
+
} else if (lower === "insert") tail.mode = "insert";
|
|
255
|
+
else if (lower === "merge") tail.mode = "merge";
|
|
256
|
+
else tail.bad = true;
|
|
257
|
+
}
|
|
258
|
+
return tail;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function usage(verb: string): RangeCommand {
|
|
262
|
+
return {
|
|
263
|
+
type: "range-usage",
|
|
264
|
+
verb,
|
|
265
|
+
message: `${verb} · ${RANGE_USAGE[verb]}`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Parse one range command, or undefined when the line is another
|
|
271
|
+
* command's (bare `clear`, `clear hat`, `move n1 to 2`, `bars 8`,
|
|
272
|
+
* `loop 5-6`) or prose for the agent.
|
|
273
|
+
*/
|
|
274
|
+
export function parseRangeCommand(
|
|
275
|
+
line: string,
|
|
276
|
+
score: TrackScore,
|
|
277
|
+
): RangeCommand | undefined {
|
|
278
|
+
const text = stripSlash(line);
|
|
279
|
+
if (text.length > 512) return undefined;
|
|
280
|
+
const words = text.split(/\s+/u).filter(Boolean);
|
|
281
|
+
const verb = words[0]?.toLowerCase();
|
|
282
|
+
if (!verb || !VERBS.has(verb)) return undefined;
|
|
283
|
+
const args = words.slice(1);
|
|
284
|
+
switch (verb) {
|
|
285
|
+
case "copy":
|
|
286
|
+
case "move":
|
|
287
|
+
case "clear":
|
|
288
|
+
case "reverse":
|
|
289
|
+
return parseTrackRange(score, verb, args);
|
|
290
|
+
case "paste": {
|
|
291
|
+
const tail = parseTail(score, args, "at");
|
|
292
|
+
if (tail.bad || (tail.mode === "insert" && tail.times > 64))
|
|
293
|
+
return usage("paste");
|
|
294
|
+
return {
|
|
295
|
+
type: "range-paste",
|
|
296
|
+
...(tail.to !== undefined ? { at: tail.to } : {}),
|
|
297
|
+
times: tail.times,
|
|
298
|
+
mode: tail.mode,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
case "bars":
|
|
302
|
+
return parseBars(score, args);
|
|
303
|
+
case "loop": {
|
|
304
|
+
const word = args.length === 1 ? args[0]!.toLowerCase() : "";
|
|
305
|
+
if (word === "next") return { type: "loop-step", direction: 1 };
|
|
306
|
+
if (word === "prev" || word === "previous")
|
|
307
|
+
return { type: "loop-step", direction: -1 };
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
case "jump":
|
|
311
|
+
return parseJump(score, args);
|
|
312
|
+
default:
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function parseTrackRange(
|
|
318
|
+
score: TrackScore,
|
|
319
|
+
verb: "copy" | "move" | "clear" | "reverse",
|
|
320
|
+
args: readonly string[],
|
|
321
|
+
): RangeCommand | undefined {
|
|
322
|
+
// Bare `clear` and `clear automation` belong to the note commands;
|
|
323
|
+
// `clear hat` is the drum-voice clear unless a track has that name.
|
|
324
|
+
if (verb === "clear" && args.length === 0) return undefined;
|
|
325
|
+
if (args.length === 0 && verb !== "reverse") return usage(verb);
|
|
326
|
+
const { tracks, rest } = leadingTracks(score, args);
|
|
327
|
+
if (
|
|
328
|
+
tracks.kind === "focused" &&
|
|
329
|
+
args.length > 0 &&
|
|
330
|
+
!BARS.test(args[0]!) &&
|
|
331
|
+
!isKeyword(args[0]!) &&
|
|
332
|
+
!leadingSection(score, args)
|
|
333
|
+
) {
|
|
334
|
+
// Not a track, a range or a section: another command's words
|
|
335
|
+
// (`move n1 to 2`, `clear hat`, `clear volume automation`) or prose.
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
const ranged = leadingRange(score, rest);
|
|
339
|
+
if (!ranged) return usage(verb);
|
|
340
|
+
if (verb === "clear" || verb === "reverse") {
|
|
341
|
+
if (ranged.rest.length > 0) return usage(verb);
|
|
342
|
+
return {
|
|
343
|
+
type: verb === "clear" ? "range-clear" : "range-reverse",
|
|
344
|
+
tracks,
|
|
345
|
+
range: ranged.range,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const tail = parseTail(score, ranged.rest);
|
|
349
|
+
if (tail.bad) return usage(verb);
|
|
350
|
+
if (verb === "move") {
|
|
351
|
+
if (tail.to === undefined || tail.times !== 1 || tail.mode === "merge")
|
|
352
|
+
return usage("move");
|
|
353
|
+
return {
|
|
354
|
+
type: "range-move",
|
|
355
|
+
tracks,
|
|
356
|
+
range: ranged.range,
|
|
357
|
+
to: tail.to,
|
|
358
|
+
insert: tail.mode === "insert",
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
if (tail.to === undefined && (tail.times !== 1 || tail.mode !== "overwrite"))
|
|
362
|
+
return usage("copy");
|
|
363
|
+
return {
|
|
364
|
+
type: "range-copy",
|
|
365
|
+
tracks,
|
|
366
|
+
range: ranged.range,
|
|
367
|
+
...(tail.to !== undefined ? { to: tail.to } : {}),
|
|
368
|
+
times: tail.times,
|
|
369
|
+
mode: tail.mode,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function parseBars(
|
|
374
|
+
score: TrackScore,
|
|
375
|
+
args: readonly string[],
|
|
376
|
+
): RangeCommand | undefined {
|
|
377
|
+
const sub = args[0]?.toLowerCase();
|
|
378
|
+
if (sub === "insert" || sub === "add") {
|
|
379
|
+
// bars insert 2 at 3 · bars insert 2 (at the playhead's range start is
|
|
380
|
+
// too implicit to be safe, so `at` is required)
|
|
381
|
+
const count = Number(args[1]);
|
|
382
|
+
const at =
|
|
383
|
+
args[2]?.toLowerCase() === "at"
|
|
384
|
+
? barTarget(score, args.slice(3))
|
|
385
|
+
: undefined;
|
|
386
|
+
if (
|
|
387
|
+
!Number.isInteger(count) ||
|
|
388
|
+
count < 1 ||
|
|
389
|
+
count > 256 ||
|
|
390
|
+
at === undefined
|
|
391
|
+
)
|
|
392
|
+
return usage("bars");
|
|
393
|
+
return { type: "bars-insert", count, at };
|
|
394
|
+
}
|
|
395
|
+
if (sub === "remove" || sub === "delete" || sub === "rm") {
|
|
396
|
+
const ranged = leadingRange(score, args.slice(1));
|
|
397
|
+
if (!ranged || ranged.rest.length > 0) return usage("bars");
|
|
398
|
+
return { type: "bars-remove", range: ranged.range };
|
|
399
|
+
}
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function parseJump(
|
|
404
|
+
score: TrackScore,
|
|
405
|
+
args: readonly string[],
|
|
406
|
+
): RangeCommand | undefined {
|
|
407
|
+
const words = [...(args[0]?.toLowerCase() === "to" ? args.slice(1) : args)];
|
|
408
|
+
if (words.length === 0) return usage("jump");
|
|
409
|
+
if (words.length === 2 && words[0]!.toLowerCase() === "bar") words.shift();
|
|
410
|
+
const position = /^(\d{1,4})(?:\.(\d{1,2}(?:\.\d+)?))?$/u.exec(
|
|
411
|
+
words.join(" "),
|
|
412
|
+
);
|
|
413
|
+
if (position) {
|
|
414
|
+
const bar = Number(position[1]);
|
|
415
|
+
const beat = position[2] === undefined ? 1 : Number(position[2]);
|
|
416
|
+
if (bar < 1 || beat < 1) return usage("jump");
|
|
417
|
+
return { type: "jump-bar", bar: bar - 1, beat: beat - 1 };
|
|
418
|
+
}
|
|
419
|
+
const section = findSection(score, words.join(" "));
|
|
420
|
+
return section ? { type: "jump-section", name: section.name } : usage("jump");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
// Apply
|
|
425
|
+
|
|
426
|
+
function trackIdsOf(
|
|
427
|
+
tracks: RangeTracks,
|
|
428
|
+
focused: string,
|
|
429
|
+
): readonly string[] | undefined {
|
|
430
|
+
if (tracks.kind === "all") return undefined;
|
|
431
|
+
return [tracks.kind === "track" ? tracks.id : focused];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function whoLabel(
|
|
435
|
+
score: TrackScore,
|
|
436
|
+
ids: readonly string[] | undefined,
|
|
437
|
+
): string {
|
|
438
|
+
if (!ids) return "all tracks";
|
|
439
|
+
const track = score.tracks.find((candidate) => candidate.id === ids[0]);
|
|
440
|
+
return track?.name ?? ids[0]!;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function resolveRange(
|
|
444
|
+
score: TrackScore,
|
|
445
|
+
ref: RangeRef,
|
|
446
|
+
playheadBar: number,
|
|
447
|
+
): { range: BarRange; from: string } {
|
|
448
|
+
if (ref.kind === "bars") return { range: ref.range, from: "" };
|
|
449
|
+
if (ref.kind === "section") {
|
|
450
|
+
const section = findSection(score, ref.name)!;
|
|
451
|
+
return {
|
|
452
|
+
range: { startBar: section.startBar, bars: section.bars },
|
|
453
|
+
from: ` (${section.name})`,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
const resolved = rangeOf(score, playheadBar);
|
|
457
|
+
const from =
|
|
458
|
+
resolved.source === "loop"
|
|
459
|
+
? " (loop)"
|
|
460
|
+
: resolved.source === "section"
|
|
461
|
+
? ` (${resolved.section})`
|
|
462
|
+
: " (playhead)";
|
|
463
|
+
return { range: resolved.range, from };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function notesWord(count: number): string {
|
|
467
|
+
return `${count} ${count === 1 ? "note" : "notes"}`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function edited(
|
|
471
|
+
next: TrackScore,
|
|
472
|
+
message: string,
|
|
473
|
+
payload: Record<string, unknown>,
|
|
474
|
+
): RangeResult {
|
|
475
|
+
return { ok: true, message, next, kind: "score.range", payload };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Apply a parsed command; validation errors become refusals. */
|
|
479
|
+
export function applyRangeCommand(
|
|
480
|
+
score: TrackScore,
|
|
481
|
+
context: RangeContext,
|
|
482
|
+
command: RangeCommand,
|
|
483
|
+
): RangeResult {
|
|
484
|
+
try {
|
|
485
|
+
return applyUnchecked(score, context, command);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
if (error instanceof ScoreValidationError)
|
|
488
|
+
return {
|
|
489
|
+
ok: false,
|
|
490
|
+
message: `${verbOf(command)} · ${error.message}`,
|
|
491
|
+
};
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function verbOf(command: RangeCommand): string {
|
|
497
|
+
switch (command.type) {
|
|
498
|
+
case "bars-insert":
|
|
499
|
+
case "bars-remove":
|
|
500
|
+
return "bars";
|
|
501
|
+
case "loop-step":
|
|
502
|
+
return "loop";
|
|
503
|
+
case "jump-bar":
|
|
504
|
+
case "jump-section":
|
|
505
|
+
return "jump";
|
|
506
|
+
case "range-usage":
|
|
507
|
+
return command.verb;
|
|
508
|
+
default:
|
|
509
|
+
return command.type.slice("range-".length);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function applyUnchecked(
|
|
514
|
+
score: TrackScore,
|
|
515
|
+
context: RangeContext,
|
|
516
|
+
command: RangeCommand,
|
|
517
|
+
): RangeResult {
|
|
518
|
+
switch (command.type) {
|
|
519
|
+
case "range-usage":
|
|
520
|
+
return { ok: false, message: command.message };
|
|
521
|
+
case "range-copy": {
|
|
522
|
+
const ids = trackIdsOf(command.tracks, context.trackId);
|
|
523
|
+
const { range, from } = resolveRange(
|
|
524
|
+
score,
|
|
525
|
+
command.range,
|
|
526
|
+
context.playheadBar,
|
|
527
|
+
);
|
|
528
|
+
const who = whoLabel(score, ids);
|
|
529
|
+
if (command.to === undefined) {
|
|
530
|
+
const clip = extractRange(score, ids, range);
|
|
531
|
+
return {
|
|
532
|
+
ok: true,
|
|
533
|
+
message: `copied · ${who} ${barsWord(range)}${from} · ${notesWord(clipNoteCount(clip))} · paste at <bar>`,
|
|
534
|
+
clipboard: { clip, source: ids?.[0] ?? "all", range },
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
const next = copyRange(score, ids, range, command.to, {
|
|
538
|
+
times: command.times,
|
|
539
|
+
mode: command.mode,
|
|
540
|
+
});
|
|
541
|
+
const landed: BarRange = {
|
|
542
|
+
startBar: command.to,
|
|
543
|
+
bars: range.bars * command.times,
|
|
544
|
+
};
|
|
545
|
+
const count = notesInRange(score, ids, range) * command.times;
|
|
546
|
+
return edited(
|
|
547
|
+
next,
|
|
548
|
+
`copy · ${who} ${barsWord(range)}${from} → ${rangeLabel(landed)}${command.times > 1 ? ` ×${command.times}` : ""}${command.mode === "overwrite" ? "" : ` ${command.mode}`} · ${notesWord(count)}${next.bars !== score.bars ? ` · ${next.bars} bars` : ""}`,
|
|
549
|
+
{
|
|
550
|
+
range,
|
|
551
|
+
to: command.to,
|
|
552
|
+
times: command.times,
|
|
553
|
+
mode: command.mode,
|
|
554
|
+
tracks: ids ?? "all",
|
|
555
|
+
},
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
case "range-move": {
|
|
559
|
+
const ids = trackIdsOf(command.tracks, context.trackId);
|
|
560
|
+
const { range, from } = resolveRange(
|
|
561
|
+
score,
|
|
562
|
+
command.range,
|
|
563
|
+
context.playheadBar,
|
|
564
|
+
);
|
|
565
|
+
const next = moveRange(score, ids, range, command.to, {
|
|
566
|
+
insert: command.insert,
|
|
567
|
+
});
|
|
568
|
+
const count = notesInRange(score, ids, range);
|
|
569
|
+
return edited(
|
|
570
|
+
next,
|
|
571
|
+
`move · ${whoLabel(score, ids)} ${barsWord(range)}${from} → ${rangeLabel({ startBar: command.to, bars: range.bars })}${command.insert ? " insert" : ""} · ${notesWord(count)}`,
|
|
572
|
+
{ range, to: command.to, insert: command.insert, tracks: ids ?? "all" },
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
case "range-clear": {
|
|
576
|
+
const ids = trackIdsOf(command.tracks, context.trackId);
|
|
577
|
+
const { range, from } = resolveRange(
|
|
578
|
+
score,
|
|
579
|
+
command.range,
|
|
580
|
+
context.playheadBar,
|
|
581
|
+
);
|
|
582
|
+
const count = notesInRange(score, ids, range);
|
|
583
|
+
const next = clearRange(score, ids, range);
|
|
584
|
+
if (next === score || (count === 0 && sameMusic(score, next)))
|
|
585
|
+
return {
|
|
586
|
+
ok: true,
|
|
587
|
+
message: `clear · ${whoLabel(score, ids)} ${barsWord(range)}${from} · already silent`,
|
|
588
|
+
};
|
|
589
|
+
return edited(
|
|
590
|
+
next,
|
|
591
|
+
`clear · ${whoLabel(score, ids)} ${barsWord(range)}${from} · −${notesWord(count)} · the bars stay`,
|
|
592
|
+
{ range, tracks: ids ?? "all" },
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
case "range-reverse": {
|
|
596
|
+
const ids = trackIdsOf(command.tracks, context.trackId);
|
|
597
|
+
const { range, from } = resolveRange(
|
|
598
|
+
score,
|
|
599
|
+
command.range,
|
|
600
|
+
context.playheadBar,
|
|
601
|
+
);
|
|
602
|
+
const next = reverseRange(score, ids, range);
|
|
603
|
+
return edited(
|
|
604
|
+
next,
|
|
605
|
+
`reverse · ${whoLabel(score, ids)} ${barsWord(range)}${from} · ${notesWord(notesInRange(score, ids, range))}`,
|
|
606
|
+
{ range, tracks: ids ?? "all" },
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
case "range-paste": {
|
|
610
|
+
const board = context.clipboard;
|
|
611
|
+
if (!board)
|
|
612
|
+
return {
|
|
613
|
+
ok: false,
|
|
614
|
+
message: "paste · the clipboard is empty · copy bass 5-6 fills it",
|
|
615
|
+
};
|
|
616
|
+
const at =
|
|
617
|
+
command.at ??
|
|
618
|
+
Math.max(0, Math.min(score.bars, Math.floor(context.playheadBar)));
|
|
619
|
+
const target = board.clip.all ? undefined : context.trackId;
|
|
620
|
+
if (target && !score.tracks.some((track) => track.id === target))
|
|
621
|
+
return { ok: false, message: `paste · no track ${target}` };
|
|
622
|
+
const next = placeRange(score, board.clip, at, {
|
|
623
|
+
times: command.times,
|
|
624
|
+
mode: command.mode,
|
|
625
|
+
...(target ? { target } : {}),
|
|
626
|
+
});
|
|
627
|
+
const landed: BarRange = {
|
|
628
|
+
startBar: at,
|
|
629
|
+
bars: board.clip.bars * command.times,
|
|
630
|
+
};
|
|
631
|
+
return edited(
|
|
632
|
+
next,
|
|
633
|
+
`paste · ${board.clip.all ? "all tracks" : whoLabel(score, [target!])} → ${barsWord(landed)}${command.times > 1 ? ` ×${command.times}` : ""}${command.mode === "overwrite" ? "" : ` ${command.mode}`} · ${notesWord(clipNoteCount(board.clip) * command.times)}`,
|
|
634
|
+
{ at, times: command.times, mode: command.mode },
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
case "bars-insert": {
|
|
638
|
+
if (command.at > score.bars)
|
|
639
|
+
return {
|
|
640
|
+
ok: false,
|
|
641
|
+
message: `bars · bar ${command.at + 1} is past the song's ${score.bars} bars`,
|
|
642
|
+
};
|
|
643
|
+
const next = insertBars(score, command.at, command.count);
|
|
644
|
+
return edited(
|
|
645
|
+
next,
|
|
646
|
+
`bars · +${command.count} at bar ${command.at + 1} · later music moves right · ${next.bars} bars`,
|
|
647
|
+
{ insert: command.count, at: command.at, bars: next.bars },
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
case "bars-remove": {
|
|
651
|
+
const { range, from } = resolveRange(
|
|
652
|
+
score,
|
|
653
|
+
command.range,
|
|
654
|
+
context.playheadBar,
|
|
655
|
+
);
|
|
656
|
+
if (range.startBar >= score.bars)
|
|
657
|
+
return {
|
|
658
|
+
ok: false,
|
|
659
|
+
message: `bars · bar ${range.startBar + 1} is past the song's ${score.bars} bars`,
|
|
660
|
+
};
|
|
661
|
+
const bars = Math.min(range.bars, score.bars - range.startBar);
|
|
662
|
+
if (bars >= score.bars)
|
|
663
|
+
return { ok: false, message: "bars · the song keeps at least one bar" };
|
|
664
|
+
const next = deleteBars(score, range.startBar, bars);
|
|
665
|
+
return edited(
|
|
666
|
+
next,
|
|
667
|
+
`bars · removed ${barsWord({ startBar: range.startBar, bars })}${from} · later music moves left · ${next.bars} bars`,
|
|
668
|
+
{ remove: { startBar: range.startBar, bars }, bars: next.bars },
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
case "loop-step": {
|
|
672
|
+
const current = score.loop ?? loopedRange(score);
|
|
673
|
+
if (!current)
|
|
674
|
+
return { ok: false, message: "loop · nothing loops · loop 5-6 first" };
|
|
675
|
+
const stepped = steppedLoop(score, current, command.direction);
|
|
676
|
+
if (!stepped)
|
|
677
|
+
return {
|
|
678
|
+
ok: false,
|
|
679
|
+
message: `loop · ${rangeLabel(current)} is at the ${command.direction > 0 ? "end" : "start"} of the song`,
|
|
680
|
+
};
|
|
681
|
+
return {
|
|
682
|
+
...edited(
|
|
683
|
+
score.withLoop(stepped),
|
|
684
|
+
`loop · bars ${rangeLabel(stepped)}`,
|
|
685
|
+
{
|
|
686
|
+
loop: stepped,
|
|
687
|
+
},
|
|
688
|
+
),
|
|
689
|
+
kind: "score.loop",
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
case "jump-section":
|
|
693
|
+
return {
|
|
694
|
+
ok: true,
|
|
695
|
+
message: "",
|
|
696
|
+
delegate: `section jump ${command.name}`,
|
|
697
|
+
};
|
|
698
|
+
case "jump-bar":
|
|
699
|
+
return jumpTo(score, command.bar, command.beat);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function sameMusic(a: TrackScore, b: TrackScore): boolean {
|
|
704
|
+
return JSON.stringify(a.toJSON()) === JSON.stringify(b.toJSON());
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function loopedRange(score: TrackScore): BarRange | undefined {
|
|
708
|
+
if (score.loopSection === undefined) return undefined;
|
|
709
|
+
const section = findSection(score, score.loopSection);
|
|
710
|
+
return section
|
|
711
|
+
? { startBar: section.startBar, bars: section.bars }
|
|
712
|
+
: undefined;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** The transport beat for bar.beat (both 0-based) on the score. */
|
|
716
|
+
function jumpTo(score: TrackScore, bar: number, beat: number): RangeResult {
|
|
717
|
+
if (bar >= score.bars)
|
|
718
|
+
return {
|
|
719
|
+
ok: false,
|
|
720
|
+
message: `jump · bar ${bar + 1} is past the song's ${score.bars} bars`,
|
|
721
|
+
};
|
|
722
|
+
const perBar =
|
|
723
|
+
(barStartTick(score, bar + 1) - barStartTick(score, bar)) /
|
|
724
|
+
score.ticksPerBeat;
|
|
725
|
+
if (beat >= perBar)
|
|
726
|
+
return { ok: false, message: `jump · bar ${bar + 1} has ${perBar} beats` };
|
|
727
|
+
const label = `bar ${bar + 1}${beat > 0 ? `.${beat + 1}` : ""}`;
|
|
728
|
+
const looped = score.loop ?? loopedRange(score);
|
|
729
|
+
const at = barStartTick(score, bar) / score.ticksPerBeat + beat;
|
|
730
|
+
if (looped) {
|
|
731
|
+
const start = barStartTick(score, looped.startBar) / score.ticksPerBeat;
|
|
732
|
+
const end =
|
|
733
|
+
barStartTick(score, looped.startBar + looped.bars) / score.ticksPerBeat;
|
|
734
|
+
if (at < start || at >= end)
|
|
735
|
+
return {
|
|
736
|
+
ok: false,
|
|
737
|
+
message: `jump · ${label} is outside the loop ${rangeLabel(looped)} · loop off first`,
|
|
738
|
+
};
|
|
739
|
+
return { ok: true, message: `jump · ${label}`, seekBeat: at - start };
|
|
740
|
+
}
|
|
741
|
+
if (score.form.length > 0) {
|
|
742
|
+
// The first pass of the form that plays this bar.
|
|
743
|
+
const segment = formSegments(score).find(
|
|
744
|
+
(candidate) =>
|
|
745
|
+
bar >= candidate.section.startBar &&
|
|
746
|
+
bar < candidate.section.startBar + candidate.section.bars,
|
|
747
|
+
);
|
|
748
|
+
if (!segment)
|
|
749
|
+
return {
|
|
750
|
+
ok: false,
|
|
751
|
+
message: `jump · the form never plays ${label} · form off plays every bar`,
|
|
752
|
+
};
|
|
753
|
+
const perScoreBar = score.beatsPerBar;
|
|
754
|
+
const arranged =
|
|
755
|
+
(segment.startBar + bar - segment.section.startBar) * perScoreBar + beat;
|
|
756
|
+
return {
|
|
757
|
+
ok: true,
|
|
758
|
+
message: `jump · ${label} (${segment.section.name})`,
|
|
759
|
+
seekBeat: arranged,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
return { ok: true, message: `jump · ${label}`, seekBeat: at };
|
|
763
|
+
}
|