dsh-realtime-audio-ws 0.2.2 → 0.2.3

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.
@@ -24,7 +24,7 @@
24
24
  * actually been read, rather than shipping a capture path that a policy might silently kill.
25
25
  */
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
- exports.INJECTED_KEY = exports.GLOBAL_KEY = exports.MAX_BACKLOG_SECONDS = exports.CAPTURE_BUFFER = exports.SAMPLE_RATE = exports.DEFAULT_PATH = exports.inject = exports.name = void 0;
27
+ exports.STRIP_ELEMENT_ID = exports.INJECTED_KEY = exports.GLOBAL_KEY = exports.MAX_BACKLOG_SECONDS = exports.CAPTURE_BUFFER = exports.SAMPLE_RATE = exports.DEFAULT_PATH = exports.inject = exports.name = void 0;
28
28
  exports.socketUrl = socketUrl;
29
29
  exports.pageAuthority = pageAuthority;
30
30
  exports.withToken = withToken;
@@ -32,6 +32,9 @@ exports.pcm16FromFloat32 = pcm16FromFloat32;
32
32
  exports.float32FromPcm16 = float32FromPcm16;
33
33
  exports.defaultDeps = defaultDeps;
34
34
  exports.createAudioClient = createAudioClient;
35
+ exports.stripRoot = stripRoot;
36
+ exports.renderStrip = renderStrip;
37
+ exports.mountStrip = mountStrip;
35
38
  exports.apply = apply;
36
39
  /** Browser-side plugin name. */
37
40
  exports.name = 'realtime-audio-client';
@@ -166,12 +169,20 @@ function createAudioClient(deps) {
166
169
  let processor;
167
170
  let silence;
168
171
  let playsAt = 0;
172
+ /** The control request waiting for its reply, if any. The host answers one frame with one frame. */
173
+ let pending;
174
+ /** The tail of the request chain, so two frames are never in flight at once. */
175
+ let chain = Promise.resolve();
169
176
  const fail = (reason) => {
170
177
  current = { kind: 'failed', reason };
171
178
  return current;
172
179
  };
173
180
  /** Release everything held. Idempotent, because stop, an error and a close all reach it. */
174
181
  const release = () => {
182
+ // A request whose socket is going away must fail rather than hang: the panel disables half of itself
183
+ // while one is in flight, and a promise that never settles would leave it disabled for ever.
184
+ pending?.reject(new Error('the audio socket closed before the host answered'));
185
+ pending = undefined;
175
186
  socket?.close();
176
187
  socket = undefined;
177
188
  processor?.disconnect();
@@ -272,8 +283,26 @@ function createAudioClient(deps) {
272
283
  socket = opened;
273
284
  opened.binaryType = 'arraybuffer';
274
285
  opened.onmessage = (event) => {
275
- if (event.data instanceof ArrayBuffer)
286
+ // Binary frames are audio; a string is the answer to a control frame. The frame's own type is what
287
+ // tells them apart, which is why the control channel cost this path nothing.
288
+ if (event.data instanceof ArrayBuffer) {
276
289
  play(new Uint8Array(event.data));
290
+ return;
291
+ }
292
+ if (typeof event.data !== 'string')
293
+ return;
294
+ const waiting = pending;
295
+ pending = undefined;
296
+ if (waiting === undefined)
297
+ return;
298
+ try {
299
+ waiting.resolve(JSON.parse(event.data));
300
+ }
301
+ catch {
302
+ // A reply that is not JSON is a host fault, and the caller has to hear about it rather than be left
303
+ // waiting: the same rule the channel itself follows, one side out.
304
+ waiting.reject(new Error('the host answered a control frame with something that is not a reply'));
305
+ }
277
306
  };
278
307
  opened.onclose = () => {
279
308
  release();
@@ -284,23 +313,329 @@ function createAudioClient(deps) {
284
313
  opened.onerror = () => { resolve(fail('the host refused the audio socket')); };
285
314
  });
286
315
  };
287
- return { start, stop, state: () => current };
316
+ /**
317
+ * Send one frame and wait for its reply.
318
+ *
319
+ * The socket is looked up at the moment of use rather than closed over, so a request made after a
320
+ * reconnect goes out on the socket that exists now — the same rule every other live value in this
321
+ * project follows.
322
+ * @param frame - the control frame.
323
+ * @returns the parsed reply.
324
+ */
325
+ const sendFrame = (frame) => {
326
+ const current = socket;
327
+ if (current === undefined) {
328
+ return Promise.reject(new Error('not connected: connect the microphone first'));
329
+ }
330
+ return new Promise((resolve, reject) => {
331
+ pending = { resolve, reject };
332
+ current.send(frame);
333
+ });
334
+ };
335
+ const request = (frame) => {
336
+ // Chained, not concurrent: the host answers in arrival order, and a client that pipelined would be
337
+ // pairing replies with frames by guesswork. The chain is kept alive across a failure, because one
338
+ // refused frame must not silently stop every frame behind it.
339
+ const next = chain.then(() => sendFrame(frame));
340
+ chain = next.catch(() => undefined);
341
+ return next;
342
+ };
343
+ return { start, stop, state: () => current, request };
344
+ }
345
+ // ---- the strip: the panel this plugin puts in the app ---------------------------------------------------
346
+ /** The element the injected markup provides. Duplicated in the host half, which mints the row. */
347
+ exports.STRIP_ELEMENT_ID = 'dsh-realtime-strip';
348
+ /**
349
+ * Where the injected markup puts the panel, or `null` when this page does not carry it.
350
+ * @param scope - the page scope, injectable so both cases are testable without a DOM.
351
+ * @returns the panel's element, or `null`.
352
+ */
353
+ function stripRoot(scope) {
354
+ return scope.document?.getElementById(exports.STRIP_ELEMENT_ID) ?? null;
355
+ }
356
+ /**
357
+ * Escape text for an element's body and for a quoted attribute at once.
358
+ *
359
+ * One function rather than two because the two contexts differ only in which characters break out, and
360
+ * every value a panel renders came from a plugin's own configuration — a session id, a model name, or
361
+ * `instructions` somebody typed.
362
+ * @param text - the text to escape.
363
+ * @returns the text, safe in either position.
364
+ */
365
+ function escapeHtml(text) {
366
+ return text
367
+ .replaceAll('&', '&')
368
+ .replaceAll('<', '&lt;')
369
+ .replaceAll('>', '&gt;')
370
+ .replaceAll('"', '&quot;');
371
+ }
372
+ /**
373
+ * Show one value as text.
374
+ * @param value - a setting's value as `status` reported it.
375
+ * @returns the text to render.
376
+ */
377
+ function show(value) {
378
+ if (value === undefined || value === null)
379
+ return '—';
380
+ return typeof value === 'string' ? value : JSON.stringify(value);
381
+ }
382
+ /**
383
+ * Render the whole panel.
384
+ *
385
+ * A pure function of the state, because that is what makes the panel testable at all: the injected script
386
+ * is three lines and the DOM is one assignment, so everything worth checking lives here.
387
+ * @param state - what the panel is showing.
388
+ * @returns the markup for the panel's element.
389
+ */
390
+ function renderStrip(state) {
391
+ const reply = state.reply;
392
+ const lines = [`<p data-dsh-strip-voice>${escapeHtml(summariseVoice(reply, state.audio))}</p>`];
393
+ if (reply !== null)
394
+ lines.push(`<p data-dsh-strip-route>${escapeHtml(summariseRoute(reply))}</p>`);
395
+ if (state.notice !== '')
396
+ lines.push(`<p data-dsh-strip-notice>${escapeHtml(state.notice)}</p>`);
397
+ lines.push(`<p data-dsh-strip-buttons>${buttons(state.audio)}</p>`);
398
+ // Restart-bound settings are omitted rather than shown read-only: the gate says no control at all, and a
399
+ // row that cannot be changed is a row a reader will try to change.
400
+ const settings = (reply?.settings ?? []).filter(entry => entry.scope !== 'restart');
401
+ lines.push(settings.length === 0
402
+ ? '<p data-dsh-strip-empty>no settings to show</p>'
403
+ : `<ul>${settings.map(entry => settingRow(entry, state.notes)).join('')}</ul>`);
404
+ return lines.join('');
405
+ }
406
+ /**
407
+ * The voice line: whether a session is live, and what it is.
408
+ * @param reply - the last status, if any.
409
+ * @param audio - what the microphone is doing.
410
+ * @returns one line of plain text.
411
+ */
412
+ function summariseVoice(reply, audio) {
413
+ const microphone = audio.kind === 'failed' ? `microphone: failed — ${audio.reason}` : `microphone: ${audio.kind}`;
414
+ if (reply === null)
415
+ return `no answer yet · ${microphone}`;
416
+ const voice = reply.voice;
417
+ // `null` is "nobody answered the question", which is a composition without an agent row — deliberately
418
+ // not the same statement as a session that is merely closed.
419
+ if (voice === null || voice === undefined)
420
+ return `voice: no agent is mounted in this profile · ${microphone}`;
421
+ const where = voice.provider === undefined ? '' : ` · ${voice.provider}/${voice.model ?? '?'}`;
422
+ const which = voice.sessionId === undefined ? '' : ` · ${voice.sessionId}`;
423
+ return `voice: ${voice.open ? 'open' : 'closed'}${where}${which} · ${microphone}`;
288
424
  }
289
425
  /**
290
- * The client plugin. Publishes the client on a global and releases it with the plugin.
426
+ * The route line: what this socket is, and the last thing the journal recorded.
427
+ * @param reply - the last status.
428
+ * @returns one line of plain text.
429
+ */
430
+ function summariseRoute(reply) {
431
+ const last = reply.journal?.last?.kind;
432
+ return [
433
+ reply.audio?.path ?? 'the audio route',
434
+ `${String(reply.audio?.clients ?? 0)} socket(s)`,
435
+ `journal ${String(reply.journal?.size ?? 0)}`,
436
+ ...last === undefined ? [] : [`last ${last}`],
437
+ ].join(' · ');
438
+ }
439
+ /**
440
+ * The buttons above the settings.
291
441
  *
292
- * There is no UI surface yet, so a global is how it is driven — a settings card is its own increment and a
293
- * bigger one than this. `inject` is empty for the same reason: nothing here needs another plugin.
442
+ * The microphone's own button is the on-switch this sprint exists to provide: what used to be a
443
+ * `globalThis` call in a devtools console is now the first control in the panel.
444
+ * @param audio - what the microphone is doing.
445
+ * @returns the markup.
446
+ */
447
+ function buttons(audio) {
448
+ const live = audio.kind === 'live';
449
+ return [
450
+ `<button data-action="${live ? 'disconnect' : 'connect'}">${live ? 'Disconnect microphone' : 'Connect microphone'}</button>`,
451
+ '<button data-action="voice-start">Start voice</button>',
452
+ '<button data-action="voice-stop">Stop voice</button>',
453
+ '<button data-action="refresh">Refresh</button>',
454
+ ].join(' ');
455
+ }
456
+ /**
457
+ * One setting's row.
458
+ *
459
+ * Three classes, three treatments, and the gate's rule is the reason: a live field gets a control, a
460
+ * session-bound field says what it would take to change it, and a restart-bound field is not rendered.
461
+ * @param entry - the setting as `status` reported it.
462
+ * @param notes - what the last change to each setting produced.
463
+ * @returns the markup for one row.
464
+ */
465
+ function settingRow(entry, notes) {
466
+ const key = escapeHtml(entry.key);
467
+ const label = `<span data-dsh-strip-label>${escapeHtml(entry.describe ?? entry.field)}</span>`;
468
+ const note = notes[entry.key] === undefined
469
+ ? ''
470
+ : `<em data-note="${key}">${escapeHtml(notes[entry.key])}</em>`;
471
+ if (entry.scope !== 'live') {
472
+ return `<li data-key="${key}" data-scope="session">${label}<b>${escapeHtml(show(entry.value))}</b>`
473
+ + `<em>fixed when the session opens — reconnect to apply</em>${note}</li>`;
474
+ }
475
+ // The channel has a verb for exactly one purpose — steering at a session — and this is the field it
476
+ // means. Everything else goes through `set <key>=<value>`.
477
+ const action = entry.field === 'sessionId' && entry.kind === 'string' ? 'steer' : 'set';
478
+ const control = entry.choices !== undefined && entry.choices.length > 0
479
+ ? `<select data-input="${key}">${choiceOptions(entry, entry.choices)}</select>`
480
+ : `<input data-input="${key}" value="${escapeHtml(show(entry.value))}">`;
481
+ const name = action === 'steer' ? 'Steer' : 'Set';
482
+ return `<li data-key="${key}" data-scope="live">${label}${control}`
483
+ + `<button data-action="${action}" data-key="${key}">${name}</button>${note}</li>`;
484
+ }
485
+ /**
486
+ * A picker's options, with the value in force always among them.
487
+ * @param entry - the setting, for its value.
488
+ * @param candidates - the values it declared. Passed in rather than read here: this is only ever called
489
+ * for a setting that has a non-empty list, and a fallback for the case that cannot happen is a branch
490
+ * the coverage gate rightly flags as dead.
491
+ * @returns the markup for each option.
492
+ */
493
+ function choiceOptions(entry, candidates) {
494
+ const current = show(entry.value);
495
+ // A value that is not among the candidates is offered anyway. A select that silently showed the first
496
+ // candidate instead of the value actually in force would be lying about the state, which is the one
497
+ // thing a control plane must not do.
498
+ const all = candidates.includes(current) ? candidates : [current, ...candidates];
499
+ return all.map(choice => `<option value="${escapeHtml(choice)}"${choice === current ? ' selected' : ''}>${escapeHtml(choice)}</option>`).join('');
500
+ }
501
+ /**
502
+ * Mount the panel on its element.
503
+ *
504
+ * Reads `status` once, then renders — and renders again after every action, because a control that
505
+ * reported an outcome without showing the state it produced would leave the reader unsure which of the two
506
+ * they are looking at.
507
+ * @param deps - the element, the transport and the microphone's own controls.
508
+ * @returns the panel's handle.
509
+ */
510
+ function mountStrip(deps) {
511
+ let reply = null;
512
+ const notes = {};
513
+ let notice = '';
514
+ const paint = () => {
515
+ deps.root.innerHTML = renderStrip({ reply, notes, notice, audio: deps.audio() });
516
+ };
517
+ const refresh = async () => {
518
+ try {
519
+ reply = await deps.request('status');
520
+ }
521
+ catch (error) {
522
+ // Nothing was sent, or nothing came back: the panel says which, rather than showing a stale state as
523
+ // if it were current.
524
+ notice = error instanceof Error ? error.message : String(error);
525
+ }
526
+ paint();
527
+ };
528
+ /**
529
+ * Send one frame and report what came back, then re-render.
530
+ * @param frame - the control frame.
531
+ * @param fieldKey - the setting the change was about, when it was about one.
532
+ * @param done - what to say when it worked.
533
+ */
534
+ const send = async (frame, fieldKey, done) => {
535
+ let line;
536
+ try {
537
+ const answer = await deps.request(frame);
538
+ // A refusal carries the reason written to be relayed verbatim, so it is relayed rather than
539
+ // translated — including for a field frozen by its class.
540
+ line = answer.ok ? done : `${answer.code ?? 'refused'}: ${answer.reason ?? 'no reason given'}`;
541
+ }
542
+ catch (error) {
543
+ line = error instanceof Error ? error.message : String(error);
544
+ }
545
+ if (fieldKey === undefined)
546
+ notice = line;
547
+ else
548
+ notes[fieldKey] = line;
549
+ await refresh();
550
+ };
551
+ const onClick = (event) => {
552
+ const action = event.target?.dataset?.action;
553
+ if (action === undefined)
554
+ return;
555
+ const key = event.target?.dataset?.key;
556
+ const value = key === undefined ? undefined : deps.root.querySelector(`[data-input="${key}"]`)?.value;
557
+ const act = async () => {
558
+ // Cleared per action: a notice from the last one is about the last one, and leaving it up would make
559
+ // a successful reconnect read as a failed one.
560
+ notice = '';
561
+ switch (action) {
562
+ case 'refresh':
563
+ await refresh();
564
+ return;
565
+ case 'disconnect':
566
+ deps.disconnect();
567
+ await refresh();
568
+ return;
569
+ case 'connect': {
570
+ const reached = await deps.connect();
571
+ if (reached.kind === 'failed')
572
+ notice = reached.reason;
573
+ await refresh();
574
+ return;
575
+ }
576
+ case 'voice-start':
577
+ await send('start', undefined, 'voice started');
578
+ return;
579
+ case 'voice-stop':
580
+ await send('stop', undefined, 'voice stopped');
581
+ return;
582
+ case 'steer':
583
+ await send(`steer ${value ?? ''}`, key, 'steered');
584
+ return;
585
+ case 'set': await send(`set ${key ?? ''}=${value ?? ''}`, key, 'applied');
586
+ }
587
+ };
588
+ void act();
589
+ };
590
+ deps.root.addEventListener('click', onClick);
591
+ return { refresh, state: () => ({ reply, notes, notice, audio: deps.audio() }) };
592
+ }
593
+ /**
594
+ * The client plugin. Publishes the client and the strip on a global, and releases them with the plugin.
595
+ *
596
+ * `inject` is empty on purpose: nothing here needs another plugin, and a client face that needs nothing
597
+ * cannot be broken by another plugin's absence.
598
+ *
599
+ * The global is **no longer the on-switch**. S2 story 3 put a panel in the app, and the panel's buttons
600
+ * are how anyone starts a microphone; the global is now the seam between the injected markup and this
601
+ * bundle, which is a different thing from a user interface that only exists in a devtools console.
294
602
  *
295
603
  * @param ctx - the client context, used only to own the lifetime.
296
604
  */
297
605
  function apply(ctx) {
298
606
  const client = createAudioClient(defaultDeps());
607
+ let mounted = false;
608
+ /**
609
+ * Render the panel into the element the injected markup provides.
610
+ *
611
+ * Called twice on purpose — once here, and once by the injected bootstrap script — because the two
612
+ * orders are both possible: this bundle is materialised by the module table, and the markup arrives with
613
+ * the body rows. Whichever runs second finds the panel already there and does nothing.
614
+ */
615
+ const mount = () => {
616
+ if (mounted)
617
+ return;
618
+ const root = stripRoot(globalThis);
619
+ if (root === null)
620
+ return;
621
+ mounted = true;
622
+ const strip = mountStrip({
623
+ root,
624
+ request: (frame) => client.request(frame),
625
+ connect: () => client.start(),
626
+ disconnect: () => { client.stop(); },
627
+ audio: () => client.state(),
628
+ });
629
+ void strip.refresh();
630
+ };
299
631
  globalThis[exports.GLOBAL_KEY] = {
300
632
  start: () => client.start(),
301
633
  stop: () => { client.stop(); },
302
634
  state: () => client.state(),
635
+ request: (frame) => client.request(frame),
636
+ mount,
303
637
  };
638
+ mount();
304
639
  // A held microphone and a live audio graph must not outlive the plugin that opened them.
305
640
  ctx.effect(() => () => { client.stop(); }, 'realtime-audio-client');
306
641
  }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The control channel: verbs, sent as text frames on the socket that already carries audio.
3
+ *
4
+ * Until this module there was no way to drive the plugin without a console. The socket was duplex and
5
+ * carried PCM16 in both directions, and a text frame was explicitly *not* part of its contract; the
6
+ * client half published itself on a `globalThis` key and that global was the on-switch. Two restarts
7
+ * and a false lead went into changing which session the voice steered, because the config was read at
8
+ * boot and only at boot.
9
+ *
10
+ * So this is a deliberate widening of a documented contract, and it is written as a **channel** rather
11
+ * than as a second API: one text frame in, exactly one text frame out, on the socket the client already
12
+ * holds.
13
+ *
14
+ * Five properties are load-bearing.
15
+ *
16
+ * - **Every frame is answered.** A control frame the channel cannot parse is answered with the reason,
17
+ * not dropped — the same rule `set` follows one layer down. A channel that reports nothing is
18
+ * indistinguishable from one that never received the frame.
19
+ * - **The reply is JSON, and small.** It carries the values a status surface needs and no prose to
20
+ * parse: `ok`, the verb as it was sent, and the fields for that verb.
21
+ * - **`steer` is a resolution, not a special case.** The verb addresses whichever plugin declares a live
22
+ * `sessionId`; it refuses with a reason rather than guessing when none or several do.
23
+ * - **`set` goes through the settings surface and nowhere else**, so a change on this channel takes the
24
+ * identical path — same parse, same refusal, same journal entry — as one from any other control plane.
25
+ * - **The verb vocabulary is closed.** A typo answers with the verbs that exist, because a silent
26
+ * fall-through is how a user concludes the feature is missing.
27
+ *
28
+ * @module dsh-realtime-audio-ws/control
29
+ */
30
+ import type { Journal, JournalEntry, RealtimeSettingInfo, RealtimeSettingRefusalCode, RealtimeSettings } from 'dsh-realtime';
31
+ import type { RealtimeSessionRefusal, RealtimeSessionRequestOutcome, RealtimeVoiceStatus } from 'dsh-realtime-agent';
32
+ /** The verbs this channel answers. Closed on purpose: see the module note. */
33
+ export type ControlVerb = 'status' | 'start' | 'stop' | 'steer' | 'set';
34
+ /**
35
+ * Why a control frame was refused.
36
+ *
37
+ * The three settings codes are carried through unchanged, because they are the seam's vocabulary and a
38
+ * client should branch on one set of codes rather than on a translation of them.
39
+ */
40
+ export type ControlRefusalCode =
41
+ /** The first word is not a verb this channel has. */
42
+ 'UNKNOWN_VERB'
43
+ /** The frame is malformed: a missing argument, an unexpected one, or a `set` with no `=`. */
44
+ | 'INVALID_CONTROL'
45
+ /** `steer` found no plugin declaring a live `sessionId`. */
46
+ | 'NO_STEER_TARGET'
47
+ /** `steer` found more than one, so the key to change is the caller's to name. */
48
+ | 'AMBIGUOUS_STEER_TARGET'
49
+ /** The verb could not be carried out at all — nothing answered a query it needs, or it threw. */
50
+ | 'CONTROL_FAILED'
51
+ /** No setting is registered under that key. */
52
+ | Extract<RealtimeSettingRefusalCode, 'UNKNOWN_SETTING'>
53
+ /** The setting exists and its class cannot honour a change: it needs a reconnect, or a restart. */
54
+ | Extract<RealtimeSettingRefusalCode, 'FROZEN_SETTING'>
55
+ /** The value did not parse, or the setting's own rules rejected it. */
56
+ | Extract<RealtimeSettingRefusalCode, 'INVALID_SETTING'>;
57
+ /** One parsed control frame. */
58
+ export type ControlCommand = {
59
+ readonly ok: true;
60
+ readonly verb: 'status' | 'start' | 'stop';
61
+ } | {
62
+ readonly ok: true;
63
+ readonly verb: 'steer';
64
+ readonly sessionId: string;
65
+ } | {
66
+ readonly ok: true;
67
+ readonly verb: 'set';
68
+ readonly key: string;
69
+ readonly value: string;
70
+ } | {
71
+ readonly ok: false;
72
+ readonly verb: string;
73
+ readonly code: ControlRefusalCode;
74
+ readonly reason: string;
75
+ };
76
+ /** A refusal, as any verb can produce one. */
77
+ export interface ControlRefusedReply {
78
+ readonly ok: false;
79
+ /** The verb as it was sent — including a word that is not a verb, so a client can pair the reply. */
80
+ readonly verb: string;
81
+ readonly code: ControlRefusalCode;
82
+ readonly reason: string;
83
+ /** The setting a `steer` or `set` addressed, when one was addressed. */
84
+ readonly key?: string;
85
+ }
86
+ /** `status`: what this route is carrying, what the voice is doing, and what a change can reach. */
87
+ export interface ControlStatusReply {
88
+ readonly ok: true;
89
+ readonly verb: 'status';
90
+ /** This route's own facts. */
91
+ readonly audio: {
92
+ readonly path: string;
93
+ readonly clients: number;
94
+ };
95
+ /**
96
+ * The voice session's state, or `null` when **no plugin answered the query** — which is what a
97
+ * composition with no agent row looks like, and is deliberately not the same as a session that is
98
+ * merely closed (that answers `open: false`).
99
+ */
100
+ readonly voice: RealtimeVoiceStatus | null;
101
+ /** Every setting the running plugins declare, in registration order, with the values read now. */
102
+ readonly settings: readonly RealtimeSettingInfo[];
103
+ /** The journal's size and its last entry — enough to see that *something* just happened. */
104
+ readonly journal: {
105
+ readonly size: number;
106
+ readonly oldestSeq: number | undefined;
107
+ readonly last: JournalEntry | null;
108
+ };
109
+ }
110
+ /** `start` / `stop`: what the request produced, not merely that it was made. */
111
+ export interface ControlSessionReply {
112
+ readonly ok: boolean;
113
+ readonly verb: 'start' | 'stop';
114
+ /** The state **after** the attempt. */
115
+ readonly voice: RealtimeVoiceStatus;
116
+ /** Present when the request did not achieve what it asked for. */
117
+ readonly refusal?: RealtimeSessionRefusal;
118
+ }
119
+ /** `set` / `steer`: the change that landed, and the value the setting now holds. */
120
+ export interface ControlSettingReply {
121
+ readonly ok: true;
122
+ readonly verb: 'set' | 'steer';
123
+ readonly key: string;
124
+ /** What the setting is now — re-read from its owner, and `undefined` when the setting is write-only. */
125
+ readonly value: unknown;
126
+ }
127
+ /** What one control frame produced. */
128
+ export type ControlReply = ControlRefusedReply | ControlStatusReply | ControlSessionReply | ControlSettingReply;
129
+ /** What {@link createControlHandler} needs from the plugin that owns the route. */
130
+ export interface ControlDeps {
131
+ /** The seam's settings surface: the only path a change takes. */
132
+ readonly settings: Pick<RealtimeSettings, 'list' | 'apply'>;
133
+ /** The seam's journal, for the size and the last entry a status reports. */
134
+ readonly journal: Pick<Journal, 'snapshot'>;
135
+ /** This route's own pathname, reported as-is. */
136
+ readonly path: string;
137
+ /** How many audio sockets this route is carrying right now. */
138
+ readonly clients: () => number;
139
+ /**
140
+ * The voice session's state.
141
+ *
142
+ * `undefined` means nothing answered; a rejection means something answered badly, and the two are kept
143
+ * apart — the first is a composition without the agent, the second is a finding.
144
+ */
145
+ readonly voice: () => Promise<RealtimeVoiceStatus | undefined>;
146
+ /** Run one of the two session requests the transport already makes. */
147
+ readonly request: (verb: 'start' | 'stop') => Promise<RealtimeSessionRequestOutcome | undefined>;
148
+ }
149
+ /**
150
+ * Read one text frame as a control command.
151
+ *
152
+ * A control frame is a **line**, so its line ending is stripped and nothing else is: `set x= 5` and
153
+ * `set x=5` are two different requests, and a channel that normalised one into the other would apply a
154
+ * change the caller did not ask for. For the same reason the verbs are matched exactly — an `INVALID_CONTROL`
155
+ * for `STATUS` is a better answer than a guess about intent, because it can be read and fixed.
156
+ *
157
+ * @param frame - the frame as it arrived, without its line ending.
158
+ * @returns the command, or a refusal naming the frame's fault.
159
+ */
160
+ export declare function parseControlFrame(frame: string): ControlCommand;
161
+ /**
162
+ * Build the channel's handler.
163
+ *
164
+ * The returned function is **total**: every frame it is given produces exactly one reply string, and it
165
+ * never rejects. That is a contract, not a hope — the caller writes the reply into a socket and has no
166
+ * one to catch a rejection, and a control frame that produces nothing at all is the silence this
167
+ * project keeps having to pay for.
168
+ *
169
+ * @param deps - the settings surface, the request edges, and this route's own facts.
170
+ * @returns a handler taking one frame and answering with the reply to send.
171
+ */
172
+ export declare function createControlHandler(deps: ControlDeps): (frame: string) => Promise<string>;
173
+ //# sourceMappingURL=control.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../src/control.ts"],"names":[],"mappings":"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"}