@hraness/dawg 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/DAWG.md +117 -30
- package/README.md +7 -5
- package/core/diff.ts +2 -0
- package/core/ids.ts +52 -0
- package/core/loop.ts +3 -0
- package/core/range.ts +581 -0
- package/core/score.ts +123 -1
- package/core/sdk/print.ts +11 -2
- package/core/sdk/v1.ts +281 -2
- package/core/sections.ts +198 -19
- package/guides/arrange.md +9 -8
- package/guides/audio.md +31 -0
- package/guides/mix.md +3 -1
- package/guides/panes.md +32 -0
- package/guides/project.md +3 -2
- package/guides/rhythm.md +2 -1
- package/guides/tape.md +32 -0
- package/package.json +2 -1
- package/src/agent/agent.ts +4 -4
- package/src/agent/command-agent.ts +19 -10
- package/src/agent/ops.ts +15 -0
- package/src/agent/planner.ts +12 -0
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/show-me.ts +1 -1
- package/src/agent/tools.ts +2 -0
- package/src/agent/xcb-agent.ts +2 -3
- package/src/audio/arrange.ts +12 -2
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +84 -13
- package/src/audio/engine.ts +216 -28
- package/src/commands/arrange.ts +60 -25
- package/src/commands/grammar.ts +3 -1
- package/src/commands/help.ts +187 -9
- package/src/commands/history.ts +122 -11
- package/src/commands/keys.ts +17 -0
- package/src/commands/parses.ts +6 -1
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +2 -6
- package/src/commands/vocal-pitch.ts +2 -1
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +18 -0
- package/src/launch-args.ts +105 -1
- package/src/main.ts +1142 -86
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +196 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/origin.ts +20 -0
- package/src/session/port.ts +87 -10
- package/src/session/protocol.ts +360 -6
- package/src/session/rebase.ts +46 -8
- package/src/session/receipt.ts +7 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +61 -2
- package/src/tui/arrange-menu.ts +169 -1
- package/src/tui/euclid.ts +95 -14
- package/src/tui/fader.ts +55 -2
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu.ts +228 -2
- package/src/tui/play-session.ts +196 -21
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/tui/app.ts +217 -44
- package/tui/delight.ts +49 -0
- package/tui/drawer.ts +167 -9
- package/tui/grammar.ts +34 -5
- package/tui/guide.ts +8 -1
- package/tui/highway.ts +40 -5
- package/tui/hints.ts +5 -0
- package/tui/hits.ts +7 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +12 -0
- package/tui/screen.ts +7 -4
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +19 -1
|
@@ -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/origin.ts
CHANGED
|
@@ -152,3 +152,23 @@ export function editedTrack(
|
|
|
152
152
|
}
|
|
153
153
|
return changed.size === 1 ? [...changed][0] : undefined;
|
|
154
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,6 +139,8 @@ 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[];
|
|
@@ -148,6 +163,7 @@ export async function openSessionPort<T>(
|
|
|
148
163
|
label: options.label,
|
|
149
164
|
focusedTrackId: options.focusedTrackId,
|
|
150
165
|
};
|
|
166
|
+
if (options.actor) clientOptions.actor = options.actor;
|
|
151
167
|
if (options.daemonArgs) clientOptions.daemonArgs = options.daemonArgs;
|
|
152
168
|
const client = await DaemonClient.connect(clientOptions);
|
|
153
169
|
return new DaemonPort<T>(client);
|
|
@@ -176,6 +192,12 @@ class DaemonPort<T> implements SessionPort<T> {
|
|
|
176
192
|
public status: string;
|
|
177
193
|
public sync: SyncStatus = "synced";
|
|
178
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;
|
|
179
201
|
|
|
180
202
|
public constructor(private readonly client: DaemonClient) {
|
|
181
203
|
this.status = `dawgd pid ${client.daemonPid}`;
|
|
@@ -257,7 +279,10 @@ class DaemonPort<T> implements SessionPort<T> {
|
|
|
257
279
|
}
|
|
258
280
|
|
|
259
281
|
public subscribe(listener: (update: PortUpdate<T>) => void): () => void {
|
|
260
|
-
listener({
|
|
282
|
+
listener({
|
|
283
|
+
type: "transport",
|
|
284
|
+
transport: toLocalTransport(this.client.transport, this.clockOffsetMs),
|
|
285
|
+
});
|
|
261
286
|
const onSync = (sync: SyncStatus) => listener({ type: "sync", sync });
|
|
262
287
|
this.syncListeners.add(onSync);
|
|
263
288
|
const unsubscribe = this.client.subscribe((update) => {
|
|
@@ -269,7 +294,12 @@ class DaemonPort<T> implements SessionPort<T> {
|
|
|
269
294
|
this.status = update.message;
|
|
270
295
|
this.setSync(update.connected ? "synced" : "offline");
|
|
271
296
|
listener({ type: "status", message: update.message });
|
|
272
|
-
} else
|
|
297
|
+
} else if (update.type === "transport")
|
|
298
|
+
listener({
|
|
299
|
+
type: "transport",
|
|
300
|
+
transport: toLocalTransport(update.transport, this.clockOffsetMs),
|
|
301
|
+
});
|
|
302
|
+
else listener(update);
|
|
273
303
|
});
|
|
274
304
|
return () => {
|
|
275
305
|
this.syncListeners.delete(onSync);
|
|
@@ -300,6 +330,28 @@ class DaemonPort<T> implements SessionPort<T> {
|
|
|
300
330
|
return this.client.presence;
|
|
301
331
|
}
|
|
302
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
|
+
|
|
303
355
|
public async updateMeta(
|
|
304
356
|
patch: MetaPatch,
|
|
305
357
|
expect?: MetaExpect,
|
|
@@ -331,6 +383,14 @@ class FilePort<T> implements SessionPort<T> {
|
|
|
331
383
|
public readonly player: WindowPlayer;
|
|
332
384
|
private readonly presenceStore: FilePresence;
|
|
333
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
|
+
}
|
|
334
394
|
|
|
335
395
|
private constructor(private readonly options: OpenPortOptions) {
|
|
336
396
|
this.player = new AudioEngine({
|
|
@@ -346,6 +406,7 @@ class FilePort<T> implements SessionPort<T> {
|
|
|
346
406
|
pid: process.pid,
|
|
347
407
|
label: options.label.slice(0, 128),
|
|
348
408
|
focusedTrackId: options.focusedTrackId,
|
|
409
|
+
...(options.actor ? { actorId: options.actor.id } : {}),
|
|
349
410
|
});
|
|
350
411
|
}
|
|
351
412
|
|
|
@@ -361,17 +422,33 @@ class FilePort<T> implements SessionPort<T> {
|
|
|
361
422
|
event: Omit<SessionEvent, "id" | "revision" | "at">,
|
|
362
423
|
composition: T,
|
|
363
424
|
): Promise<SessionRecord<T>> {
|
|
364
|
-
|
|
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
|
+
);
|
|
365
433
|
}
|
|
366
434
|
|
|
367
|
-
/**
|
|
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
|
+
*/
|
|
368
439
|
public appendOperations(
|
|
369
440
|
current: SessionRecord<T>,
|
|
370
441
|
event: Omit<SessionEvent, "id" | "revision" | "at">,
|
|
371
|
-
|
|
442
|
+
operations: readonly unknown[],
|
|
372
443
|
composition: T,
|
|
373
444
|
): Promise<SessionRecord<T>> {
|
|
374
|
-
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
|
+
);
|
|
375
452
|
}
|
|
376
453
|
|
|
377
454
|
public load(): Promise<SessionRecord<T>> {
|