@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/render.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
sectionCues,
|
|
37
37
|
} from "./audio/arrange.ts";
|
|
38
38
|
import { SampleLibrary, hasSamplerTracks } from "./audio/samples.ts";
|
|
39
|
+
import { withClipLengths } from "./audio/clips.ts";
|
|
39
40
|
import {
|
|
40
41
|
PackStore,
|
|
41
42
|
creditsLine,
|
|
@@ -118,6 +119,14 @@ export async function runRenderCommand(
|
|
|
118
119
|
);
|
|
119
120
|
return 1;
|
|
120
121
|
}
|
|
122
|
+
// Clips crossing the section's edges are cut against their files' real
|
|
123
|
+
// ends, so read their lengths first (load problems are reported below).
|
|
124
|
+
if (score.tracks.some((track) => track.clips)) {
|
|
125
|
+
const bank = await new SampleLibrary({ projectRoot: workspace }).load(
|
|
126
|
+
score,
|
|
127
|
+
);
|
|
128
|
+
score = withClipLengths(score, bank);
|
|
129
|
+
}
|
|
121
130
|
// One section as it plays when looped: its mutes and variations, no form.
|
|
122
131
|
score = withNoteCap(BAKED_NOTE_CAP, () =>
|
|
123
132
|
sectionScore(bakeTrackTime(score), section),
|
|
@@ -226,7 +235,13 @@ export async function runRenderCommand(
|
|
|
226
235
|
const sha = createHash("sha256").update(wav).digest("hex");
|
|
227
236
|
const seconds = audio.frames / audio.sampleRate;
|
|
228
237
|
stdout.write(
|
|
229
|
-
`rendered · ${target} · ${seconds.toFixed(2)} s · ${
|
|
238
|
+
`rendered · ${target} · ${seconds.toFixed(2)} s · ${audio.sampleRate} Hz${
|
|
239
|
+
// A plain song keeps 0.4's 22.05 kHz (byte-identical renders); say
|
|
240
|
+
// so, since its ceiling is 11 kHz and cymbal air is lost.
|
|
241
|
+
rateArg === undefined && !score.master
|
|
242
|
+
? " (--rate 48000 for full band)"
|
|
243
|
+
: ""
|
|
244
|
+
} · ${wav.byteLength} bytes · ${sha}\n`,
|
|
230
245
|
);
|
|
231
246
|
if (credits) stdout.write(`credits · ${credits}\n`);
|
|
232
247
|
const clipped = clippedSamples(audio.pcm);
|
|
@@ -238,6 +253,10 @@ export async function runRenderCommand(
|
|
|
238
253
|
: "lower track volumes or add a master with a limiter (master streaming, or master: { limiter: {} } in song.ts)"
|
|
239
254
|
}\n`,
|
|
240
255
|
);
|
|
256
|
+
if (audio.nonFinite)
|
|
257
|
+
stderr.write(
|
|
258
|
+
`warning · ${audio.nonFinite.samples} non-finite ${audio.nonFinite.samples === 1 ? "sample" : "samples"} zeroed · from ${audio.nonFinite.tracks.join(", ")} · an effect or voice setting is unstable\n`,
|
|
259
|
+
);
|
|
241
260
|
if (options.has("--measure"))
|
|
242
261
|
stdout.write(
|
|
243
262
|
`loudness · ${measurementLine(measureRendered(audio, false).mix, audio.master)}\n`,
|
package/src/session/client.ts
CHANGED
|
@@ -8,8 +8,14 @@ import {
|
|
|
8
8
|
LineDecoder,
|
|
9
9
|
MAX_SERVER_FRAME_BYTES,
|
|
10
10
|
parseServerMessage,
|
|
11
|
+
PROTOCOL_MAX,
|
|
12
|
+
PROTOCOL_MIN,
|
|
13
|
+
ProtocolError,
|
|
11
14
|
type ApplyResult,
|
|
12
15
|
type ClientMessage,
|
|
16
|
+
type LiveMessage,
|
|
17
|
+
type LiveStatus,
|
|
18
|
+
type PaneView,
|
|
13
19
|
type PresenceEntry,
|
|
14
20
|
type ServerMessage,
|
|
15
21
|
type TransportAction,
|
|
@@ -34,6 +40,13 @@ export type DaemonClientOptions = {
|
|
|
34
40
|
sessionId: string;
|
|
35
41
|
label?: string;
|
|
36
42
|
clientId?: string;
|
|
43
|
+
/** Who is at this window; see src/identity/actor.ts. */
|
|
44
|
+
actor?: { id: string; name: string };
|
|
45
|
+
/**
|
|
46
|
+
* Protocol range to offer. Defaults to this build's full range; tests pin
|
|
47
|
+
* `{vMin: 1, vMax: 1}` to act as an old client.
|
|
48
|
+
*/
|
|
49
|
+
versions?: { vMin: number; vMax: number };
|
|
37
50
|
focusedTrackId?: string | null;
|
|
38
51
|
/** Spawn a detached daemon when none answers. Defaults to true. */
|
|
39
52
|
spawn?: boolean;
|
|
@@ -74,6 +87,11 @@ export class DaemonClient {
|
|
|
74
87
|
public transport!: TransportState;
|
|
75
88
|
public presence: PresenceEntry[] = [];
|
|
76
89
|
public daemonPid = 0;
|
|
90
|
+
/** Negotiated revision: 1 until a v2 daemon says otherwise. */
|
|
91
|
+
public protocol = 1;
|
|
92
|
+
public caps: readonly string[] = [];
|
|
93
|
+
/** Last intent seq this client issued; retries resend the same one. */
|
|
94
|
+
private seq = 0;
|
|
77
95
|
private socket: Socket | undefined;
|
|
78
96
|
private decoder = new LineDecoder(MAX_SERVER_FRAME_BYTES);
|
|
79
97
|
private readonly pending = new Map<string, Pending>();
|
|
@@ -82,6 +100,9 @@ export class DaemonClient {
|
|
|
82
100
|
private reconnecting: Promise<void> | undefined;
|
|
83
101
|
private focusedTrackId: string | null;
|
|
84
102
|
private welcomed: (() => void) | undefined;
|
|
103
|
+
private view: ClientMessage | undefined;
|
|
104
|
+
private monitoring = false;
|
|
105
|
+
private click: LiveMessage | undefined;
|
|
85
106
|
|
|
86
107
|
private constructor(private readonly options: DaemonClientOptions) {
|
|
87
108
|
this.paths = sessionPaths(options.workspace, options.sessionId);
|
|
@@ -113,6 +134,8 @@ export class DaemonClient {
|
|
|
113
134
|
kind: string;
|
|
114
135
|
payload: unknown;
|
|
115
136
|
key?: string;
|
|
137
|
+
/** Reuse to retry the same intent; a fresh one is drawn otherwise. */
|
|
138
|
+
seq?: number;
|
|
116
139
|
operations?: unknown[];
|
|
117
140
|
composition?: unknown;
|
|
118
141
|
}): Promise<ApplyResult> {
|
|
@@ -125,6 +148,7 @@ export class DaemonClient {
|
|
|
125
148
|
kind: intent.kind,
|
|
126
149
|
payload: intent.payload,
|
|
127
150
|
};
|
|
151
|
+
if (this.protocol >= 2) message.seq = intent.seq ?? this.nextSeq();
|
|
128
152
|
if (intent.operations) message.operations = intent.operations;
|
|
129
153
|
else message.composition = intent.composition;
|
|
130
154
|
return this.result(await this.request(message));
|
|
@@ -209,6 +233,51 @@ export class DaemonClient {
|
|
|
209
233
|
);
|
|
210
234
|
}
|
|
211
235
|
|
|
236
|
+
/** True when dawgd runs live notes and the click (`live` capability). */
|
|
237
|
+
public get sharedLive(): boolean {
|
|
238
|
+
return this.caps.includes("live");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** This pane's own presence entry, as dawgd last broadcast it. */
|
|
242
|
+
public get self(): PresenceEntry | undefined {
|
|
243
|
+
return this.presence.find((entry) => entry.clientId === this.clientId);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Reports this pane's view fields; resent after a reconnect. */
|
|
247
|
+
public setView(view: PaneView): void {
|
|
248
|
+
this.view = { v: 1, type: "view", ...view };
|
|
249
|
+
if (this.connected) this.socket!.write(encodeFrame(this.view));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Turns this pane's share of the shared live engine on or off. */
|
|
253
|
+
public async liveMonitor(on: boolean): Promise<LiveStatus> {
|
|
254
|
+
this.monitoring = on;
|
|
255
|
+
const reply = await this.request({
|
|
256
|
+
v: 1,
|
|
257
|
+
type: "live",
|
|
258
|
+
action: "monitor",
|
|
259
|
+
id: randomUUID(),
|
|
260
|
+
on,
|
|
261
|
+
});
|
|
262
|
+
if (reply.type !== "liveStatus")
|
|
263
|
+
throw new Error("dawgd did not answer live monitor");
|
|
264
|
+
const { v: _v, type: _type, id: _id, ...status } = reply;
|
|
265
|
+
return status;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Fire-and-forget live note or click; the click is resent on reconnect. */
|
|
269
|
+
public live(message: Exclude<LiveMessage, { action: "monitor" }>): void {
|
|
270
|
+
if (message.action === "click")
|
|
271
|
+
this.click = message.on || message.countIn ? message : undefined;
|
|
272
|
+
if (this.connected) this.socket!.write(encodeFrame(message));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The next per-(actor, client) intent number. */
|
|
276
|
+
public nextSeq(): number {
|
|
277
|
+
this.seq += 1;
|
|
278
|
+
return this.seq;
|
|
279
|
+
}
|
|
280
|
+
|
|
212
281
|
public close(): void {
|
|
213
282
|
this.closed = true;
|
|
214
283
|
for (const [id, pending] of this.pending) {
|
|
@@ -334,7 +403,21 @@ export class DaemonClient {
|
|
|
334
403
|
socket.destroy();
|
|
335
404
|
this.onClose(socket);
|
|
336
405
|
});
|
|
337
|
-
// Resend everything that was unanswered on the previous socket
|
|
406
|
+
// Resend everything that was unanswered on the previous socket,
|
|
407
|
+
// after this pane's view and live state (a respawned dawgd knows
|
|
408
|
+
// neither).
|
|
409
|
+
if (this.view) socket.write(encodeFrame(this.view));
|
|
410
|
+
if (this.monitoring)
|
|
411
|
+
socket.write(
|
|
412
|
+
encodeFrame({
|
|
413
|
+
v: 1,
|
|
414
|
+
type: "live",
|
|
415
|
+
action: "monitor",
|
|
416
|
+
id: randomUUID(),
|
|
417
|
+
on: true,
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
420
|
+
if (this.click) socket.write(encodeFrame(this.click));
|
|
338
421
|
for (const pending of this.pending.values())
|
|
339
422
|
socket.write(pending.frame);
|
|
340
423
|
resolve();
|
|
@@ -348,6 +431,17 @@ export class DaemonClient {
|
|
|
348
431
|
label: (this.options.label ?? "dawg").slice(0, 128),
|
|
349
432
|
clientId: this.clientId,
|
|
350
433
|
focusedTrackId: this.focusedTrackId,
|
|
434
|
+
...(this.options.versions ?? {
|
|
435
|
+
vMin: PROTOCOL_MIN,
|
|
436
|
+
vMax: PROTOCOL_MAX,
|
|
437
|
+
}),
|
|
438
|
+
caps: ["actor", "seq", "quantum", "panes", "live"],
|
|
439
|
+
...(this.options.actor
|
|
440
|
+
? {
|
|
441
|
+
actorId: this.options.actor.id,
|
|
442
|
+
actorName: this.options.actor.name,
|
|
443
|
+
}
|
|
444
|
+
: {}),
|
|
351
445
|
}),
|
|
352
446
|
);
|
|
353
447
|
});
|
|
@@ -368,7 +462,11 @@ export class DaemonClient {
|
|
|
368
462
|
let message: ServerMessage;
|
|
369
463
|
try {
|
|
370
464
|
message = parseServerMessage(line);
|
|
371
|
-
} catch {
|
|
465
|
+
} catch (error) {
|
|
466
|
+
// A newer daemon's message types are skipped, not fatal; a malformed
|
|
467
|
+
// frame of a known type still drops the connection.
|
|
468
|
+
if (error instanceof ProtocolError && error.code === "unknown-type")
|
|
469
|
+
continue;
|
|
372
470
|
socket.destroy();
|
|
373
471
|
return;
|
|
374
472
|
}
|
|
@@ -379,6 +477,8 @@ export class DaemonClient {
|
|
|
379
477
|
private onMessage(message: ServerMessage): void {
|
|
380
478
|
if (message.type === "welcome") {
|
|
381
479
|
this.daemonPid = message.pid;
|
|
480
|
+
this.protocol = message.protocol ?? 1;
|
|
481
|
+
this.caps = message.caps ?? [];
|
|
382
482
|
this.record = message.record;
|
|
383
483
|
this.digest = message.digest;
|
|
384
484
|
this.transport = message.transport;
|
|
@@ -443,7 +543,11 @@ export class DaemonClient {
|
|
|
443
543
|
this.emit({
|
|
444
544
|
type: "status",
|
|
445
545
|
connected: this.connected,
|
|
446
|
-
|
|
546
|
+
// Audio notices (a lost output, a dying player) carry their words.
|
|
547
|
+
message:
|
|
548
|
+
message.code === "audio"
|
|
549
|
+
? `audio · ${message.message}`
|
|
550
|
+
: `dawgd error · ${message.code}`,
|
|
447
551
|
});
|
|
448
552
|
return;
|
|
449
553
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-pane range clipboard on disk (op1-ux §6.3): it lives in `.dawg`
|
|
3
|
+
* next to the session, one file per session and pane letter, so it survives
|
|
4
|
+
* a restart of that pane and is never in the score file.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import type { RangeClipboard } from "../commands/range.ts";
|
|
9
|
+
import { stateDir } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
const SAFE = /^[A-Za-z0-9-]{1,64}$/;
|
|
12
|
+
|
|
13
|
+
/** `.dawg/clipboard/<session>-<pane>.json`; `solo` without a pane letter. */
|
|
14
|
+
export function clipboardPath(
|
|
15
|
+
workspace: string,
|
|
16
|
+
sessionId: string,
|
|
17
|
+
pane: string | undefined,
|
|
18
|
+
): string | undefined {
|
|
19
|
+
const who = pane ?? "solo";
|
|
20
|
+
if (!SAFE.test(sessionId) || !SAFE.test(who)) return undefined;
|
|
21
|
+
return join(stateDir(workspace), "clipboard", `${sessionId}-${who}.json`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isBarRange(value: unknown): boolean {
|
|
25
|
+
if (!value || typeof value !== "object") return false;
|
|
26
|
+
const range = value as Record<string, unknown>;
|
|
27
|
+
return (
|
|
28
|
+
Number.isInteger(range.startBar) &&
|
|
29
|
+
Number.isInteger(range.bars) &&
|
|
30
|
+
(range.startBar as number) >= 0 &&
|
|
31
|
+
(range.bars as number) >= 1
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A clipboard read back from disk, or undefined when absent or malformed. */
|
|
36
|
+
export function parseClipboard(text: string): RangeClipboard | undefined {
|
|
37
|
+
try {
|
|
38
|
+
const value = JSON.parse(text) as Record<string, unknown>;
|
|
39
|
+
const clip = value.clip as Record<string, unknown> | undefined;
|
|
40
|
+
if (
|
|
41
|
+
typeof value.source !== "string" ||
|
|
42
|
+
!isBarRange(value.range) ||
|
|
43
|
+
!clip ||
|
|
44
|
+
typeof clip.all !== "boolean" ||
|
|
45
|
+
!Number.isInteger(clip.bars) ||
|
|
46
|
+
!Array.isArray(clip.tracks) ||
|
|
47
|
+
!Array.isArray(clip.barTicks)
|
|
48
|
+
)
|
|
49
|
+
return undefined;
|
|
50
|
+
return value as unknown as RangeClipboard;
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function loadClipboard(
|
|
57
|
+
path: string | undefined,
|
|
58
|
+
): Promise<RangeClipboard | undefined> {
|
|
59
|
+
if (!path) return undefined;
|
|
60
|
+
try {
|
|
61
|
+
return parseClipboard(await readFile(path, "utf8"));
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Writes (or, for undefined, removes) the pane's clipboard. */
|
|
68
|
+
export async function saveClipboard(
|
|
69
|
+
path: string | undefined,
|
|
70
|
+
board: RangeClipboard | undefined,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
if (!path) return;
|
|
73
|
+
if (!board) {
|
|
74
|
+
await rm(path, { force: true });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
await mkdir(dirname(path), { recursive: true });
|
|
78
|
+
await writeFile(path, `${JSON.stringify(board)}\n`);
|
|
79
|
+
}
|
package/src/session/daemon.ts
CHANGED
|
@@ -6,9 +6,17 @@ import {
|
|
|
6
6
|
type ScoreOperation,
|
|
7
7
|
type TrackScore,
|
|
8
8
|
} from "../../core/score.ts";
|
|
9
|
+
import { DiffError, diffScores } from "../../core/diff.ts";
|
|
9
10
|
import { TransportClock, transportMapFor } from "../audio/clock.ts";
|
|
10
11
|
import { AudioEngine } from "../audio/engine.ts";
|
|
12
|
+
import {
|
|
13
|
+
SampleLibrary,
|
|
14
|
+
hasSamplerTracks,
|
|
15
|
+
type SampleBank,
|
|
16
|
+
} from "../audio/samples.ts";
|
|
17
|
+
import { PackStore } from "../audio/packs.ts";
|
|
11
18
|
import { acquireSessionLock } from "./lock.ts";
|
|
19
|
+
import { LiveHost } from "./live-host.ts";
|
|
12
20
|
import {
|
|
13
21
|
compositionAt,
|
|
14
22
|
MAX_REBASE_DISTANCE,
|
|
@@ -22,8 +30,12 @@ import {
|
|
|
22
30
|
encodeFrame,
|
|
23
31
|
LineDecoder,
|
|
24
32
|
MAX_CLIENT_FRAME_BYTES,
|
|
33
|
+
DAEMON_CAPS,
|
|
25
34
|
monotonicEpochMs,
|
|
35
|
+
negotiateProtocol,
|
|
26
36
|
parseClientMessage,
|
|
37
|
+
PROTOCOL_MAX,
|
|
38
|
+
PROTOCOL_MIN,
|
|
27
39
|
ProtocolError,
|
|
28
40
|
type ApplyResult,
|
|
29
41
|
chooseTrack,
|
|
@@ -39,6 +51,7 @@ import {
|
|
|
39
51
|
SessionConflictError,
|
|
40
52
|
sessionPaths,
|
|
41
53
|
updateSessionMeta,
|
|
54
|
+
type EventActor,
|
|
42
55
|
type SessionPaths,
|
|
43
56
|
type SessionRecord,
|
|
44
57
|
} from "./store.ts";
|
|
@@ -56,8 +69,24 @@ type Client = {
|
|
|
56
69
|
decoder: LineDecoder;
|
|
57
70
|
ready: boolean;
|
|
58
71
|
presence?: PresenceEntry;
|
|
72
|
+
/** Negotiated protocol revision (1 for clients that sent no range). */
|
|
73
|
+
protocol?: number;
|
|
59
74
|
};
|
|
60
75
|
|
|
76
|
+
/** The lowest free pane letter (A, B, C in join order, reused when freed). */
|
|
77
|
+
export function freePaneLetter(taken: ReadonlySet<string>): string {
|
|
78
|
+
for (let code = 65; code <= 90; code += 1) {
|
|
79
|
+
const letter = String.fromCharCode(code);
|
|
80
|
+
if (!taken.has(letter)) return letter;
|
|
81
|
+
}
|
|
82
|
+
return "Z";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Retry-dedupe key for an intent: (actor, client, seq). */
|
|
86
|
+
function seqKey(actorId: string | undefined, clientId: string, seq: number) {
|
|
87
|
+
return `${actorId ?? "-"}\u0000${clientId}\u0000${seq}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
61
90
|
export type DaemonOptions = {
|
|
62
91
|
workspace: string;
|
|
63
92
|
sessionId: string;
|
|
@@ -75,8 +104,13 @@ export class DawgDaemon {
|
|
|
75
104
|
private readonly graceMs: number;
|
|
76
105
|
private readonly clients = new Set<Client>();
|
|
77
106
|
private readonly keys = new Map<string, number>();
|
|
107
|
+
/** (actor, client, seq) to the revision it produced; see `seqKey`. */
|
|
108
|
+
private readonly seqs = new Map<string, number>();
|
|
78
109
|
private readonly clock = new TransportClock();
|
|
79
110
|
private readonly audio: AudioEngine;
|
|
111
|
+
private readonly live: LiveHost;
|
|
112
|
+
private samples: SampleBank | undefined;
|
|
113
|
+
private sampleLibrary: SampleLibrary | undefined;
|
|
80
114
|
private record!: SessionRecord<Composition>;
|
|
81
115
|
private score!: TrackScore;
|
|
82
116
|
private digest = "";
|
|
@@ -111,6 +145,37 @@ export class DawgDaemon {
|
|
|
111
145
|
});
|
|
112
146
|
},
|
|
113
147
|
});
|
|
148
|
+
this.workspace = options.workspace;
|
|
149
|
+
this.live = new LiveHost({
|
|
150
|
+
sink: this.audio,
|
|
151
|
+
score: () => this.score,
|
|
152
|
+
samples: () => this.samples,
|
|
153
|
+
transportBeatAt: (ms) =>
|
|
154
|
+
this.clock.playing ? this.clock.beatAt(ms) : undefined,
|
|
155
|
+
toMonotonic: (epochMs) => epochMs - performance.timeOrigin,
|
|
156
|
+
log: (line) => void this.log(line),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private readonly workspace: string;
|
|
161
|
+
|
|
162
|
+
/** Notes each client played through the shared engine (tests read it). */
|
|
163
|
+
public get livePlayed(): ReadonlyMap<string, number> {
|
|
164
|
+
return this.live.played;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Decodes sampler voices for live notes, once per score that needs them. */
|
|
168
|
+
private async loadSamples(): Promise<void> {
|
|
169
|
+
if (!hasSamplerTracks(this.score)) return;
|
|
170
|
+
this.sampleLibrary ??= new SampleLibrary({
|
|
171
|
+
projectRoot: this.workspace,
|
|
172
|
+
packs: new PackStore(),
|
|
173
|
+
});
|
|
174
|
+
try {
|
|
175
|
+
this.samples = await this.sampleLibrary.load(this.score);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
await this.log(`live samples: ${String(error)}`);
|
|
178
|
+
}
|
|
114
179
|
}
|
|
115
180
|
|
|
116
181
|
/** Returns false when another live daemon already owns this session. */
|
|
@@ -229,7 +294,10 @@ export class DawgDaemon {
|
|
|
229
294
|
const drop = () => {
|
|
230
295
|
if (!this.clients.delete(client)) return;
|
|
231
296
|
socket.destroy();
|
|
232
|
-
if (client.presence)
|
|
297
|
+
if (client.presence) {
|
|
298
|
+
void this.live.drop(client.presence.clientId);
|
|
299
|
+
this.broadcastPresence();
|
|
300
|
+
}
|
|
233
301
|
if (this.clients.size === 0) this.armGrace();
|
|
234
302
|
};
|
|
235
303
|
socket.on("end", drop);
|
|
@@ -246,13 +314,31 @@ export class DawgDaemon {
|
|
|
246
314
|
|
|
247
315
|
private handle(client: Client, message: ClientMessage): void {
|
|
248
316
|
if (message.type === "hello") {
|
|
317
|
+
const protocol = negotiateProtocol(message.vMin, message.vMax);
|
|
318
|
+
if (protocol === undefined) {
|
|
319
|
+
this.fail(
|
|
320
|
+
client,
|
|
321
|
+
new ProtocolError(
|
|
322
|
+
"version",
|
|
323
|
+
`dawgd speaks protocol ${PROTOCOL_MIN}-${PROTOCOL_MAX}`,
|
|
324
|
+
),
|
|
325
|
+
);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
249
328
|
client.ready = true;
|
|
329
|
+
client.protocol = protocol;
|
|
330
|
+
const letters = new Set<string>();
|
|
331
|
+
for (const other of this.clients)
|
|
332
|
+
if (other !== client && other.presence?.pane)
|
|
333
|
+
letters.add(other.presence.pane);
|
|
250
334
|
client.presence = {
|
|
251
335
|
clientId: message.clientId,
|
|
252
336
|
pid: message.pid,
|
|
253
337
|
label: message.label,
|
|
254
338
|
focusedTrackId: message.focusedTrackId,
|
|
339
|
+
pane: freePaneLetter(letters),
|
|
255
340
|
};
|
|
341
|
+
if (message.actorId) client.presence.actorId = message.actorId;
|
|
256
342
|
this.send(client, {
|
|
257
343
|
v: 1,
|
|
258
344
|
type: "welcome",
|
|
@@ -261,6 +347,8 @@ export class DawgDaemon {
|
|
|
261
347
|
record: this.record,
|
|
262
348
|
digest: this.digest,
|
|
263
349
|
transport: this.transportState(),
|
|
350
|
+
protocol,
|
|
351
|
+
caps: [...DAEMON_CAPS],
|
|
264
352
|
});
|
|
265
353
|
this.broadcastPresence();
|
|
266
354
|
return;
|
|
@@ -379,6 +467,39 @@ export class DawgDaemon {
|
|
|
379
467
|
});
|
|
380
468
|
return;
|
|
381
469
|
}
|
|
470
|
+
if (message.type === "view") {
|
|
471
|
+
const presence = client.presence!;
|
|
472
|
+
const { v: _v, type: _type, ...view } = message;
|
|
473
|
+
for (const key of [
|
|
474
|
+
"screen",
|
|
475
|
+
"param",
|
|
476
|
+
"recording",
|
|
477
|
+
"playing",
|
|
478
|
+
"pinned",
|
|
479
|
+
"follow",
|
|
480
|
+
] as const)
|
|
481
|
+
delete presence[key];
|
|
482
|
+
Object.assign(presence, view);
|
|
483
|
+
this.broadcastPresence();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (message.type === "live") {
|
|
487
|
+
const clientId = client.presence!.clientId;
|
|
488
|
+
if (message.action === "monitor")
|
|
489
|
+
void (async () => {
|
|
490
|
+
if (message.on) await this.loadSamples();
|
|
491
|
+
const status = await this.live.handle(clientId, message);
|
|
492
|
+
if (status)
|
|
493
|
+
this.send(client, {
|
|
494
|
+
v: 1,
|
|
495
|
+
type: "liveStatus",
|
|
496
|
+
id: message.id,
|
|
497
|
+
...status,
|
|
498
|
+
});
|
|
499
|
+
})();
|
|
500
|
+
else void this.live.handle(clientId, message);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
382
503
|
if (message.type === "transport") {
|
|
383
504
|
this.enqueue(async () => {
|
|
384
505
|
this.transport(message.action, message.beat, message.bpm);
|
|
@@ -393,7 +514,7 @@ export class DawgDaemon {
|
|
|
393
514
|
return;
|
|
394
515
|
}
|
|
395
516
|
this.enqueue(async () => {
|
|
396
|
-
const result = await this.apply(message);
|
|
517
|
+
const result = await this.apply(message, client);
|
|
397
518
|
this.send(client, { v: 1, type: "result", id: message.id, ...result });
|
|
398
519
|
});
|
|
399
520
|
}
|
|
@@ -407,15 +528,35 @@ export class DawgDaemon {
|
|
|
407
528
|
|
|
408
529
|
private async apply(
|
|
409
530
|
message: Extract<ClientMessage, { type: "apply" }>,
|
|
531
|
+
client?: Client,
|
|
410
532
|
): Promise<ApplyResult> {
|
|
411
533
|
const seen = this.keys.get(message.key);
|
|
412
534
|
if (seen !== undefined) return { status: "duplicate", revision: seen };
|
|
535
|
+
// The authority stamps who made the event from the connection, never
|
|
536
|
+
// from the intent body.
|
|
537
|
+
const actor: EventActor | undefined = client?.presence
|
|
538
|
+
? {
|
|
539
|
+
clientId: client.presence.clientId,
|
|
540
|
+
...(client.presence.actorId
|
|
541
|
+
? { actorId: client.presence.actorId }
|
|
542
|
+
: {}),
|
|
543
|
+
...(message.seq !== undefined ? { seq: message.seq } : {}),
|
|
544
|
+
}
|
|
545
|
+
: undefined;
|
|
546
|
+
const retryKey =
|
|
547
|
+
actor?.seq !== undefined
|
|
548
|
+
? seqKey(actor.actorId, actor.clientId, actor.seq)
|
|
549
|
+
: undefined;
|
|
550
|
+
const retried = retryKey ? this.seqs.get(retryKey) : undefined;
|
|
551
|
+
if (retried !== undefined)
|
|
552
|
+
return { status: "duplicate", revision: retried };
|
|
553
|
+
let operations: ScoreOperation[] | undefined;
|
|
413
554
|
const stale = message.base !== this.record.revision;
|
|
414
555
|
let rebased = false;
|
|
415
556
|
let next: TrackScore;
|
|
416
557
|
try {
|
|
417
558
|
if (message.operations) {
|
|
418
|
-
|
|
559
|
+
operations = message.operations.map(parseOperation);
|
|
419
560
|
if (stale) {
|
|
420
561
|
// Operation intents from an older base replay on the current score
|
|
421
562
|
// when nothing they touch changed in between.
|
|
@@ -435,6 +576,13 @@ export class DawgDaemon {
|
|
|
435
576
|
else next = scoreFromJSON(message.composition);
|
|
436
577
|
// Round-trip through the parser so only canonical score data is stored.
|
|
437
578
|
next = scoreFromJSON(next.toJSON());
|
|
579
|
+
// Composition intents still log as ops, so the log replays as ops.
|
|
580
|
+
operations ??= derivedOperations(
|
|
581
|
+
this.score,
|
|
582
|
+
next,
|
|
583
|
+
MAX_DERIVED_OPS_BYTES -
|
|
584
|
+
Buffer.byteLength(JSON.stringify(message.payload ?? null), "utf8"),
|
|
585
|
+
);
|
|
438
586
|
} catch (error) {
|
|
439
587
|
return {
|
|
440
588
|
status: "rejected",
|
|
@@ -459,7 +607,13 @@ export class DawgDaemon {
|
|
|
459
607
|
this.record = await appendSessionEvent(
|
|
460
608
|
this.paths,
|
|
461
609
|
this.record,
|
|
462
|
-
{
|
|
610
|
+
{
|
|
611
|
+
kind: message.kind,
|
|
612
|
+
payload,
|
|
613
|
+
id: message.key,
|
|
614
|
+
...(actor ? { actor } : {}),
|
|
615
|
+
...(operations ? { ops: operations } : {}),
|
|
616
|
+
},
|
|
463
617
|
next.toJSON(),
|
|
464
618
|
);
|
|
465
619
|
} catch (error) {
|
|
@@ -488,6 +642,7 @@ export class DawgDaemon {
|
|
|
488
642
|
this.score = next;
|
|
489
643
|
this.digest = compositionDigest(this.record.composition);
|
|
490
644
|
this.keys.set(message.key, this.record.revision);
|
|
645
|
+
if (retryKey) this.seqs.set(retryKey, this.record.revision);
|
|
491
646
|
this.diskMtime = await this.recordMtime();
|
|
492
647
|
const event = this.record.events[this.record.events.length - 1]!;
|
|
493
648
|
this.broadcast({
|
|
@@ -578,6 +733,7 @@ export class DawgDaemon {
|
|
|
578
733
|
beat: this.clock.beatAt(),
|
|
579
734
|
bpm: this.score.tempoBpm,
|
|
580
735
|
atMs,
|
|
736
|
+
quantum: this.score.beatsPerBar,
|
|
581
737
|
};
|
|
582
738
|
}
|
|
583
739
|
|
|
@@ -621,8 +777,15 @@ export class DawgDaemon {
|
|
|
621
777
|
this.score = scoreFromJSON(this.record.composition);
|
|
622
778
|
this.digest = compositionDigest(this.record.composition);
|
|
623
779
|
this.keys.clear();
|
|
624
|
-
|
|
780
|
+
this.seqs.clear();
|
|
781
|
+
for (const event of this.record.events) {
|
|
625
782
|
this.keys.set(event.id, event.revision);
|
|
783
|
+
if (event.actor?.seq !== undefined)
|
|
784
|
+
this.seqs.set(
|
|
785
|
+
seqKey(event.actor.actorId, event.actor.clientId, event.actor.seq),
|
|
786
|
+
event.revision,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
626
789
|
this.diskMtime = await this.recordMtime();
|
|
627
790
|
}
|
|
628
791
|
|
|
@@ -704,10 +867,41 @@ const OPERATION_TYPES = new Set([
|
|
|
704
867
|
"setTime",
|
|
705
868
|
"setTuning",
|
|
706
869
|
"setMaster",
|
|
870
|
+
"setCalibration",
|
|
871
|
+
"setStyle",
|
|
707
872
|
"setSections",
|
|
873
|
+
"setLoop",
|
|
874
|
+
"setClips",
|
|
708
875
|
]);
|
|
709
876
|
|
|
710
877
|
/** Shape gate before the reducer, which validates every field it reads. */
|
|
878
|
+
/**
|
|
879
|
+
* Serialized budget for payload plus derived ops; the store bounds the two
|
|
880
|
+
* together at 64 KiB per event.
|
|
881
|
+
*/
|
|
882
|
+
const MAX_DERIVED_OPS_BYTES = 60 * 1024;
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* The operations turning `before` into `after`, or undefined (a snapshot
|
|
886
|
+
* event) when the change has no op form or is too large to log as ops.
|
|
887
|
+
*/
|
|
888
|
+
export function derivedOperations(
|
|
889
|
+
before: TrackScore,
|
|
890
|
+
after: TrackScore,
|
|
891
|
+
budgetBytes = MAX_DERIVED_OPS_BYTES,
|
|
892
|
+
): ScoreOperation[] | undefined {
|
|
893
|
+
let ops: readonly ScoreOperation[];
|
|
894
|
+
try {
|
|
895
|
+
ops = diffScores(before, after);
|
|
896
|
+
} catch (error) {
|
|
897
|
+
if (error instanceof DiffError) return undefined;
|
|
898
|
+
throw error;
|
|
899
|
+
}
|
|
900
|
+
if (Buffer.byteLength(JSON.stringify(ops), "utf8") > budgetBytes)
|
|
901
|
+
return undefined;
|
|
902
|
+
return [...ops];
|
|
903
|
+
}
|
|
904
|
+
|
|
711
905
|
function parseOperation(value: unknown): ScoreOperation {
|
|
712
906
|
if (
|
|
713
907
|
typeof value !== "object" ||
|