@hraness/dawg 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -0
- package/DAWG.md +454 -140
- package/README.md +30 -26
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +184 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/ids.ts +52 -0
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +8 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/range.ts +581 -0
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +955 -14
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +287 -5
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +2132 -46
- package/core/sections.ts +480 -35
- package/core/sing.ts +815 -0
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +31 -0
- package/guides/audio.md +31 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -7
- package/guides/chords.md +15 -15
- package/guides/effects.md +17 -16
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +15 -7
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/panes.md +32 -0
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +26 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +18 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -13
- package/guides/styles.md +31 -0
- package/guides/tape.md +32 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +6 -2
- package/src/agent/agent.ts +130 -18
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +378 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +1 -1
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +27 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +25 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +4 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +110 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +13 -10
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +49 -5
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +157 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +10 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/effects/chain.ts +3 -1
- package/src/audio/effects/common.ts +43 -0
- package/src/audio/effects/convolution.ts +7 -4
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/engine.ts +360 -40
- package/src/audio/fit.ts +35 -3
- package/src/audio/instrument-check.ts +59 -43
- package/src/audio/instruments.ts +4 -0
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +8 -1
- package/src/audio/keys/engine.ts +13 -1
- package/src/audio/keys/piano.ts +22 -2
- package/src/audio/kits.ts +135 -6
- package/src/audio/live.ts +114 -20
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +30 -2
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +2 -1
- package/src/audio/sampler.ts +85 -4
- package/src/audio/samples.ts +20 -2
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/bow.ts +48 -5
- package/src/audio/strings/engine.ts +8 -2
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +500 -77
- package/src/audio/winds/engine.ts +5 -1
- package/src/audio/winds/trim.ts +28 -4
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +15 -2
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +82 -32
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +1 -1
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +101 -32
- package/src/commands/grammar.ts +560 -0
- package/src/commands/help.ts +774 -366
- package/src/commands/history.ts +139 -10
- package/src/commands/keys.ts +25 -8
- package/src/commands/modal.ts +1 -1
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +135 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +474 -0
- package/src/commands/strum.ts +13 -1
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +617 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +2 -2
- package/src/fs/durable.ts +50 -0
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +511 -0
- package/src/launch-args.ts +267 -0
- package/src/main.ts +2508 -308
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +21 -2
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +20 -1
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +199 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +174 -0
- package/src/session/port.ts +109 -14
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +365 -7
- package/src/session/rebase.ts +63 -12
- package/src/session/receipt.ts +265 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +126 -45
- package/src/tui/arrange-menu.ts +225 -23
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +113 -27
- package/src/tui/fader.ts +282 -42
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +987 -179
- package/src/tui/modal-menu.ts +6 -6
- package/src/tui/performance-menu.ts +5 -2
- package/src/tui/play-chords.ts +4 -2
- package/src/tui/play-mode.ts +15 -1
- package/src/tui/play-session.ts +243 -28
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +3 -3
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +491 -84
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +193 -0
- package/tui/drawer.ts +233 -27
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +143 -65
- package/tui/guide.ts +50 -5
- package/tui/highway.ts +309 -30
- package/tui/hints.ts +197 -0
- package/tui/hits.ts +7 -1
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +80 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +151 -18
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +46 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dawgd's live path: every pane's played notes and the click mix on the one
|
|
3
|
+
* engine this machine runs (design §12.3). A pane sends note events, never
|
|
4
|
+
* audio; dawgd renders each voice from the score it already holds, through
|
|
5
|
+
* the same `LiveSynth` a solo window uses, so a key sounds the same either
|
|
6
|
+
* way.
|
|
7
|
+
*
|
|
8
|
+
* Voice ids are window-local, so they are namespaced by client. The click is
|
|
9
|
+
* on while any pane wants it and follows dawgd's own transport clock; a
|
|
10
|
+
* count-in comes from the pane that armed last.
|
|
11
|
+
*/
|
|
12
|
+
import type { ClickBus } from "../audio/engine.ts";
|
|
13
|
+
import { countInClicks, meterClickGrid } from "../audio/click.ts";
|
|
14
|
+
import { LiveSynth, type LiveNotePcm } from "../audio/live.ts";
|
|
15
|
+
import type { SampleBank } from "../audio/samples.ts";
|
|
16
|
+
import { hasMeterChanges, loopTickAt } from "../../core/tempo.ts";
|
|
17
|
+
import type { TrackScore } from "../../core/score.ts";
|
|
18
|
+
import type { LiveClick, LiveMessage, LiveStatus } from "./protocol.ts";
|
|
19
|
+
|
|
20
|
+
/** The part of AudioEngine the live host drives. */
|
|
21
|
+
export interface LiveSink {
|
|
22
|
+
readonly sampleRate: number;
|
|
23
|
+
readonly canMonitor: boolean;
|
|
24
|
+
readonly leadMs: number;
|
|
25
|
+
readonly playLeadMs?: number;
|
|
26
|
+
readonly audioNote?: string;
|
|
27
|
+
monitor(on: boolean): Promise<void>;
|
|
28
|
+
setLeadMs(ms: number | undefined): void;
|
|
29
|
+
noteOn(id: number, note: LiveNotePcm): number;
|
|
30
|
+
noteOff(id: number): void;
|
|
31
|
+
voicePosition?(id: number): number | undefined;
|
|
32
|
+
setClick(click: ClickBus | undefined): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type LiveHostOptions = {
|
|
36
|
+
sink: LiveSink;
|
|
37
|
+
score(): TrackScore;
|
|
38
|
+
samples?(): SampleBank | undefined;
|
|
39
|
+
/** Transport beat at monotonic `ms`, or undefined while stopped. */
|
|
40
|
+
transportBeatAt(ms: number): number | undefined;
|
|
41
|
+
/** Authority clock (epoch ms) to local monotonic ms. */
|
|
42
|
+
toMonotonic(epochMs: number): number;
|
|
43
|
+
log?(line: string): void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Longest a held live note renders before its key-up arrives. */
|
|
47
|
+
const MAX_SECONDS = 8;
|
|
48
|
+
|
|
49
|
+
export class LiveHost {
|
|
50
|
+
private readonly monitors = new Set<string>();
|
|
51
|
+
private readonly clicks = new Map<string, LiveClick>();
|
|
52
|
+
private readonly voices = new Map<string, number>();
|
|
53
|
+
private nextVoice = 1;
|
|
54
|
+
private synth: LiveSynth | undefined;
|
|
55
|
+
/** Notes played since start, by client (tests and the log read it). */
|
|
56
|
+
public readonly played = new Map<string, number>();
|
|
57
|
+
|
|
58
|
+
public constructor(private readonly options: LiveHostOptions) {}
|
|
59
|
+
|
|
60
|
+
public status(): LiveStatus {
|
|
61
|
+
const sink = this.options.sink;
|
|
62
|
+
const status: LiveStatus = {
|
|
63
|
+
canMonitor: sink.canMonitor,
|
|
64
|
+
sampleRate: sink.sampleRate,
|
|
65
|
+
leadMs: sink.playLeadMs ?? sink.leadMs,
|
|
66
|
+
};
|
|
67
|
+
if (sink.audioNote) status.note = `shared engine · ${sink.audioNote}`;
|
|
68
|
+
return status;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public async handle(
|
|
72
|
+
client: string,
|
|
73
|
+
message: LiveMessage,
|
|
74
|
+
): Promise<LiveStatus | undefined> {
|
|
75
|
+
if (message.action === "monitor") {
|
|
76
|
+
await this.monitor(client, message.on);
|
|
77
|
+
return this.status();
|
|
78
|
+
}
|
|
79
|
+
if (message.action === "on") this.noteOn(client, message);
|
|
80
|
+
else if (message.action === "off") this.noteOff(client, message.voice);
|
|
81
|
+
else {
|
|
82
|
+
const { on, volume } = message;
|
|
83
|
+
const click: LiveClick = { on, volume };
|
|
84
|
+
if (message.countIn) click.countIn = message.countIn;
|
|
85
|
+
if (on || click.countIn) this.clicks.set(client, click);
|
|
86
|
+
else this.clicks.delete(client);
|
|
87
|
+
this.applyClick();
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A pane left: its voices stop, its click and monitor drop out. */
|
|
93
|
+
public async drop(client: string): Promise<void> {
|
|
94
|
+
for (const [key, id] of this.voices)
|
|
95
|
+
if (key.startsWith(`${client}:`)) {
|
|
96
|
+
this.options.sink.noteOff(id);
|
|
97
|
+
this.voices.delete(key);
|
|
98
|
+
}
|
|
99
|
+
this.clicks.delete(client);
|
|
100
|
+
this.applyClick();
|
|
101
|
+
if (this.monitors.delete(client) && this.monitors.size === 0)
|
|
102
|
+
await this.release();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public get monitoring(): number {
|
|
106
|
+
return this.monitors.size;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async monitor(client: string, on: boolean): Promise<void> {
|
|
110
|
+
const sink = this.options.sink;
|
|
111
|
+
if (on) {
|
|
112
|
+
const first = this.monitors.size === 0;
|
|
113
|
+
this.monitors.add(client);
|
|
114
|
+
if (first) {
|
|
115
|
+
sink.setLeadMs(sink.playLeadMs);
|
|
116
|
+
await sink.monitor(true);
|
|
117
|
+
this.options.log?.("live: monitor on");
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (this.monitors.delete(client) && this.monitors.size === 0)
|
|
122
|
+
await this.release();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async release(): Promise<void> {
|
|
126
|
+
const sink = this.options.sink;
|
|
127
|
+
sink.setLeadMs(undefined);
|
|
128
|
+
await sink.monitor(false);
|
|
129
|
+
this.options.log?.("live: monitor off");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private noteOn(
|
|
133
|
+
client: string,
|
|
134
|
+
message: Extract<LiveMessage, { action: "on" }>,
|
|
135
|
+
): void {
|
|
136
|
+
this.played.set(client, (this.played.get(client) ?? 0) + 1);
|
|
137
|
+
this.options.log?.(
|
|
138
|
+
`live: note ${client} ${message.trackId} ${message.pitch} beat ${message.beat.toFixed(2)}`,
|
|
139
|
+
);
|
|
140
|
+
const sink = this.options.sink;
|
|
141
|
+
if (!sink.canMonitor) return;
|
|
142
|
+
const score = this.options.score();
|
|
143
|
+
if (!score.tracks.some((track) => track.id === message.trackId)) return;
|
|
144
|
+
if (this.synth?.rate !== sink.sampleRate)
|
|
145
|
+
this.synth = new LiveSynth(sink.sampleRate);
|
|
146
|
+
const samples = this.options.samples?.();
|
|
147
|
+
const request = {
|
|
148
|
+
score,
|
|
149
|
+
trackId: message.trackId,
|
|
150
|
+
pitch: message.pitch,
|
|
151
|
+
velocity: message.velocity,
|
|
152
|
+
seconds: Math.min(message.seconds, MAX_SECONDS),
|
|
153
|
+
...(samples ? { samples } : {}),
|
|
154
|
+
...(score.time?.tempo ? { tick: loopTickAt(score, message.beat) } : {}),
|
|
155
|
+
};
|
|
156
|
+
const synth = this.synth;
|
|
157
|
+
const pcm = synth.render(request);
|
|
158
|
+
if (!pcm || pcm.fitting) return;
|
|
159
|
+
const key = `${client}:${message.voice}`;
|
|
160
|
+
const previous = this.voices.get(key);
|
|
161
|
+
const id = previous ?? this.nextVoice++;
|
|
162
|
+
this.voices.set(key, id);
|
|
163
|
+
sink.noteOn(id, pcm);
|
|
164
|
+
// A rig's first window sounds now; the whole note follows off the key
|
|
165
|
+
// path and swaps in place while the voice still sounds.
|
|
166
|
+
if (pcm.partial)
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
if (this.voices.get(key) !== id) return;
|
|
169
|
+
const full = synth.render({
|
|
170
|
+
...request,
|
|
171
|
+
full: true,
|
|
172
|
+
...(pcm.clock === undefined ? {} : { clock: pcm.clock }),
|
|
173
|
+
});
|
|
174
|
+
if (full && sink.voicePosition?.(id) !== undefined)
|
|
175
|
+
sink.noteOn(id, full);
|
|
176
|
+
}, 0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private noteOff(client: string, voice: number): void {
|
|
180
|
+
const key = `${client}:${voice}`;
|
|
181
|
+
const id = this.voices.get(key);
|
|
182
|
+
if (id === undefined) return;
|
|
183
|
+
this.voices.delete(key);
|
|
184
|
+
this.options.sink.noteOff(id);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The one click: on while any pane wants it. */
|
|
188
|
+
private applyClick(): void {
|
|
189
|
+
const sink = this.options.sink;
|
|
190
|
+
let wanted: LiveClick | undefined;
|
|
191
|
+
for (const click of this.clicks.values())
|
|
192
|
+
if (click.on || click.countIn) wanted = click;
|
|
193
|
+
if (!wanted) {
|
|
194
|
+
sink.setClick(undefined);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const score = this.options.score;
|
|
198
|
+
const count = wanted.countIn;
|
|
199
|
+
const startMs = count
|
|
200
|
+
? this.options.toMonotonic(count.startAtMs)
|
|
201
|
+
: undefined;
|
|
202
|
+
const on = [...this.clicks.values()].some((click) => click.on);
|
|
203
|
+
let grid: ClickBus["grid"];
|
|
204
|
+
if (count && hasMeterChanges(score()))
|
|
205
|
+
grid = (from, to) =>
|
|
206
|
+
countInClicks(
|
|
207
|
+
count.startBeat,
|
|
208
|
+
count.beats / count.barBeats,
|
|
209
|
+
count.barBeats,
|
|
210
|
+
count.clickBeats,
|
|
211
|
+
from,
|
|
212
|
+
to,
|
|
213
|
+
);
|
|
214
|
+
else if (!count && hasMeterChanges(score())) grid = meterClickGrid(score);
|
|
215
|
+
sink.setClick({
|
|
216
|
+
volume: wanted.volume,
|
|
217
|
+
beatsPerBar: score().beatsPerBar,
|
|
218
|
+
subdivision: 1,
|
|
219
|
+
beatAt: (ms) => {
|
|
220
|
+
if (count && startMs !== undefined) {
|
|
221
|
+
const beat =
|
|
222
|
+
count.startBeat -
|
|
223
|
+
count.beats +
|
|
224
|
+
(ms - startMs) / (60_000 / count.bpm);
|
|
225
|
+
if (beat < count.startBeat) return beat;
|
|
226
|
+
}
|
|
227
|
+
return on ? this.options.transportBeatAt(ms) : undefined;
|
|
228
|
+
},
|
|
229
|
+
...(grid ? { grid } : {}),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
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,174 @@
|
|
|
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
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The pane letters that authored `events` (dawgd stamps each event's
|
|
158
|
+
* clientId), in event order, excluding this pane. Empty when no author is a
|
|
159
|
+
* live lettered pane (file sessions, v1 daemons, a pane that already left).
|
|
160
|
+
*/
|
|
161
|
+
export function paneLetters(
|
|
162
|
+
events: readonly Pick<SessionEvent, "actor">[],
|
|
163
|
+
clients: readonly { clientId: string; pane?: string }[],
|
|
164
|
+
selfId: string,
|
|
165
|
+
): string[] {
|
|
166
|
+
const letters: string[] = [];
|
|
167
|
+
for (const event of events) {
|
|
168
|
+
const id = event.actor?.clientId;
|
|
169
|
+
if (!id || id === selfId) continue;
|
|
170
|
+
const pane = clients.find((client) => client.clientId === id)?.pane;
|
|
171
|
+
if (pane && !letters.includes(pane)) letters.push(pane);
|
|
172
|
+
}
|
|
173
|
+
return letters;
|
|
174
|
+
}
|
package/src/session/port.ts
CHANGED
|
@@ -5,10 +5,13 @@ import type { TrackScore } from "../../core/score.ts";
|
|
|
5
5
|
import { AudioEngine } from "../audio/engine.ts";
|
|
6
6
|
import { DaemonClient, type DaemonClientOptions } from "./client.ts";
|
|
7
7
|
import { FilePresence } from "./presence.ts";
|
|
8
|
-
import type {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
import type { LiveLink } from "./shared-live.ts";
|
|
9
|
+
import {
|
|
10
|
+
toLocalTransport,
|
|
11
|
+
type PaneView,
|
|
12
|
+
type PresenceEntry,
|
|
13
|
+
type TransportAction,
|
|
14
|
+
type TransportState,
|
|
12
15
|
} from "./protocol.ts";
|
|
13
16
|
import type { MetaExpect, MetaPatch, SessionMeta } from "./meta.ts";
|
|
14
17
|
import {
|
|
@@ -16,6 +19,7 @@ import {
|
|
|
16
19
|
loadSession,
|
|
17
20
|
SessionConflictError,
|
|
18
21
|
updateSessionMeta,
|
|
22
|
+
type EventActor,
|
|
19
23
|
type SessionEvent,
|
|
20
24
|
type SessionPaths,
|
|
21
25
|
type SessionRecord,
|
|
@@ -113,6 +117,15 @@ export interface SessionPort<T> {
|
|
|
113
117
|
preferred?: string,
|
|
114
118
|
): Promise<{ trackId: string; draft: boolean }>;
|
|
115
119
|
presence(): Promise<PresenceEntry[]>;
|
|
120
|
+
/** This pane's letter (daemon mode, assigned by dawgd). */
|
|
121
|
+
readonly pane: string | undefined;
|
|
122
|
+
/** Reports what this pane shows; others see it in presence. */
|
|
123
|
+
setView(view: PaneView): void;
|
|
124
|
+
/**
|
|
125
|
+
* The machine's shared live engine (daemon with the `live` capability),
|
|
126
|
+
* or undefined: the pane then plays through its own monitor engine.
|
|
127
|
+
*/
|
|
128
|
+
liveLink(): LiveLink | undefined;
|
|
116
129
|
/**
|
|
117
130
|
* Conditional metadata write: applies only while `expect` matches the
|
|
118
131
|
* stored metadata, so a user rename always beats an in-flight auto-name.
|
|
@@ -126,9 +139,16 @@ export type OpenPortOptions = {
|
|
|
126
139
|
sessionId: string;
|
|
127
140
|
label: string;
|
|
128
141
|
focusedTrackId: string | null;
|
|
142
|
+
/** Who is at this window; stamped on events and presence. */
|
|
143
|
+
actor?: { id: string; name: string };
|
|
129
144
|
/** Set false for one-shot commands (demo, export) that must not spawn dawgd. */
|
|
130
145
|
daemon?: boolean;
|
|
131
146
|
daemonArgs?: string[];
|
|
147
|
+
/**
|
|
148
|
+
* File sessions: the backstop poll while fs.watch is live (default 1 s).
|
|
149
|
+
* Tests raise it to prove a change arrived through the watcher.
|
|
150
|
+
*/
|
|
151
|
+
watchedPollMs?: number;
|
|
132
152
|
};
|
|
133
153
|
|
|
134
154
|
/** Connects to (or starts) dawgd, falling back to the file-lock path. */
|
|
@@ -143,6 +163,7 @@ export async function openSessionPort<T>(
|
|
|
143
163
|
label: options.label,
|
|
144
164
|
focusedTrackId: options.focusedTrackId,
|
|
145
165
|
};
|
|
166
|
+
if (options.actor) clientOptions.actor = options.actor;
|
|
146
167
|
if (options.daemonArgs) clientOptions.daemonArgs = options.daemonArgs;
|
|
147
168
|
const client = await DaemonClient.connect(clientOptions);
|
|
148
169
|
return new DaemonPort<T>(client);
|
|
@@ -171,6 +192,12 @@ class DaemonPort<T> implements SessionPort<T> {
|
|
|
171
192
|
public status: string;
|
|
172
193
|
public sync: SyncStatus = "synced";
|
|
173
194
|
private readonly syncListeners = new Set<(sync: SyncStatus) => void>();
|
|
195
|
+
/**
|
|
196
|
+
* (authority clock - this machine's clock), applied to every transport
|
|
197
|
+
* `atMs` before the window sees it. Always 0 on one machine; the hook a
|
|
198
|
+
* future relay fills from its clock-sync exchange.
|
|
199
|
+
*/
|
|
200
|
+
public clockOffsetMs = 0;
|
|
174
201
|
|
|
175
202
|
public constructor(private readonly client: DaemonClient) {
|
|
176
203
|
this.status = `dawgd pid ${client.daemonPid}`;
|
|
@@ -225,7 +252,11 @@ class DaemonPort<T> implements SessionPort<T> {
|
|
|
225
252
|
}
|
|
226
253
|
if (result.status === "accepted" || result.status === "duplicate") {
|
|
227
254
|
this.setSync("synced");
|
|
228
|
-
|
|
255
|
+
// The client's record can already hold a later write from another
|
|
256
|
+
// window; mark which revision is this one's (OwnWrites reads it).
|
|
257
|
+
const record = { ...this.record() };
|
|
258
|
+
WRITTEN_REVISION.set(record, result.revision);
|
|
259
|
+
return record;
|
|
229
260
|
}
|
|
230
261
|
if (result.status === "rebase") {
|
|
231
262
|
this.setSync("conflict");
|
|
@@ -248,7 +279,10 @@ class DaemonPort<T> implements SessionPort<T> {
|
|
|
248
279
|
}
|
|
249
280
|
|
|
250
281
|
public subscribe(listener: (update: PortUpdate<T>) => void): () => void {
|
|
251
|
-
listener({
|
|
282
|
+
listener({
|
|
283
|
+
type: "transport",
|
|
284
|
+
transport: toLocalTransport(this.client.transport, this.clockOffsetMs),
|
|
285
|
+
});
|
|
252
286
|
const onSync = (sync: SyncStatus) => listener({ type: "sync", sync });
|
|
253
287
|
this.syncListeners.add(onSync);
|
|
254
288
|
const unsubscribe = this.client.subscribe((update) => {
|
|
@@ -260,7 +294,12 @@ class DaemonPort<T> implements SessionPort<T> {
|
|
|
260
294
|
this.status = update.message;
|
|
261
295
|
this.setSync(update.connected ? "synced" : "offline");
|
|
262
296
|
listener({ type: "status", message: update.message });
|
|
263
|
-
} else
|
|
297
|
+
} else if (update.type === "transport")
|
|
298
|
+
listener({
|
|
299
|
+
type: "transport",
|
|
300
|
+
transport: toLocalTransport(update.transport, this.clockOffsetMs),
|
|
301
|
+
});
|
|
302
|
+
else listener(update);
|
|
264
303
|
});
|
|
265
304
|
return () => {
|
|
266
305
|
this.syncListeners.delete(onSync);
|
|
@@ -291,6 +330,28 @@ class DaemonPort<T> implements SessionPort<T> {
|
|
|
291
330
|
return this.client.presence;
|
|
292
331
|
}
|
|
293
332
|
|
|
333
|
+
public get pane(): string | undefined {
|
|
334
|
+
return this.client.self?.pane;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
public setView(view: PaneView): void {
|
|
338
|
+
if (this.client.caps.includes("panes")) this.client.setView(view);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
public liveLink(): LiveLink | undefined {
|
|
342
|
+
if (!this.client.sharedLive) return undefined;
|
|
343
|
+
const client = this.client;
|
|
344
|
+
const port = this;
|
|
345
|
+
return (this.link ??= {
|
|
346
|
+
liveMonitor: (on) => client.liveMonitor(on),
|
|
347
|
+
live: (message) => client.live(message),
|
|
348
|
+
get clockOffsetMs() {
|
|
349
|
+
return port.clockOffsetMs;
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
private link: LiveLink | undefined;
|
|
354
|
+
|
|
294
355
|
public async updateMeta(
|
|
295
356
|
patch: MetaPatch,
|
|
296
357
|
expect?: MetaExpect,
|
|
@@ -308,6 +369,12 @@ class DaemonPort<T> implements SessionPort<T> {
|
|
|
308
369
|
}
|
|
309
370
|
}
|
|
310
371
|
|
|
372
|
+
/**
|
|
373
|
+
* The revision an append produced, for records whose last event may be a
|
|
374
|
+
* newer write from elsewhere (a daemon window's record follows the log).
|
|
375
|
+
*/
|
|
376
|
+
export const WRITTEN_REVISION = new WeakMap<object, number>();
|
|
377
|
+
|
|
311
378
|
class FilePort<T> implements SessionPort<T> {
|
|
312
379
|
public readonly mode = "file" as const;
|
|
313
380
|
public readonly clientId = randomUUID();
|
|
@@ -316,6 +383,14 @@ class FilePort<T> implements SessionPort<T> {
|
|
|
316
383
|
public readonly player: WindowPlayer;
|
|
317
384
|
private readonly presenceStore: FilePresence;
|
|
318
385
|
private readonly statusListeners = new Set<(update: PortUpdate<T>) => void>();
|
|
386
|
+
/** File sessions have no pane letters or shared engine (§12.3). */
|
|
387
|
+
public readonly pane = undefined;
|
|
388
|
+
|
|
389
|
+
public setView(_view: PaneView): void {}
|
|
390
|
+
|
|
391
|
+
public liveLink(): LiveLink | undefined {
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
319
394
|
|
|
320
395
|
private constructor(private readonly options: OpenPortOptions) {
|
|
321
396
|
this.player = new AudioEngine({
|
|
@@ -331,6 +406,7 @@ class FilePort<T> implements SessionPort<T> {
|
|
|
331
406
|
pid: process.pid,
|
|
332
407
|
label: options.label.slice(0, 128),
|
|
333
408
|
focusedTrackId: options.focusedTrackId,
|
|
409
|
+
...(options.actor ? { actorId: options.actor.id } : {}),
|
|
334
410
|
});
|
|
335
411
|
}
|
|
336
412
|
|
|
@@ -346,17 +422,33 @@ class FilePort<T> implements SessionPort<T> {
|
|
|
346
422
|
event: Omit<SessionEvent, "id" | "revision" | "at">,
|
|
347
423
|
composition: T,
|
|
348
424
|
): Promise<SessionRecord<T>> {
|
|
349
|
-
|
|
425
|
+
const actor: EventActor = { clientId: this.clientId };
|
|
426
|
+
if (this.options.actor) actor.actorId = this.options.actor.id;
|
|
427
|
+
return appendSessionEvent(
|
|
428
|
+
this.options.paths,
|
|
429
|
+
current,
|
|
430
|
+
{ ...event, actor },
|
|
431
|
+
composition,
|
|
432
|
+
);
|
|
350
433
|
}
|
|
351
434
|
|
|
352
|
-
/**
|
|
435
|
+
/**
|
|
436
|
+
* No daemon to rebase against: commit the full composition, and keep the
|
|
437
|
+
* operations on the event so the log still replays as ops.
|
|
438
|
+
*/
|
|
353
439
|
public appendOperations(
|
|
354
440
|
current: SessionRecord<T>,
|
|
355
441
|
event: Omit<SessionEvent, "id" | "revision" | "at">,
|
|
356
|
-
|
|
442
|
+
operations: readonly unknown[],
|
|
357
443
|
composition: T,
|
|
358
444
|
): Promise<SessionRecord<T>> {
|
|
359
|
-
return this.append(
|
|
445
|
+
return this.append(
|
|
446
|
+
current,
|
|
447
|
+
operations.length > 0 && operations.length <= MAX_INTENT_OPERATIONS
|
|
448
|
+
? { ...event, ops: [...operations] }
|
|
449
|
+
: event,
|
|
450
|
+
composition,
|
|
451
|
+
);
|
|
360
452
|
}
|
|
361
453
|
|
|
362
454
|
public load(): Promise<SessionRecord<T>> {
|
|
@@ -408,10 +500,10 @@ class FilePort<T> implements SessionPort<T> {
|
|
|
408
500
|
};
|
|
409
501
|
const recordName = basename(this.options.paths.record);
|
|
410
502
|
// 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
|
|
503
|
+
// the temp file (`<record>.<pid>.<n>.tmp`), so match that too, but not the
|
|
412
504
|
// sibling `.audio.lock` / `.daemon.log` files that share the prefix.
|
|
413
505
|
const recordFile = new RegExp(
|
|
414
|
-
`^${recordName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\.\\d+\\.tmp)?$`,
|
|
506
|
+
`^${recordName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:(?:\\.\\d+)+\\.tmp)?$`,
|
|
415
507
|
);
|
|
416
508
|
let watcher: FSWatcher | undefined;
|
|
417
509
|
try {
|
|
@@ -429,7 +521,10 @@ class FilePort<T> implements SessionPort<T> {
|
|
|
429
521
|
} catch {
|
|
430
522
|
watcher = undefined;
|
|
431
523
|
}
|
|
432
|
-
const timer = setInterval(
|
|
524
|
+
const timer = setInterval(
|
|
525
|
+
check,
|
|
526
|
+
watcher ? (this.options.watchedPollMs ?? WATCHED_POLL_MS) : POLL_MS,
|
|
527
|
+
);
|
|
433
528
|
this.statusListeners.add(listener);
|
|
434
529
|
check();
|
|
435
530
|
return () => {
|