@hraness/dawg 0.7.0 → 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 +26 -0
- package/DAWG.md +117 -30
- package/README.md +7 -5
- package/core/diff.ts +2 -0
- package/core/ids.ts +52 -0
- package/core/loop.ts +3 -0
- package/core/range.ts +581 -0
- package/core/score.ts +123 -1
- package/core/sdk/print.ts +11 -2
- package/core/sdk/v1.ts +281 -2
- package/core/sections.ts +198 -19
- package/guides/arrange.md +9 -8
- package/guides/audio.md +31 -0
- package/guides/mix.md +3 -1
- package/guides/panes.md +32 -0
- package/guides/project.md +3 -2
- package/guides/rhythm.md +2 -1
- package/guides/tape.md +32 -0
- package/package.json +2 -1
- package/src/agent/agent.ts +4 -4
- package/src/agent/command-agent.ts +19 -10
- package/src/agent/ops.ts +15 -0
- package/src/agent/planner.ts +12 -0
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/show-me.ts +1 -1
- package/src/agent/tools.ts +2 -0
- package/src/agent/xcb-agent.ts +2 -3
- package/src/audio/arrange.ts +12 -2
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +84 -13
- package/src/audio/engine.ts +216 -28
- package/src/commands/arrange.ts +60 -25
- package/src/commands/grammar.ts +3 -1
- package/src/commands/help.ts +187 -9
- package/src/commands/history.ts +122 -11
- package/src/commands/keys.ts +17 -0
- package/src/commands/parses.ts +6 -1
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +2 -6
- package/src/commands/vocal-pitch.ts +2 -1
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +18 -0
- package/src/launch-args.ts +105 -1
- package/src/main.ts +1142 -86
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +196 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/origin.ts +20 -0
- package/src/session/port.ts +87 -10
- package/src/session/protocol.ts +360 -6
- package/src/session/rebase.ts +46 -8
- package/src/session/receipt.ts +7 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +61 -2
- package/src/tui/arrange-menu.ts +169 -1
- package/src/tui/euclid.ts +95 -14
- package/src/tui/fader.ts +55 -2
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu.ts +228 -2
- package/src/tui/play-session.ts +196 -21
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/tui/app.ts +217 -44
- package/tui/delight.ts +49 -0
- package/tui/drawer.ts +167 -9
- package/tui/grammar.ts +34 -5
- package/tui/guide.ts +8 -1
- package/tui/highway.ts +40 -5
- package/tui/hints.ts +5 -0
- package/tui/hits.ts +7 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +12 -0
- package/tui/screen.ts +7 -4
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +19 -1
package/core/score.ts
CHANGED
|
@@ -555,6 +555,34 @@ export type Sampler = Readonly<{
|
|
|
555
555
|
mode: "oneshot" | "keyed";
|
|
556
556
|
}>;
|
|
557
557
|
|
|
558
|
+
/**
|
|
559
|
+
* A sample reference as created by capture, import or resample: always
|
|
560
|
+
* content-pinned, so another machine can fetch the exact bytes by hash.
|
|
561
|
+
* Older refs may lack `sha256`; new ones may not.
|
|
562
|
+
*/
|
|
563
|
+
export type PinnedSampleRef = SampleRef & Readonly<{ sha256: string }>;
|
|
564
|
+
|
|
565
|
+
/** Rejects a newly created local sample ref that carries no valid sha256. */
|
|
566
|
+
export function requirePinnedRef(
|
|
567
|
+
ref: SampleRef,
|
|
568
|
+
label: string,
|
|
569
|
+
): PinnedSampleRef {
|
|
570
|
+
if (ref.src.startsWith(PACK_PREFIX)) {
|
|
571
|
+
if (ref.url === undefined && ref.sha256 === undefined)
|
|
572
|
+
throw new ScoreValidationError(
|
|
573
|
+
`${label} pack sound must be pinned by url or sha256`,
|
|
574
|
+
"invalid-track",
|
|
575
|
+
);
|
|
576
|
+
return ref as PinnedSampleRef;
|
|
577
|
+
}
|
|
578
|
+
if (typeof ref.sha256 !== "string" || !SHA256_HEX.test(ref.sha256))
|
|
579
|
+
throw new ScoreValidationError(
|
|
580
|
+
`${label} needs a sha256 content pin`,
|
|
581
|
+
"invalid-track",
|
|
582
|
+
);
|
|
583
|
+
return ref as PinnedSampleRef;
|
|
584
|
+
}
|
|
585
|
+
|
|
558
586
|
export type SampleRef = Readonly<{
|
|
559
587
|
/**
|
|
560
588
|
* Project-relative path, normally `tracks/<slug>/samples/<file>`, or a
|
|
@@ -1115,10 +1143,19 @@ export type TrackScoreData = Readonly<{
|
|
|
1115
1143
|
* the song (or its form). A name that matches no section is dropped.
|
|
1116
1144
|
*/
|
|
1117
1145
|
loopSection?: string | null;
|
|
1146
|
+
/**
|
|
1147
|
+
* The loop range (op1-ux): an ad hoc run of bars playback loops, set by
|
|
1148
|
+
* `loop 5-6`. It creates no section; when present it wins over
|
|
1149
|
+
* `loopSection`. Absent or null plays the song.
|
|
1150
|
+
*/
|
|
1151
|
+
loop?: LoopRange | null;
|
|
1118
1152
|
/** Sound calibration (0.7), 0..CALIBRATION_LATEST; absent or 0 is legacy. */
|
|
1119
1153
|
calibration?: number | null;
|
|
1120
1154
|
}>;
|
|
1121
1155
|
|
|
1156
|
+
/** A loop range: bars `startBar..startBar+bars` (0-based, like sections). */
|
|
1157
|
+
export type LoopRange = Readonly<{ startBar: number; bars: number }>;
|
|
1158
|
+
|
|
1122
1159
|
/** Canonical immutable score. Use `addNote`/`removeNote` to create a revision. */
|
|
1123
1160
|
export class TrackScore {
|
|
1124
1161
|
readonly version = SCORE_VERSION;
|
|
@@ -1142,6 +1179,8 @@ export class TrackScore {
|
|
|
1142
1179
|
readonly form: readonly FormEntry[];
|
|
1143
1180
|
/** The section playback loops; undefined plays the song. */
|
|
1144
1181
|
readonly loopSection: string | undefined;
|
|
1182
|
+
/** The loop range; absent without one (it wins over `loopSection`). */
|
|
1183
|
+
declare readonly loop?: LoopRange;
|
|
1145
1184
|
/** Sound calibration revision; absent is 0 (legacy engines). */
|
|
1146
1185
|
declare readonly calibration?: number;
|
|
1147
1186
|
|
|
@@ -1254,7 +1293,11 @@ export class TrackScore {
|
|
|
1254
1293
|
this.notes = freezeArray(notes);
|
|
1255
1294
|
this.sections = freezeArray(sections);
|
|
1256
1295
|
this.form = freezeArray(form);
|
|
1257
|
-
|
|
1296
|
+
// One loop at a time: a range wins over a looped section.
|
|
1297
|
+
const loop = normalizeLoopRange(data.loop);
|
|
1298
|
+
const looped = normalizeLoopSection(data.loopSection, sections);
|
|
1299
|
+
this.loopSection = loop ? undefined : looped;
|
|
1300
|
+
if (loop) this.loop = loop;
|
|
1258
1301
|
const calibration = normalizeCalibration(data.calibration);
|
|
1259
1302
|
if (calibration) this.calibration = calibration;
|
|
1260
1303
|
Object.freeze(this);
|
|
@@ -1278,6 +1321,18 @@ export class TrackScore {
|
|
|
1278
1321
|
});
|
|
1279
1322
|
}
|
|
1280
1323
|
|
|
1324
|
+
/**
|
|
1325
|
+
* Set the loop range (or clear it with null). A range clears the looped
|
|
1326
|
+
* section, so one loop plays at a time.
|
|
1327
|
+
*/
|
|
1328
|
+
withLoop(loop: LoopRange | null): TrackScore {
|
|
1329
|
+
return new TrackScore({
|
|
1330
|
+
...this.toJSON(),
|
|
1331
|
+
loop,
|
|
1332
|
+
...(loop ? { loopSection: null } : {}),
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1281
1336
|
withTempo(tempoBpm: number): TrackScore {
|
|
1282
1337
|
return new TrackScore({ ...this.toJSON(), tempoBpm });
|
|
1283
1338
|
}
|
|
@@ -1296,6 +1351,8 @@ export class TrackScore {
|
|
|
1296
1351
|
sections,
|
|
1297
1352
|
form,
|
|
1298
1353
|
loopSection: loopSection ?? null,
|
|
1354
|
+
// Looping a section ends the loop range.
|
|
1355
|
+
...(loopSection ? { loop: null } : {}),
|
|
1299
1356
|
});
|
|
1300
1357
|
}
|
|
1301
1358
|
|
|
@@ -1379,6 +1436,7 @@ export class TrackScore {
|
|
|
1379
1436
|
...(this.loopSection === undefined
|
|
1380
1437
|
? {}
|
|
1381
1438
|
: { loopSection: this.loopSection }),
|
|
1439
|
+
...(this.loop ? { loop: this.loop } : {}),
|
|
1382
1440
|
...(this.calibration ? { calibration: this.calibration } : {}),
|
|
1383
1441
|
};
|
|
1384
1442
|
}
|
|
@@ -1736,6 +1794,11 @@ export type ScoreOperation =
|
|
|
1736
1794
|
form: readonly FormEntry[];
|
|
1737
1795
|
/** The looped section; absent or null plays the song. */
|
|
1738
1796
|
loopSection?: string | null;
|
|
1797
|
+
}>
|
|
1798
|
+
| Readonly<{
|
|
1799
|
+
/** The loop range (op1-ux); null clears it. */
|
|
1800
|
+
type: "setLoop";
|
|
1801
|
+
loop: LoopRange | null;
|
|
1739
1802
|
}>;
|
|
1740
1803
|
|
|
1741
1804
|
export function applyScoreOperation(
|
|
@@ -1761,6 +1824,7 @@ export function applyScoreOperation(
|
|
|
1761
1824
|
operation.form,
|
|
1762
1825
|
operation.loopSection ?? null,
|
|
1763
1826
|
);
|
|
1827
|
+
if (operation.type === "setLoop") return score.withLoop(operation.loop);
|
|
1764
1828
|
if (operation.type === "addTrack") return addTrack(score, operation.track);
|
|
1765
1829
|
if (operation.type === "addNote") return addNote(score, operation.note);
|
|
1766
1830
|
if (operation.type === "removeNote")
|
|
@@ -1816,6 +1880,7 @@ export function scoreFromJSON(value: unknown): TrackScore {
|
|
|
1816
1880
|
sections?: readonly Section[];
|
|
1817
1881
|
form?: readonly FormEntry[];
|
|
1818
1882
|
loopSection?: string | null;
|
|
1883
|
+
loop?: LoopRange | null;
|
|
1819
1884
|
calibration?: number | null;
|
|
1820
1885
|
} = {
|
|
1821
1886
|
tracks: optionalArray(value.tracks).map(parseTrack),
|
|
@@ -1855,6 +1920,8 @@ export function scoreFromJSON(value: unknown): TrackScore {
|
|
|
1855
1920
|
// The constructor checks the type and drops a name matching no section.
|
|
1856
1921
|
if (value.loopSection !== undefined)
|
|
1857
1922
|
data.loopSection = value.loopSection as string | null;
|
|
1923
|
+
if (value.loop !== undefined) data.loop = value.loop as LoopRange | null;
|
|
1924
|
+
migrateHiddenLoop(data);
|
|
1858
1925
|
// The constructor checks the range.
|
|
1859
1926
|
if (value.calibration !== undefined)
|
|
1860
1927
|
data.calibration = value.calibration as number | null;
|
|
@@ -2002,6 +2069,61 @@ function normalizeCalibration(value: unknown): number | undefined {
|
|
|
2002
2069
|
return value;
|
|
2003
2070
|
}
|
|
2004
2071
|
|
|
2072
|
+
/** A loop range, checked: whole bars from 0, at least one, inside the limit. */
|
|
2073
|
+
function normalizeLoopRange(value: unknown): LoopRange | undefined {
|
|
2074
|
+
if (value === undefined || value === null) return undefined;
|
|
2075
|
+
const range = value as { startBar?: unknown; bars?: unknown };
|
|
2076
|
+
const { startBar, bars } = range;
|
|
2077
|
+
if (
|
|
2078
|
+
typeof value !== "object" ||
|
|
2079
|
+
typeof startBar !== "number" ||
|
|
2080
|
+
typeof bars !== "number" ||
|
|
2081
|
+
!Number.isInteger(startBar) ||
|
|
2082
|
+
!Number.isInteger(bars) ||
|
|
2083
|
+
startBar < 0 ||
|
|
2084
|
+
bars < 1 ||
|
|
2085
|
+
startBar + bars > SCORE_LIMITS.maxBars
|
|
2086
|
+
)
|
|
2087
|
+
throw new ScoreValidationError(
|
|
2088
|
+
`loop must be {startBar, bars}: whole bars from 0, 1..${SCORE_LIMITS.maxBars}`,
|
|
2089
|
+
"invalid-score",
|
|
2090
|
+
);
|
|
2091
|
+
return Object.freeze({ startBar, bars });
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* f07 stored `loop 5-6` as a hidden section named `loop`. On load it becomes
|
|
2096
|
+
* the loop range and the section (and its form entries) go.
|
|
2097
|
+
*/
|
|
2098
|
+
function migrateHiddenLoop(data: {
|
|
2099
|
+
sections?: readonly Section[];
|
|
2100
|
+
form?: readonly FormEntry[];
|
|
2101
|
+
loopSection?: string | null;
|
|
2102
|
+
loop?: LoopRange | null;
|
|
2103
|
+
}): void {
|
|
2104
|
+
if (data.loop || typeof data.loopSection !== "string") return;
|
|
2105
|
+
if (data.loopSection.trim().toLowerCase() !== "loop") return;
|
|
2106
|
+
const sections = data.sections ?? [];
|
|
2107
|
+
const hidden = sections.find(
|
|
2108
|
+
(section) =>
|
|
2109
|
+
typeof section?.name === "string" &&
|
|
2110
|
+
section.name.trim().toLowerCase() === "loop",
|
|
2111
|
+
);
|
|
2112
|
+
if (
|
|
2113
|
+
!hidden ||
|
|
2114
|
+
!Number.isInteger(hidden.startBar) ||
|
|
2115
|
+
!Number.isInteger(hidden.bars)
|
|
2116
|
+
)
|
|
2117
|
+
return;
|
|
2118
|
+
data.loop = { startBar: hidden.startBar, bars: hidden.bars };
|
|
2119
|
+
data.loopSection = null;
|
|
2120
|
+
data.sections = sections.filter((section) => section !== hidden);
|
|
2121
|
+
if (data.form)
|
|
2122
|
+
data.form = data.form.filter(
|
|
2123
|
+
(entry) => entry?.section?.trim?.().toLowerCase() !== "loop",
|
|
2124
|
+
);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2005
2127
|
function normalizeLoopSection(
|
|
2006
2128
|
value: unknown,
|
|
2007
2129
|
sections: readonly Section[],
|
package/core/sdk/print.ts
CHANGED
|
@@ -263,8 +263,17 @@ export function printSong(
|
|
|
263
263
|
);
|
|
264
264
|
if (score.form.length > 0)
|
|
265
265
|
entries.push(`form: ${printForm(score.form, INDENT)}`);
|
|
266
|
-
|
|
267
|
-
|
|
266
|
+
// One field for both: `loop: "5-6"` (1-based bars) or `loop: "chorus"`.
|
|
267
|
+
if (score.loop)
|
|
268
|
+
entries.push(
|
|
269
|
+
`loop: ${str(
|
|
270
|
+
score.loop.bars === 1
|
|
271
|
+
? `${score.loop.startBar + 1}`
|
|
272
|
+
: `${score.loop.startBar + 1}-${score.loop.startBar + score.loop.bars}`,
|
|
273
|
+
)}`,
|
|
274
|
+
);
|
|
275
|
+
else if (score.loopSection !== undefined)
|
|
276
|
+
entries.push(`loop: ${str(score.loopSection)}`);
|
|
268
277
|
if (score.calibration) entries.push(`calibration: ${num(score.calibration)}`);
|
|
269
278
|
lines.push("export default song({");
|
|
270
279
|
for (const entry of entries) lines.push(`${INDENT}${entry},`);
|
package/core/sdk/v1.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
/** SDK release; dawg refreshes the vendored copy when its own is newer. */
|
|
30
|
-
export const SDK_VERSION = "1.
|
|
30
|
+
export const SDK_VERSION = "1.34.0";
|
|
31
31
|
/** Major of `SDK_VERSION`; `dawg.json` records it as `sdk`. */
|
|
32
32
|
export const SDK_MAJOR = 1;
|
|
33
33
|
|
|
@@ -4564,8 +4564,13 @@ export type SongInput = Readonly<{
|
|
|
4564
4564
|
* Absent plays the bars straight through.
|
|
4565
4565
|
*/
|
|
4566
4566
|
form?: string | readonly (string | SongFormEntry)[];
|
|
4567
|
-
/** The section playback loops (SDK 1.18.0);
|
|
4567
|
+
/** The section playback loops (SDK 1.18.0); `loop: "chorus"` says the same. */
|
|
4568
4568
|
loopSection?: string;
|
|
4569
|
+
/**
|
|
4570
|
+
* What playback loops (SDK 1.34.0): bars `"5-6"` (1-based, as the prompt
|
|
4571
|
+
* shows them) or a section `"chorus"`. Export ignores it.
|
|
4572
|
+
*/
|
|
4573
|
+
loop?: string;
|
|
4569
4574
|
/**
|
|
4570
4575
|
* Sound calibration (SDK 1.32.0): `1` renders the 0.7 level, pitch and
|
|
4571
4576
|
* drum-kit fixes (hat choke, tuned toms, crash and ride, level keys,
|
|
@@ -4771,6 +4776,8 @@ export type Song = Readonly<{
|
|
|
4771
4776
|
form?: readonly SongFormEntry[];
|
|
4772
4777
|
/** Present only when a section loops (SDK 1.18.0). */
|
|
4773
4778
|
loopSection?: string;
|
|
4779
|
+
/** Present only when bars loop (SDK 1.34.0): 0-based `startBar`. */
|
|
4780
|
+
loop?: Readonly<{ startBar: number; bars: number }>;
|
|
4774
4781
|
/** Present only when the song sets one (SDK 1.32.0). */
|
|
4775
4782
|
calibration?: number;
|
|
4776
4783
|
}>;
|
|
@@ -5009,6 +5016,270 @@ function songSections(
|
|
|
5009
5016
|
/** The newest `song({ calibration })` (mirrors core CALIBRATION_LATEST). */
|
|
5010
5017
|
export const SONG_CALIBRATION_LATEST = 1;
|
|
5011
5018
|
|
|
5019
|
+
/** `song({ loop })`: bars `"5-6"` → a 0-based range, else a section name. */
|
|
5020
|
+
function songLoop(
|
|
5021
|
+
input: unknown,
|
|
5022
|
+
bars: number,
|
|
5023
|
+
): string | Readonly<{ startBar: number; bars: number }> {
|
|
5024
|
+
if (typeof input !== "string" || input.trim() === "")
|
|
5025
|
+
throw new DawgSdkError('song loop must be bars "5-6" or a section name');
|
|
5026
|
+
const match = /^\s*(\d{1,4})\s*(?:-|–|\.\.)?\s*(\d{1,4})?\s*$/u.exec(input);
|
|
5027
|
+
if (!match) return input.trim();
|
|
5028
|
+
const from = Number(match[1]);
|
|
5029
|
+
const to = match[2] === undefined ? from : Number(match[2]);
|
|
5030
|
+
if (from < 1 || to < from)
|
|
5031
|
+
throw new DawgSdkError(`song loop "${input}" runs low-high from bar 1`);
|
|
5032
|
+
if (to > bars)
|
|
5033
|
+
throw new DawgSdkError(
|
|
5034
|
+
`song loop "${input}" is past the song's ${bars} bars`,
|
|
5035
|
+
);
|
|
5036
|
+
return Object.freeze({ startBar: from - 1, bars: to - from + 1 });
|
|
5037
|
+
}
|
|
5038
|
+
|
|
5039
|
+
// ---------------------------------------------------------------------------
|
|
5040
|
+
// Range helpers (SDK 1.34.0): the prompt's copy, move and bars insert as
|
|
5041
|
+
// pure functions over notes, for files written by hand. Bars are 1-based.
|
|
5042
|
+
|
|
5043
|
+
type Timed = Readonly<{ start: number; length: number }>;
|
|
5044
|
+
type RangeHelperOptions = Readonly<{ beatsPerBar?: number }>;
|
|
5045
|
+
|
|
5046
|
+
function perBar(options: RangeHelperOptions | undefined, what: string): number {
|
|
5047
|
+
return positive(options?.beatsPerBar ?? 4, `${what} beatsPerBar`);
|
|
5048
|
+
}
|
|
5049
|
+
|
|
5050
|
+
function wholeBar(value: unknown, what: string): number {
|
|
5051
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1)
|
|
5052
|
+
throw new DawgSdkError(`${what} must be a bar from 1`);
|
|
5053
|
+
return value;
|
|
5054
|
+
}
|
|
5055
|
+
|
|
5056
|
+
/**
|
|
5057
|
+
* The notes (or hits) that start in bars `from..to`, re-based to beat 0
|
|
5058
|
+
* and cut at the range's end: `bars(chorus, 5, 6)`.
|
|
5059
|
+
*/
|
|
5060
|
+
export function bars<T extends Timed>(
|
|
5061
|
+
notes: readonly T[],
|
|
5062
|
+
from: number,
|
|
5063
|
+
to: number = from,
|
|
5064
|
+
options?: RangeHelperOptions,
|
|
5065
|
+
): readonly T[] {
|
|
5066
|
+
if (!Array.isArray(notes))
|
|
5067
|
+
throw new DawgSdkError("bars needs an array of notes");
|
|
5068
|
+
const each = perBar(options, "bars");
|
|
5069
|
+
const first = wholeBar(from, "bars from");
|
|
5070
|
+
const last = wholeBar(to, "bars to");
|
|
5071
|
+
if (last < first)
|
|
5072
|
+
throw new DawgSdkError("bars runs low-high: bars(notes, 5, 6)");
|
|
5073
|
+
const start = (first - 1) * each;
|
|
5074
|
+
const end = last * each;
|
|
5075
|
+
return Object.freeze(
|
|
5076
|
+
notes
|
|
5077
|
+
.filter((item) => item.start >= start && item.start < end)
|
|
5078
|
+
.map((item) =>
|
|
5079
|
+
Object.freeze({
|
|
5080
|
+
...item,
|
|
5081
|
+
start: item.start - start,
|
|
5082
|
+
length: Math.min(item.length, end - item.start),
|
|
5083
|
+
}),
|
|
5084
|
+
),
|
|
5085
|
+
);
|
|
5086
|
+
}
|
|
5087
|
+
|
|
5088
|
+
/**
|
|
5089
|
+
* Notes laid down at bar `at`, repeated `times` times end to end:
|
|
5090
|
+
* `place(bars(bass, 5, 6), { at: 7, times: 2 })`. Each repeat spans
|
|
5091
|
+
* `bars` bars (default: the whole bars the notes reach).
|
|
5092
|
+
*/
|
|
5093
|
+
export function place<T extends Timed>(
|
|
5094
|
+
notes: readonly T[],
|
|
5095
|
+
options: Readonly<{ at: number; times?: number; bars?: number }> &
|
|
5096
|
+
RangeHelperOptions,
|
|
5097
|
+
): readonly T[] {
|
|
5098
|
+
if (!Array.isArray(notes))
|
|
5099
|
+
throw new DawgSdkError("place needs an array of notes");
|
|
5100
|
+
if (!isRecord(options)) throw new DawgSdkError("place needs { at: <bar> }");
|
|
5101
|
+
const each = perBar(options, "place");
|
|
5102
|
+
const at = (wholeBar(options.at, "place at") - 1) * each;
|
|
5103
|
+
const times = options.times ?? 1;
|
|
5104
|
+
if (!Number.isInteger(times) || times < 1 || times > 64)
|
|
5105
|
+
throw new DawgSdkError("place times must be 1..64");
|
|
5106
|
+
const reach = notes.reduce(
|
|
5107
|
+
(most, item) => Math.max(most, item.start + item.length),
|
|
5108
|
+
0,
|
|
5109
|
+
);
|
|
5110
|
+
const span =
|
|
5111
|
+
(options.bars === undefined
|
|
5112
|
+
? Math.max(1, Math.ceil(reach / each - 1e-9))
|
|
5113
|
+
: wholeBar(options.bars, "place bars")) * each;
|
|
5114
|
+
const out: T[] = [];
|
|
5115
|
+
for (let pass = 0; pass < times; pass += 1)
|
|
5116
|
+
for (const item of notes)
|
|
5117
|
+
out.push(
|
|
5118
|
+
Object.freeze({ ...item, start: at + pass * span + item.start }),
|
|
5119
|
+
);
|
|
5120
|
+
return Object.freeze(out);
|
|
5121
|
+
}
|
|
5122
|
+
|
|
5123
|
+
/**
|
|
5124
|
+
* The notes mirrored in time over `bars` bars from beat 0 (a note ending
|
|
5125
|
+
* at the span's end starts at 0): `reversed(bars(lead, 5, 6), { bars: 2 })`.
|
|
5126
|
+
*/
|
|
5127
|
+
export function reversed<T extends Timed>(
|
|
5128
|
+
notes: readonly T[],
|
|
5129
|
+
options: Readonly<{ bars: number }> & RangeHelperOptions,
|
|
5130
|
+
): readonly T[] {
|
|
5131
|
+
if (!Array.isArray(notes))
|
|
5132
|
+
throw new DawgSdkError("reversed needs an array of notes");
|
|
5133
|
+
if (!isRecord(options))
|
|
5134
|
+
throw new DawgSdkError("reversed needs { bars: <n> }");
|
|
5135
|
+
const span =
|
|
5136
|
+
wholeBar(options.bars, "reversed bars") * perBar(options, "reversed");
|
|
5137
|
+
return Object.freeze(
|
|
5138
|
+
notes
|
|
5139
|
+
.filter((item) => item.start < span)
|
|
5140
|
+
.map((item) => {
|
|
5141
|
+
const length = Math.min(item.length, span - item.start);
|
|
5142
|
+
return Object.freeze({
|
|
5143
|
+
...item,
|
|
5144
|
+
start: span - item.start - length,
|
|
5145
|
+
length,
|
|
5146
|
+
});
|
|
5147
|
+
})
|
|
5148
|
+
.sort((a, b) => a.start - b.start),
|
|
5149
|
+
);
|
|
5150
|
+
}
|
|
5151
|
+
|
|
5152
|
+
/** Shift every `tick` field at or past `at` in an array of points. */
|
|
5153
|
+
function shiftTicks(value: unknown, at: number, shift: number): unknown {
|
|
5154
|
+
if (!Array.isArray(value)) return value;
|
|
5155
|
+
if (!value.every((item) => isRecord(item) && typeof item.tick === "number"))
|
|
5156
|
+
return value;
|
|
5157
|
+
return Object.freeze(
|
|
5158
|
+
value.map((item) =>
|
|
5159
|
+
(item as { tick: number }).tick >= at
|
|
5160
|
+
? Object.freeze({
|
|
5161
|
+
...(item as object),
|
|
5162
|
+
tick: (item as { tick: number }).tick + shift,
|
|
5163
|
+
})
|
|
5164
|
+
: item,
|
|
5165
|
+
),
|
|
5166
|
+
);
|
|
5167
|
+
}
|
|
5168
|
+
|
|
5169
|
+
/**
|
|
5170
|
+
* `bars` empty bars inserted before bar `at` of a `song()` result: later
|
|
5171
|
+
* notes, sections, the loop, tempo marks, fermatas, automation points and
|
|
5172
|
+
* clips move right; a note held across `at` sounds on through the gap.
|
|
5173
|
+
* `export default insertBars(song({...}), { at: 7, bars: 2 })`.
|
|
5174
|
+
*/
|
|
5175
|
+
export function insertBars(
|
|
5176
|
+
input: Song,
|
|
5177
|
+
options: Readonly<{ at: number; bars: number }>,
|
|
5178
|
+
): Song {
|
|
5179
|
+
if (!isRecord(input) || input.format !== "track.loop/v1")
|
|
5180
|
+
throw new DawgSdkError("insertBars needs a song() result");
|
|
5181
|
+
if (!isRecord(options))
|
|
5182
|
+
throw new DawgSdkError("insertBars needs { at, bars }");
|
|
5183
|
+
const atBar = wholeBar(options.at, "insertBars at") - 1;
|
|
5184
|
+
const count = wholeBar(options.bars, "insertBars bars");
|
|
5185
|
+
if (atBar > input.bars)
|
|
5186
|
+
throw new DawgSdkError(
|
|
5187
|
+
`insertBars at ${atBar + 1} is past the song's ${input.bars} bars`,
|
|
5188
|
+
);
|
|
5189
|
+
if (input.bars + count > 256)
|
|
5190
|
+
throw new DawgSdkError("a song has at most 256 bars");
|
|
5191
|
+
if (input.time?.meter?.some((mark) => mark.bar > 0))
|
|
5192
|
+
throw new DawgSdkError(
|
|
5193
|
+
"insertBars needs one meter · use dawg's bars insert",
|
|
5194
|
+
);
|
|
5195
|
+
const barTicks = input.beatsPerBar * input.ticksPerBeat;
|
|
5196
|
+
const at = atBar * barTicks;
|
|
5197
|
+
const shift = count * barTicks;
|
|
5198
|
+
const atBeat = atBar * input.beatsPerBar;
|
|
5199
|
+
const beats = count * input.beatsPerBar;
|
|
5200
|
+
const moveRange = <R extends { startBar: number; bars: number }>(
|
|
5201
|
+
range: R,
|
|
5202
|
+
): R =>
|
|
5203
|
+
range.startBar >= atBar
|
|
5204
|
+
? { ...range, startBar: range.startBar + count }
|
|
5205
|
+
: range.startBar + range.bars > atBar
|
|
5206
|
+
? { ...range, bars: range.bars + count }
|
|
5207
|
+
: range;
|
|
5208
|
+
const tracks = input.tracks.map((track) => {
|
|
5209
|
+
const out: Record<string, unknown> = { ...track };
|
|
5210
|
+
for (const [field, value] of Object.entries(track)) {
|
|
5211
|
+
if (field === "fxAutomation" && isRecord(value)) {
|
|
5212
|
+
out[field] = Object.freeze(
|
|
5213
|
+
Object.fromEntries(
|
|
5214
|
+
Object.entries(value).map(([lane, points]) => [
|
|
5215
|
+
lane,
|
|
5216
|
+
shiftTicks(points, at, shift),
|
|
5217
|
+
]),
|
|
5218
|
+
),
|
|
5219
|
+
);
|
|
5220
|
+
} else if (field === "clips" && Array.isArray(value)) {
|
|
5221
|
+
out[field] = Object.freeze(
|
|
5222
|
+
value.map((clip) =>
|
|
5223
|
+
isRecord(clip) && typeof clip.at === "number" && clip.at >= atBeat
|
|
5224
|
+
? Object.freeze({ ...clip, at: clip.at + beats })
|
|
5225
|
+
: clip,
|
|
5226
|
+
),
|
|
5227
|
+
);
|
|
5228
|
+
} else out[field] = shiftTicks(value, at, shift);
|
|
5229
|
+
}
|
|
5230
|
+
return Object.freeze(out) as ScoreTrack;
|
|
5231
|
+
});
|
|
5232
|
+
const time = input.time
|
|
5233
|
+
? Object.freeze({
|
|
5234
|
+
...input.time,
|
|
5235
|
+
...(input.time.tempo
|
|
5236
|
+
? {
|
|
5237
|
+
tempo: shiftTicks(
|
|
5238
|
+
input.time.tempo,
|
|
5239
|
+
at,
|
|
5240
|
+
shift,
|
|
5241
|
+
) as ScoreTime["tempo"],
|
|
5242
|
+
}
|
|
5243
|
+
: {}),
|
|
5244
|
+
...(input.time.fermatas
|
|
5245
|
+
? {
|
|
5246
|
+
fermatas: shiftTicks(
|
|
5247
|
+
input.time.fermatas,
|
|
5248
|
+
at,
|
|
5249
|
+
shift,
|
|
5250
|
+
) as ScoreTime["fermatas"],
|
|
5251
|
+
}
|
|
5252
|
+
: {}),
|
|
5253
|
+
})
|
|
5254
|
+
: undefined;
|
|
5255
|
+
return Object.freeze({
|
|
5256
|
+
...input,
|
|
5257
|
+
bars: input.bars + count,
|
|
5258
|
+
...(time ? { time } : {}),
|
|
5259
|
+
tracks: Object.freeze(tracks),
|
|
5260
|
+
notes: Object.freeze(
|
|
5261
|
+
input.notes.map((item) =>
|
|
5262
|
+
item.startTick >= at
|
|
5263
|
+
? Object.freeze({ ...item, startTick: item.startTick + shift })
|
|
5264
|
+
: item.startTick + item.durationTicks > at
|
|
5265
|
+
? Object.freeze({
|
|
5266
|
+
...item,
|
|
5267
|
+
durationTicks: item.durationTicks + shift,
|
|
5268
|
+
})
|
|
5269
|
+
: item,
|
|
5270
|
+
),
|
|
5271
|
+
),
|
|
5272
|
+
...(input.sections
|
|
5273
|
+
? {
|
|
5274
|
+
sections: Object.freeze(
|
|
5275
|
+
input.sections.map((section) => Object.freeze(moveRange(section))),
|
|
5276
|
+
),
|
|
5277
|
+
}
|
|
5278
|
+
: {}),
|
|
5279
|
+
...(input.loop ? { loop: Object.freeze(moveRange(input.loop)) } : {}),
|
|
5280
|
+
});
|
|
5281
|
+
}
|
|
5282
|
+
|
|
5012
5283
|
export function song(input: SongInput): Song {
|
|
5013
5284
|
if (!isRecord(input)) throw new DawgSdkError("song() needs an object");
|
|
5014
5285
|
const tempoBpm = finite(input.tempo ?? 120, "song tempo");
|
|
@@ -5238,6 +5509,7 @@ export function song(input: SongInput): Song {
|
|
|
5238
5509
|
sections?: readonly SongSection[];
|
|
5239
5510
|
form?: readonly SongFormEntry[];
|
|
5240
5511
|
loopSection?: string;
|
|
5512
|
+
loop?: Readonly<{ startBar: number; bars: number }>;
|
|
5241
5513
|
} = {};
|
|
5242
5514
|
if (input.sections !== undefined) {
|
|
5243
5515
|
const sections = songSections(input.sections);
|
|
@@ -5252,6 +5524,13 @@ export function song(input: SongInput): Song {
|
|
|
5252
5524
|
throw new DawgSdkError("song loopSection must be a section name");
|
|
5253
5525
|
arrangement.loopSection = input.loopSection;
|
|
5254
5526
|
}
|
|
5527
|
+
if (input.loop !== undefined) {
|
|
5528
|
+
if (input.loopSection !== undefined)
|
|
5529
|
+
throw new DawgSdkError("song sets loop or loopSection, not both");
|
|
5530
|
+
const looped = songLoop(input.loop, bars);
|
|
5531
|
+
if (typeof looped === "string") arrangement.loopSection = looped;
|
|
5532
|
+
else arrangement.loop = looped;
|
|
5533
|
+
}
|
|
5255
5534
|
return Object.freeze({
|
|
5256
5535
|
format: "track.loop/v1",
|
|
5257
5536
|
version: 1,
|