@hraness/dawg 0.6.1 → 0.7.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 +92 -0
- package/DAWG.md +343 -116
- package/README.md +27 -25
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +182 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +5 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +832 -13
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +276 -3
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +1849 -42
- package/core/sections.ts +292 -26
- 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 +30 -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 +12 -6
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +25 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +17 -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/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 +5 -2
- package/src/agent/agent.ts +126 -14
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +369 -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 +12 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +13 -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/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 +108 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +11 -7
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +37 -3
- 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/doctor.ts +86 -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 +160 -28
- 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 +44 -29
- 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 +558 -0
- package/src/commands/help.ts +610 -380
- package/src/commands/history.ts +18 -0
- package/src/commands/keys.ts +8 -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 +130 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sing.ts +478 -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 +616 -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/lang/glossary.ts +493 -0
- package/src/launch-args.ts +163 -0
- package/src/main.ts +1397 -253
- 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/daemon.ts +3 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +154 -0
- package/src/session/port.ts +22 -4
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +5 -1
- package/src/session/rebase.ts +18 -5
- package/src/session/receipt.ts +258 -0
- package/src/session/store.ts +65 -43
- package/src/tui/arrange-menu.ts +65 -31
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +18 -13
- package/src/tui/fader.ts +228 -41
- package/src/tui/granular-menu.ts +2 -4
- 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 +759 -177
- 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 +47 -7
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -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 +274 -40
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +144 -0
- package/tui/drawer.ts +70 -22
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +112 -63
- package/tui/guide.ts +42 -4
- package/tui/highway.ts +269 -25
- package/tui/hints.ts +192 -0
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/play-strip.ts +68 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +144 -14
- package/tui/theme.ts +27 -0
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/daemon.ts
CHANGED
|
@@ -704,7 +704,10 @@ const OPERATION_TYPES = new Set([
|
|
|
704
704
|
"setTime",
|
|
705
705
|
"setTuning",
|
|
706
706
|
"setMaster",
|
|
707
|
+
"setCalibration",
|
|
708
|
+
"setStyle",
|
|
707
709
|
"setSections",
|
|
710
|
+
"setClips",
|
|
708
711
|
]);
|
|
709
712
|
|
|
710
713
|
/** Shape gate before the reducer, which validates every field it reads. */
|
package/src/session/meta.ts
CHANGED
|
@@ -30,6 +30,11 @@ export type SessionMeta = {
|
|
|
30
30
|
namedFingerprint?: string;
|
|
31
31
|
/** Track-and-instrument-set hash at the last auto name. */
|
|
32
32
|
namedStructure?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Optional (0.7): the song's loop has wrapped once, so the one-shot
|
|
35
|
+
* `↻ first loop` moment never repeats. Session state, never the score.
|
|
36
|
+
*/
|
|
37
|
+
heardLoop?: true;
|
|
33
38
|
};
|
|
34
39
|
|
|
35
40
|
/** Fields a client may change. `null` clears an optional field. */
|
|
@@ -38,6 +43,8 @@ export type MetaPatch = {
|
|
|
38
43
|
nameSource?: NameSource;
|
|
39
44
|
namedFingerprint?: string | null;
|
|
40
45
|
namedStructure?: string | null;
|
|
46
|
+
/** Only ever set; the first loop is heard once. */
|
|
47
|
+
heardLoop?: true;
|
|
41
48
|
};
|
|
42
49
|
|
|
43
50
|
/** A conditional write applies only when every given field still matches. */
|
|
@@ -125,6 +132,7 @@ export function parseSessionMeta(
|
|
|
125
132
|
if (fingerprint) result.namedFingerprint = fingerprint;
|
|
126
133
|
const structure = optionalFingerprint(meta.namedStructure);
|
|
127
134
|
if (structure) result.namedStructure = structure;
|
|
135
|
+
if (meta.heardLoop === true) result.heardLoop = true;
|
|
128
136
|
return result;
|
|
129
137
|
}
|
|
130
138
|
|
|
@@ -144,6 +152,11 @@ export function parseMetaPatch(value: unknown): MetaPatch {
|
|
|
144
152
|
if (raw[key] === undefined) continue;
|
|
145
153
|
patch[key] = raw[key] === null ? null : requireFingerprint(raw[key]);
|
|
146
154
|
}
|
|
155
|
+
if (raw.heardLoop !== undefined) {
|
|
156
|
+
if (raw.heardLoop !== true)
|
|
157
|
+
throw new MetaValidationError("heardLoop is invalid");
|
|
158
|
+
patch.heardLoop = true;
|
|
159
|
+
}
|
|
147
160
|
if (Object.keys(patch).length === 0)
|
|
148
161
|
throw new MetaValidationError("meta patch is empty");
|
|
149
162
|
return patch;
|
|
@@ -184,6 +197,7 @@ export function applyMetaPatch(
|
|
|
184
197
|
};
|
|
185
198
|
if (patch.name !== undefined) next.name = patch.name;
|
|
186
199
|
if (patch.nameSource !== undefined) next.nameSource = patch.nameSource;
|
|
200
|
+
if (patch.heardLoop) next.heardLoop = true;
|
|
187
201
|
for (const key of ["namedFingerprint", "namedStructure"] as const) {
|
|
188
202
|
if (patch[key] === null) delete next[key];
|
|
189
203
|
else if (patch[key] !== undefined) next[key] = patch[key];
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { WRITTEN_REVISION, type SessionPort } from "./port.ts";
|
|
2
|
+
import type { SessionEvent, SessionRecord } from "./store.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Remembers the events this window wrote, so the "synced" card shows only
|
|
6
|
+
* for revisions that came from another client (design C12). Session events
|
|
7
|
+
* carry no writer id, so the tracker records the id of the event each of
|
|
8
|
+
* this window's appends produced, and holds a decision while a write is in
|
|
9
|
+
* flight (a watcher update can land before the append resolves).
|
|
10
|
+
*/
|
|
11
|
+
export class OwnWrites {
|
|
12
|
+
private readonly ids = new Set<string>();
|
|
13
|
+
private inflight = 0;
|
|
14
|
+
private waiters: (() => void)[] = [];
|
|
15
|
+
|
|
16
|
+
/** Wraps one append: records the event it produced. */
|
|
17
|
+
public async track<T>(
|
|
18
|
+
write: Promise<SessionRecord<T>>,
|
|
19
|
+
): Promise<SessionRecord<T>> {
|
|
20
|
+
this.inflight++;
|
|
21
|
+
try {
|
|
22
|
+
const record = await write;
|
|
23
|
+
const revision = WRITTEN_REVISION.get(record);
|
|
24
|
+
const own =
|
|
25
|
+
revision === undefined
|
|
26
|
+
? record.events[record.events.length - 1]
|
|
27
|
+
: record.events.find((event) => event.revision === revision);
|
|
28
|
+
if (own) this.remember(own.id);
|
|
29
|
+
return record;
|
|
30
|
+
} finally {
|
|
31
|
+
this.inflight--;
|
|
32
|
+
if (this.inflight === 0) {
|
|
33
|
+
const waiters = this.waiters;
|
|
34
|
+
this.waiters = [];
|
|
35
|
+
for (const wake of waiters) wake();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public remember(id: string): void {
|
|
41
|
+
this.ids.add(id);
|
|
42
|
+
// Bounded: only recent ids matter for the next watcher update.
|
|
43
|
+
if (this.ids.size > 512) {
|
|
44
|
+
const oldest = this.ids.values().next().value;
|
|
45
|
+
if (oldest !== undefined) this.ids.delete(oldest);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public isOwn(id: string): boolean {
|
|
50
|
+
return this.ids.has(id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolves once no append from this window is in flight. */
|
|
54
|
+
public settled(): Promise<void> {
|
|
55
|
+
if (this.inflight === 0) return Promise.resolve();
|
|
56
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Patches a port's append methods in place so every write it commits is
|
|
61
|
+
* tracked. Idempotent per port.
|
|
62
|
+
*/
|
|
63
|
+
public attach<T>(port: SessionPort<T>): void {
|
|
64
|
+
const marked = port as SessionPort<T> & { [TRACKED]?: true };
|
|
65
|
+
if (marked[TRACKED]) return;
|
|
66
|
+
marked[TRACKED] = true;
|
|
67
|
+
const append = port.append.bind(port);
|
|
68
|
+
const appendOperations = port.appendOperations.bind(port);
|
|
69
|
+
port.append = (...args) => this.track(append(...args));
|
|
70
|
+
port.appendOperations = (...args) => this.track(appendOperations(...args));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const TRACKED = Symbol("dawg.ownWrites");
|
|
75
|
+
|
|
76
|
+
/** Events in `latest` past `fromRevision` that this window did not write. */
|
|
77
|
+
export function foreignEvents(
|
|
78
|
+
latest: Pick<SessionRecord<unknown>, "events">,
|
|
79
|
+
fromRevision: number,
|
|
80
|
+
own: Pick<OwnWrites, "isOwn">,
|
|
81
|
+
): SessionEvent[] {
|
|
82
|
+
return latest.events.filter(
|
|
83
|
+
(event) => event.revision > fromRevision && !own.isOwn(event.id),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Names the other window for the sync card by the track it has open, the
|
|
89
|
+
* word that window's own header shows (`synced · lead window`), never by a
|
|
90
|
+
* number taken from the presence list's order. With several other windows
|
|
91
|
+
* the one whose track the edit touched is named; before presence arrives
|
|
92
|
+
* the edited track names it. Otherwise `another window`.
|
|
93
|
+
*/
|
|
94
|
+
export function otherWindowName(
|
|
95
|
+
clients: readonly { clientId: string; focusedTrackId?: string | null }[],
|
|
96
|
+
selfId: string,
|
|
97
|
+
options: {
|
|
98
|
+
/** The one track the foreign revision changed, when exactly one. */
|
|
99
|
+
editedTrackId?: string | undefined;
|
|
100
|
+
/** Display name for a track id. */
|
|
101
|
+
trackName?: (trackId: string) => string;
|
|
102
|
+
} = {},
|
|
103
|
+
): string {
|
|
104
|
+
const name = options.trackName ?? ((trackId: string) => trackId);
|
|
105
|
+
const others = clients.filter(
|
|
106
|
+
(client) => client.clientId !== selfId && client.focusedTrackId,
|
|
107
|
+
);
|
|
108
|
+
const edited = options.editedTrackId;
|
|
109
|
+
const owner =
|
|
110
|
+
(edited && others.find((client) => client.focusedTrackId === edited)) ||
|
|
111
|
+
(others.length === 1 ? others[0] : undefined);
|
|
112
|
+
if (owner?.focusedTrackId) return `${name(owner.focusedTrackId)} window`;
|
|
113
|
+
if (edited) return `${name(edited)} window`;
|
|
114
|
+
return "another window";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The single track whose notes or settings differ, or undefined. */
|
|
118
|
+
export function editedTrack(
|
|
119
|
+
before: {
|
|
120
|
+
tracks: readonly { id: string }[];
|
|
121
|
+
notes: readonly { trackId: string }[];
|
|
122
|
+
},
|
|
123
|
+
after: {
|
|
124
|
+
tracks: readonly { id: string }[];
|
|
125
|
+
notes: readonly { trackId: string }[];
|
|
126
|
+
},
|
|
127
|
+
): string | undefined {
|
|
128
|
+
const changed = new Set<string>();
|
|
129
|
+
const byTrack = (value: typeof before) => {
|
|
130
|
+
const map = new Map<string, string[]>();
|
|
131
|
+
for (const note of value.notes) {
|
|
132
|
+
const list = map.get(note.trackId) ?? [];
|
|
133
|
+
list.push(JSON.stringify(note));
|
|
134
|
+
map.set(note.trackId, list);
|
|
135
|
+
}
|
|
136
|
+
return map;
|
|
137
|
+
};
|
|
138
|
+
const left = byTrack(before);
|
|
139
|
+
const right = byTrack(after);
|
|
140
|
+
const ids = new Set([
|
|
141
|
+
...before.tracks.map((track) => track.id),
|
|
142
|
+
...after.tracks.map((track) => track.id),
|
|
143
|
+
]);
|
|
144
|
+
for (const id of ids) {
|
|
145
|
+
const a = before.tracks.find((track) => track.id === id);
|
|
146
|
+
const b = after.tracks.find((track) => track.id === id);
|
|
147
|
+
if (
|
|
148
|
+
JSON.stringify(a) !== JSON.stringify(b) ||
|
|
149
|
+
(left.get(id) ?? []).join("\n") !== (right.get(id) ?? []).join("\n")
|
|
150
|
+
)
|
|
151
|
+
changed.add(id);
|
|
152
|
+
}
|
|
153
|
+
return changed.size === 1 ? [...changed][0] : undefined;
|
|
154
|
+
}
|
package/src/session/port.ts
CHANGED
|
@@ -129,6 +129,11 @@ export type OpenPortOptions = {
|
|
|
129
129
|
/** Set false for one-shot commands (demo, export) that must not spawn dawgd. */
|
|
130
130
|
daemon?: boolean;
|
|
131
131
|
daemonArgs?: string[];
|
|
132
|
+
/**
|
|
133
|
+
* File sessions: the backstop poll while fs.watch is live (default 1 s).
|
|
134
|
+
* Tests raise it to prove a change arrived through the watcher.
|
|
135
|
+
*/
|
|
136
|
+
watchedPollMs?: number;
|
|
132
137
|
};
|
|
133
138
|
|
|
134
139
|
/** Connects to (or starts) dawgd, falling back to the file-lock path. */
|
|
@@ -225,7 +230,11 @@ class DaemonPort<T> implements SessionPort<T> {
|
|
|
225
230
|
}
|
|
226
231
|
if (result.status === "accepted" || result.status === "duplicate") {
|
|
227
232
|
this.setSync("synced");
|
|
228
|
-
|
|
233
|
+
// The client's record can already hold a later write from another
|
|
234
|
+
// window; mark which revision is this one's (OwnWrites reads it).
|
|
235
|
+
const record = { ...this.record() };
|
|
236
|
+
WRITTEN_REVISION.set(record, result.revision);
|
|
237
|
+
return record;
|
|
229
238
|
}
|
|
230
239
|
if (result.status === "rebase") {
|
|
231
240
|
this.setSync("conflict");
|
|
@@ -308,6 +317,12 @@ class DaemonPort<T> implements SessionPort<T> {
|
|
|
308
317
|
}
|
|
309
318
|
}
|
|
310
319
|
|
|
320
|
+
/**
|
|
321
|
+
* The revision an append produced, for records whose last event may be a
|
|
322
|
+
* newer write from elsewhere (a daemon window's record follows the log).
|
|
323
|
+
*/
|
|
324
|
+
export const WRITTEN_REVISION = new WeakMap<object, number>();
|
|
325
|
+
|
|
311
326
|
class FilePort<T> implements SessionPort<T> {
|
|
312
327
|
public readonly mode = "file" as const;
|
|
313
328
|
public readonly clientId = randomUUID();
|
|
@@ -408,10 +423,10 @@ class FilePort<T> implements SessionPort<T> {
|
|
|
408
423
|
};
|
|
409
424
|
const recordName = basename(this.options.paths.record);
|
|
410
425
|
// macOS coalesces the temp write and the rename into events named after
|
|
411
|
-
// the temp file (`<record>.<pid>.tmp`), so match that too, but not the
|
|
426
|
+
// the temp file (`<record>.<pid>.<n>.tmp`), so match that too, but not the
|
|
412
427
|
// sibling `.audio.lock` / `.daemon.log` files that share the prefix.
|
|
413
428
|
const recordFile = new RegExp(
|
|
414
|
-
`^${recordName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\.\\d+\\.tmp)?$`,
|
|
429
|
+
`^${recordName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:(?:\\.\\d+)+\\.tmp)?$`,
|
|
415
430
|
);
|
|
416
431
|
let watcher: FSWatcher | undefined;
|
|
417
432
|
try {
|
|
@@ -429,7 +444,10 @@ class FilePort<T> implements SessionPort<T> {
|
|
|
429
444
|
} catch {
|
|
430
445
|
watcher = undefined;
|
|
431
446
|
}
|
|
432
|
-
const timer = setInterval(
|
|
447
|
+
const timer = setInterval(
|
|
448
|
+
check,
|
|
449
|
+
watcher ? (this.options.watchedPollMs ?? WATCHED_POLL_MS) : POLL_MS,
|
|
450
|
+
);
|
|
433
451
|
this.statusListeners.add(listener);
|
|
434
452
|
check();
|
|
435
453
|
return () => {
|
package/src/session/presence.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
readFile,
|
|
5
5
|
rename,
|
|
6
6
|
rm,
|
|
7
|
+
stat,
|
|
7
8
|
writeFile,
|
|
8
9
|
} from "node:fs/promises";
|
|
9
10
|
import { join } from "node:path";
|
|
@@ -34,6 +35,7 @@ export class FilePresence {
|
|
|
34
35
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
35
36
|
private writing: Promise<void> = Promise.resolve();
|
|
36
37
|
private writes = 0;
|
|
38
|
+
private stopped = false;
|
|
37
39
|
|
|
38
40
|
public constructor(paths: SessionPaths, entry: PresenceEntry) {
|
|
39
41
|
this.dir = `${paths.record}.presence`;
|
|
@@ -42,6 +44,7 @@ export class FilePresence {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
public async start(): Promise<void> {
|
|
47
|
+
this.stopped = false;
|
|
45
48
|
await this.write();
|
|
46
49
|
this.timer = setInterval(
|
|
47
50
|
() => void this.write().catch(() => {}),
|
|
@@ -51,8 +54,12 @@ export class FilePresence {
|
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
public async stop(): Promise<void> {
|
|
57
|
+
// A heartbeat already queued would otherwise rename its file back after
|
|
58
|
+
// the removal and leave a ghost entry.
|
|
59
|
+
this.stopped = true;
|
|
54
60
|
if (this.timer) clearInterval(this.timer);
|
|
55
61
|
this.timer = undefined;
|
|
62
|
+
await this.writing;
|
|
56
63
|
await rm(this.file(), { force: true });
|
|
57
64
|
}
|
|
58
65
|
|
|
@@ -101,14 +108,17 @@ export class FilePresence {
|
|
|
101
108
|
|
|
102
109
|
/** Live entries; stale or dead-process heartbeats are removed. */
|
|
103
110
|
public async list(now = Date.now()): Promise<PresenceEntry[]> {
|
|
104
|
-
let
|
|
111
|
+
let all: string[];
|
|
105
112
|
try {
|
|
106
|
-
|
|
107
|
-
name.endsWith(".json"),
|
|
108
|
-
);
|
|
113
|
+
all = await readdir(this.dir);
|
|
109
114
|
} catch {
|
|
110
115
|
return [];
|
|
111
116
|
}
|
|
117
|
+
// A writer that crashed between write and rename leaves its temp file.
|
|
118
|
+
for (const name of all.slice(0, MAX_PRESENCE_FILES))
|
|
119
|
+
if (name.endsWith(".tmp"))
|
|
120
|
+
await sweepTemporary(join(this.dir, name), now);
|
|
121
|
+
const names = all.filter((name) => name.endsWith(".json"));
|
|
112
122
|
const entries: PresenceEntry[] = [];
|
|
113
123
|
for (const name of names.slice(0, MAX_PRESENCE_FILES)) {
|
|
114
124
|
const path = join(this.dir, name);
|
|
@@ -147,6 +157,7 @@ export class FilePresence {
|
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
private async writeNow(): Promise<void> {
|
|
160
|
+
if (this.stopped) return;
|
|
150
161
|
await mkdir(this.dir, { recursive: true });
|
|
151
162
|
const temporary = `${this.file()}.${process.pid}.${++this.writes}.tmp`;
|
|
152
163
|
await writeFile(
|
|
@@ -154,10 +165,29 @@ export class FilePresence {
|
|
|
154
165
|
JSON.stringify({ ...this.entry, at: Date.now() }),
|
|
155
166
|
"utf8",
|
|
156
167
|
);
|
|
168
|
+
if (this.stopped) {
|
|
169
|
+
await rm(temporary, { force: true });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
157
172
|
await rename(temporary, this.file());
|
|
158
173
|
}
|
|
159
174
|
}
|
|
160
175
|
|
|
176
|
+
/** Removes a temp file left by a dead writer, or one older than the stale window. */
|
|
177
|
+
async function sweepTemporary(path: string, now: number): Promise<void> {
|
|
178
|
+
const pid = Number(/\.(\d+)\.\d+\.tmp$/.exec(path)?.[1]);
|
|
179
|
+
try {
|
|
180
|
+
const { mtimeMs } = await stat(path);
|
|
181
|
+
if (
|
|
182
|
+
now - mtimeMs > PRESENCE_STALE_MS ||
|
|
183
|
+
(Number.isSafeInteger(pid) && pid > 0 && !alive(pid))
|
|
184
|
+
)
|
|
185
|
+
await rm(path, { force: true });
|
|
186
|
+
} catch {
|
|
187
|
+
// Already renamed or removed by its writer.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
161
191
|
function alive(pid: number): boolean {
|
|
162
192
|
try {
|
|
163
193
|
process.kill(pid, 0);
|
package/src/session/protocol.ts
CHANGED
|
@@ -551,7 +551,11 @@ function requireRecord(value: unknown): SessionRecord<unknown> {
|
|
|
551
551
|
!Number.isSafeInteger(record.revision) ||
|
|
552
552
|
typeof record.updatedAt !== "string" ||
|
|
553
553
|
!Array.isArray(record.events) ||
|
|
554
|
-
record.
|
|
554
|
+
(record.folded !== undefined &&
|
|
555
|
+
(!Number.isSafeInteger(record.folded) ||
|
|
556
|
+
(record.folded as number) < 1)) ||
|
|
557
|
+
record.events.length + ((record.folded as number | undefined) ?? 0) !==
|
|
558
|
+
record.revision ||
|
|
555
559
|
record.composition === undefined
|
|
556
560
|
)
|
|
557
561
|
throw new ProtocolError("invalid", "record is invalid");
|
package/src/session/rebase.ts
CHANGED
|
@@ -84,6 +84,7 @@ export function rebaseOperations(
|
|
|
84
84
|
break;
|
|
85
85
|
}
|
|
86
86
|
case "updateTrack":
|
|
87
|
+
case "setClips":
|
|
87
88
|
case "setAutomation": {
|
|
88
89
|
if (added.tracks.has(operation.trackId)) break;
|
|
89
90
|
if (!settingsUnchanged(operation.trackId))
|
|
@@ -123,10 +124,18 @@ export function rebaseOperations(
|
|
|
123
124
|
if (!same(base.tuning ?? null, current.tuning ?? null))
|
|
124
125
|
return { ok: false, reason: "tuning changed" };
|
|
125
126
|
break;
|
|
127
|
+
case "setCalibration":
|
|
128
|
+
if ((base.calibration ?? 0) !== (current.calibration ?? 0))
|
|
129
|
+
return { ok: false, reason: "calibration changed" };
|
|
130
|
+
break;
|
|
126
131
|
case "setMaster":
|
|
127
132
|
if (!deepEqual(base.master, current.master))
|
|
128
133
|
return { ok: false, reason: "master changed" };
|
|
129
134
|
break;
|
|
135
|
+
case "setStyle":
|
|
136
|
+
if (!deepEqual(base.style, current.style))
|
|
137
|
+
return { ok: false, reason: "style changed" };
|
|
138
|
+
break;
|
|
130
139
|
case "setSections":
|
|
131
140
|
if (
|
|
132
141
|
JSON.stringify(base.sections) !== JSON.stringify(current.sections) ||
|
|
@@ -177,13 +186,17 @@ export function compositionAt(
|
|
|
177
186
|
record: {
|
|
178
187
|
composition: unknown;
|
|
179
188
|
events: readonly { revision: number; rewind?: Rewind | undefined }[];
|
|
189
|
+
/** Events folded out of the front of the log (see `SessionRecord`). */
|
|
190
|
+
folded?: number | undefined;
|
|
180
191
|
},
|
|
181
192
|
revision: number,
|
|
182
193
|
): unknown {
|
|
183
194
|
const { events } = record;
|
|
184
|
-
|
|
185
|
-
if (revision
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
195
|
+
const base = record.folded ?? 0;
|
|
196
|
+
if (!Number.isSafeInteger(revision) || revision < base) return undefined;
|
|
197
|
+
const start = revision - base;
|
|
198
|
+
if (start >= events.length) return undefined;
|
|
199
|
+
for (let index = start; index < events.length; index += 1)
|
|
200
|
+
if (events[index]!.revision !== base + index + 1) return undefined;
|
|
201
|
+
return rewindComposition(record.composition, events, start);
|
|
189
202
|
}
|