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.
@@ -83,7 +83,8 @@ export interface ContextLike {
83
83
  }
84
84
  export interface SocketLike {
85
85
  binaryType?: string
86
- send(data: Uint8Array): void
86
+ /** Binary frames carry audio; a string is a control frame. */
87
+ send(data: Uint8Array | string): void
87
88
  close(): void
88
89
  onopen: (() => void) | null
89
90
  onmessage: ((event: { data: unknown }) => void) | null
@@ -117,6 +118,38 @@ export type ClientAudioState =
117
118
  | { readonly kind: 'live' }
118
119
  | { readonly kind: 'failed'; readonly reason: string }
119
120
 
121
+ /** One setting as `status` reports it. Structural, like everything else here: a client face imports nothing. */
122
+ export interface SettingLike {
123
+ readonly key: string
124
+ readonly field: string
125
+ readonly kind: string
126
+ readonly scope: string
127
+ readonly describe?: string
128
+ readonly value?: unknown
129
+ /** The values a picker may offer. Present only when the owner declared a set to pick from. */
130
+ readonly choices?: readonly string[]
131
+ }
132
+
133
+ /**
134
+ * One control reply, as the panel reads it.
135
+ *
136
+ * Described rather than imported, for the reason the whole file describes its APIs: a type-only import
137
+ * would still pull the host module into this program, and a second emitted file is one the module table
138
+ * never serves.
139
+ */
140
+ export interface ControlReplyLike {
141
+ readonly ok: boolean
142
+ readonly verb?: string
143
+ readonly code?: string
144
+ readonly reason?: string
145
+ readonly key?: string
146
+ readonly value?: unknown
147
+ readonly voice?: { readonly open: boolean; readonly provider?: string; readonly model?: string; readonly sessionId?: string } | null
148
+ readonly audio?: { readonly path?: string; readonly clients?: number }
149
+ readonly settings?: readonly SettingLike[]
150
+ readonly journal?: { readonly size?: number; readonly last?: { readonly kind?: string } | null }
151
+ }
152
+
120
153
  export interface ClientAudio {
121
154
  /** Acquire the microphone and connect. Resolves with the state it reached. */
122
155
  start(): Promise<ClientAudioState>
@@ -124,6 +157,19 @@ export interface ClientAudio {
124
157
  stop(): void
125
158
  /** Current state. */
126
159
  state(): ClientAudioState
160
+ /**
161
+ * Send one control frame and resolve with its reply.
162
+ *
163
+ * The panel's whole transport. Requests are serialised: the host answers in arrival order, but the
164
+ * *client* still sends one frame at a time, because a caller that pipelined would be pairing replies
165
+ * with frames by guesswork the moment anything on the host slowed down.
166
+ *
167
+ * Rejects — rather than inventing a reply — when there is no socket: nothing was sent, so there is
168
+ * nothing the channel could have said about it.
169
+ * @param frame - one control frame, e.g. `status` or `set key=value`.
170
+ * @returns the host's reply, already parsed.
171
+ */
172
+ request(frame: string): Promise<ControlReplyLike>
127
173
  }
128
174
 
129
175
  // ---- pure helpers --------------------------------------------------------------------------------------
@@ -256,6 +302,10 @@ export function createAudioClient(deps: ClientAudioDeps): ClientAudio {
256
302
  let processor: MediaNodeLike | undefined
257
303
  let silence: MediaNodeLike | undefined
258
304
  let playsAt = 0
305
+ /** The control request waiting for its reply, if any. The host answers one frame with one frame. */
306
+ let pending: { resolve: (reply: ControlReplyLike) => void; reject: (error: Error) => void } | undefined
307
+ /** The tail of the request chain, so two frames are never in flight at once. */
308
+ let chain: Promise<unknown> = Promise.resolve()
259
309
 
260
310
  const fail = (reason: string): ClientAudioState => {
261
311
  current = { kind: 'failed', reason }
@@ -264,6 +314,10 @@ export function createAudioClient(deps: ClientAudioDeps): ClientAudio {
264
314
 
265
315
  /** Release everything held. Idempotent, because stop, an error and a close all reach it. */
266
316
  const release = (): void => {
317
+ // A request whose socket is going away must fail rather than hang: the panel disables half of itself
318
+ // while one is in flight, and a promise that never settles would leave it disabled for ever.
319
+ pending?.reject(new Error('the audio socket closed before the host answered'))
320
+ pending = undefined
267
321
  socket?.close()
268
322
  socket = undefined
269
323
  processor?.disconnect()
@@ -361,7 +415,23 @@ export function createAudioClient(deps: ClientAudioDeps): ClientAudio {
361
415
  socket = opened
362
416
  opened.binaryType = 'arraybuffer'
363
417
  opened.onmessage = (event) => {
364
- if (event.data instanceof ArrayBuffer) play(new Uint8Array(event.data))
418
+ // Binary frames are audio; a string is the answer to a control frame. The frame's own type is what
419
+ // tells them apart, which is why the control channel cost this path nothing.
420
+ if (event.data instanceof ArrayBuffer) {
421
+ play(new Uint8Array(event.data))
422
+ return
423
+ }
424
+ if (typeof event.data !== 'string') return
425
+ const waiting = pending
426
+ pending = undefined
427
+ if (waiting === undefined) return
428
+ try {
429
+ waiting.resolve(JSON.parse(event.data) as ControlReplyLike)
430
+ } catch {
431
+ // A reply that is not JSON is a host fault, and the caller has to hear about it rather than be left
432
+ // waiting: the same rule the channel itself follows, one side out.
433
+ waiting.reject(new Error('the host answered a control frame with something that is not a reply'))
434
+ }
365
435
  }
366
436
  opened.onclose = () => {
367
437
  release()
@@ -373,24 +443,383 @@ export function createAudioClient(deps: ClientAudioDeps): ClientAudio {
373
443
  })
374
444
  }
375
445
 
376
- return { start, stop, state: () => current }
446
+ /**
447
+ * Send one frame and wait for its reply.
448
+ *
449
+ * The socket is looked up at the moment of use rather than closed over, so a request made after a
450
+ * reconnect goes out on the socket that exists now — the same rule every other live value in this
451
+ * project follows.
452
+ * @param frame - the control frame.
453
+ * @returns the parsed reply.
454
+ */
455
+ const sendFrame = (frame: string): Promise<ControlReplyLike> => {
456
+ const current = socket
457
+ if (current === undefined) {
458
+ return Promise.reject(new Error('not connected: connect the microphone first'))
459
+ }
460
+ return new Promise<ControlReplyLike>((resolve, reject) => {
461
+ pending = { resolve, reject }
462
+ current.send(frame)
463
+ })
464
+ }
465
+
466
+ const request = (frame: string): Promise<ControlReplyLike> => {
467
+ // Chained, not concurrent: the host answers in arrival order, and a client that pipelined would be
468
+ // pairing replies with frames by guesswork. The chain is kept alive across a failure, because one
469
+ // refused frame must not silently stop every frame behind it.
470
+ const next = chain.then(() => sendFrame(frame))
471
+ chain = next.catch(() => undefined)
472
+ return next
473
+ }
474
+
475
+ return { start, stop, state: () => current, request }
377
476
  }
378
477
 
478
+ // ---- the strip: the panel this plugin puts in the app ---------------------------------------------------
479
+
480
+ /** The element the injected markup provides. Duplicated in the host half, which mints the row. */
481
+ export const STRIP_ELEMENT_ID = 'dsh-realtime-strip'
482
+
379
483
  /**
380
- * The client plugin. Publishes the client on a global and releases it with the plugin.
484
+ * The minimum of a DOM element this file uses.
381
485
  *
382
- * There is no UI surface yet, so a global is how it is driven — a settings card is its own increment and a
383
- * bigger one than this. `inject` is empty for the same reason: nothing here needs another plugin.
486
+ * Structural, like everything else here, and for a second reason beyond testability: the panel's logic
487
+ * lives *in this bundle* rather than in the injected script, so what it does is covered by tests instead of
488
+ * sitting in a string that nothing ever executes.
489
+ */
490
+ export interface StripElement {
491
+ innerHTML: string
492
+ /** An input's or a select's current value. Absent on everything else. */
493
+ value?: string
494
+ addEventListener(type: 'click' | 'change', listener: (event: StripEvent) => void): void
495
+ querySelector(selector: string): StripElement | null
496
+ }
497
+
498
+ /** A click or a change, as much of it as the panel reads. */
499
+ export interface StripEvent {
500
+ readonly target: { readonly dataset?: Record<string, string | undefined>; readonly value?: string } | null
501
+ }
502
+
503
+ /** The page scope the panel looks itself up in. */
504
+ export interface StripScope {
505
+ readonly document?: { getElementById(id: string): StripElement | null }
506
+ }
507
+
508
+ /** What the panel is showing, exposed so a test can read it rather than scrape the markup. */
509
+ export interface StripState {
510
+ /** The last status, or `null` before one has arrived. */
511
+ readonly reply: ControlReplyLike | null
512
+ /** Per-setting notes, keyed by the setting's key: what the last change to *it* produced. */
513
+ readonly notes: Readonly<Record<string, string>>
514
+ /** Anything with no field to belong to — a transport failure, or what a start or stop produced. */
515
+ readonly notice: string
516
+ /** What the microphone is doing. */
517
+ readonly audio: ClientAudioState
518
+ }
519
+
520
+ export interface StripDeps {
521
+ readonly root: StripElement
522
+ readonly request: (frame: string) => Promise<ControlReplyLike>
523
+ /** Connect the microphone, resolving with the state the attempt reached. */
524
+ readonly connect: () => Promise<ClientAudioState>
525
+ readonly disconnect: () => void
526
+ readonly audio: () => ClientAudioState
527
+ }
528
+
529
+ export interface Strip {
530
+ /** Ask the host for a status and render whatever comes back. */
531
+ refresh(): Promise<void>
532
+ /** What is on screen now. */
533
+ state(): StripState
534
+ }
535
+
536
+ /**
537
+ * Where the injected markup puts the panel, or `null` when this page does not carry it.
538
+ * @param scope - the page scope, injectable so both cases are testable without a DOM.
539
+ * @returns the panel's element, or `null`.
540
+ */
541
+ export function stripRoot(scope: StripScope): StripElement | null {
542
+ return scope.document?.getElementById(STRIP_ELEMENT_ID) ?? null
543
+ }
544
+
545
+ /**
546
+ * Escape text for an element's body and for a quoted attribute at once.
547
+ *
548
+ * One function rather than two because the two contexts differ only in which characters break out, and
549
+ * every value a panel renders came from a plugin's own configuration — a session id, a model name, or
550
+ * `instructions` somebody typed.
551
+ * @param text - the text to escape.
552
+ * @returns the text, safe in either position.
553
+ */
554
+ function escapeHtml(text: string): string {
555
+ return text
556
+ .replaceAll('&', '&amp;')
557
+ .replaceAll('<', '&lt;')
558
+ .replaceAll('>', '&gt;')
559
+ .replaceAll('"', '&quot;')
560
+ }
561
+
562
+ /**
563
+ * Show one value as text.
564
+ * @param value - a setting's value as `status` reported it.
565
+ * @returns the text to render.
566
+ */
567
+ function show(value: unknown): string {
568
+ if (value === undefined || value === null) return '—'
569
+ return typeof value === 'string' ? value : JSON.stringify(value)
570
+ }
571
+
572
+ /**
573
+ * Render the whole panel.
574
+ *
575
+ * A pure function of the state, because that is what makes the panel testable at all: the injected script
576
+ * is three lines and the DOM is one assignment, so everything worth checking lives here.
577
+ * @param state - what the panel is showing.
578
+ * @returns the markup for the panel's element.
579
+ */
580
+ export function renderStrip(state: StripState): string {
581
+ const reply = state.reply
582
+ const lines = [`<p data-dsh-strip-voice>${escapeHtml(summariseVoice(reply, state.audio))}</p>`]
583
+ if (reply !== null) lines.push(`<p data-dsh-strip-route>${escapeHtml(summariseRoute(reply))}</p>`)
584
+ if (state.notice !== '') lines.push(`<p data-dsh-strip-notice>${escapeHtml(state.notice)}</p>`)
585
+ lines.push(`<p data-dsh-strip-buttons>${buttons(state.audio)}</p>`)
586
+ // Restart-bound settings are omitted rather than shown read-only: the gate says no control at all, and a
587
+ // row that cannot be changed is a row a reader will try to change.
588
+ const settings = (reply?.settings ?? []).filter(entry => entry.scope !== 'restart')
589
+ lines.push(settings.length === 0
590
+ ? '<p data-dsh-strip-empty>no settings to show</p>'
591
+ : `<ul>${settings.map(entry => settingRow(entry, state.notes)).join('')}</ul>`)
592
+ return lines.join('')
593
+ }
594
+
595
+ /**
596
+ * The voice line: whether a session is live, and what it is.
597
+ * @param reply - the last status, if any.
598
+ * @param audio - what the microphone is doing.
599
+ * @returns one line of plain text.
600
+ */
601
+ function summariseVoice(reply: ControlReplyLike | null, audio: ClientAudioState): string {
602
+ const microphone = audio.kind === 'failed' ? `microphone: failed — ${audio.reason}` : `microphone: ${audio.kind}`
603
+ if (reply === null) return `no answer yet · ${microphone}`
604
+ const voice = reply.voice
605
+ // `null` is "nobody answered the question", which is a composition without an agent row — deliberately
606
+ // not the same statement as a session that is merely closed.
607
+ if (voice === null || voice === undefined) return `voice: no agent is mounted in this profile · ${microphone}`
608
+ const where = voice.provider === undefined ? '' : ` · ${voice.provider}/${voice.model ?? '?'}`
609
+ const which = voice.sessionId === undefined ? '' : ` · ${voice.sessionId}`
610
+ return `voice: ${voice.open ? 'open' : 'closed'}${where}${which} · ${microphone}`
611
+ }
612
+
613
+ /**
614
+ * The route line: what this socket is, and the last thing the journal recorded.
615
+ * @param reply - the last status.
616
+ * @returns one line of plain text.
617
+ */
618
+ function summariseRoute(reply: ControlReplyLike): string {
619
+ const last = reply.journal?.last?.kind
620
+ return [
621
+ reply.audio?.path ?? 'the audio route',
622
+ `${String(reply.audio?.clients ?? 0)} socket(s)`,
623
+ `journal ${String(reply.journal?.size ?? 0)}`,
624
+ ...last === undefined ? [] : [`last ${last}`],
625
+ ].join(' · ')
626
+ }
627
+
628
+ /**
629
+ * The buttons above the settings.
630
+ *
631
+ * The microphone's own button is the on-switch this sprint exists to provide: what used to be a
632
+ * `globalThis` call in a devtools console is now the first control in the panel.
633
+ * @param audio - what the microphone is doing.
634
+ * @returns the markup.
635
+ */
636
+ function buttons(audio: ClientAudioState): string {
637
+ const live = audio.kind === 'live'
638
+ return [
639
+ `<button data-action="${live ? 'disconnect' : 'connect'}">${live ? 'Disconnect microphone' : 'Connect microphone'}</button>`,
640
+ '<button data-action="voice-start">Start voice</button>',
641
+ '<button data-action="voice-stop">Stop voice</button>',
642
+ '<button data-action="refresh">Refresh</button>',
643
+ ].join(' ')
644
+ }
645
+
646
+ /**
647
+ * One setting's row.
648
+ *
649
+ * Three classes, three treatments, and the gate's rule is the reason: a live field gets a control, a
650
+ * session-bound field says what it would take to change it, and a restart-bound field is not rendered.
651
+ * @param entry - the setting as `status` reported it.
652
+ * @param notes - what the last change to each setting produced.
653
+ * @returns the markup for one row.
654
+ */
655
+ function settingRow(entry: SettingLike, notes: Readonly<Record<string, string>>): string {
656
+ const key = escapeHtml(entry.key)
657
+ const label = `<span data-dsh-strip-label>${escapeHtml(entry.describe ?? entry.field)}</span>`
658
+ const note = notes[entry.key] === undefined
659
+ ? ''
660
+ : `<em data-note="${key}">${escapeHtml(notes[entry.key]!)}</em>`
661
+ if (entry.scope !== 'live') {
662
+ return `<li data-key="${key}" data-scope="session">${label}<b>${escapeHtml(show(entry.value))}</b>`
663
+ + `<em>fixed when the session opens — reconnect to apply</em>${note}</li>`
664
+ }
665
+ // The channel has a verb for exactly one purpose — steering at a session — and this is the field it
666
+ // means. Everything else goes through `set <key>=<value>`.
667
+ const action = entry.field === 'sessionId' && entry.kind === 'string' ? 'steer' : 'set'
668
+ const control = entry.choices !== undefined && entry.choices.length > 0
669
+ ? `<select data-input="${key}">${choiceOptions(entry, entry.choices)}</select>`
670
+ : `<input data-input="${key}" value="${escapeHtml(show(entry.value))}">`
671
+ const name = action === 'steer' ? 'Steer' : 'Set'
672
+ return `<li data-key="${key}" data-scope="live">${label}${control}`
673
+ + `<button data-action="${action}" data-key="${key}">${name}</button>${note}</li>`
674
+ }
675
+
676
+ /**
677
+ * A picker's options, with the value in force always among them.
678
+ * @param entry - the setting, for its value.
679
+ * @param candidates - the values it declared. Passed in rather than read here: this is only ever called
680
+ * for a setting that has a non-empty list, and a fallback for the case that cannot happen is a branch
681
+ * the coverage gate rightly flags as dead.
682
+ * @returns the markup for each option.
683
+ */
684
+ function choiceOptions(entry: SettingLike, candidates: readonly string[]): string {
685
+ const current = show(entry.value)
686
+ // A value that is not among the candidates is offered anyway. A select that silently showed the first
687
+ // candidate instead of the value actually in force would be lying about the state, which is the one
688
+ // thing a control plane must not do.
689
+ const all = candidates.includes(current) ? candidates : [current, ...candidates]
690
+ return all.map(choice =>
691
+ `<option value="${escapeHtml(choice)}"${choice === current ? ' selected' : ''}>${escapeHtml(choice)}</option>`,
692
+ ).join('')
693
+ }
694
+
695
+ /**
696
+ * Mount the panel on its element.
697
+ *
698
+ * Reads `status` once, then renders — and renders again after every action, because a control that
699
+ * reported an outcome without showing the state it produced would leave the reader unsure which of the two
700
+ * they are looking at.
701
+ * @param deps - the element, the transport and the microphone's own controls.
702
+ * @returns the panel's handle.
703
+ */
704
+ export function mountStrip(deps: StripDeps): Strip {
705
+ let reply: ControlReplyLike | null = null
706
+ const notes: Record<string, string> = {}
707
+ let notice = ''
708
+
709
+ const paint = (): void => {
710
+ deps.root.innerHTML = renderStrip({ reply, notes, notice, audio: deps.audio() })
711
+ }
712
+
713
+ const refresh = async (): Promise<void> => {
714
+ try {
715
+ reply = await deps.request('status')
716
+ } catch (error) {
717
+ // Nothing was sent, or nothing came back: the panel says which, rather than showing a stale state as
718
+ // if it were current.
719
+ notice = error instanceof Error ? error.message : String(error)
720
+ }
721
+ paint()
722
+ }
723
+
724
+ /**
725
+ * Send one frame and report what came back, then re-render.
726
+ * @param frame - the control frame.
727
+ * @param fieldKey - the setting the change was about, when it was about one.
728
+ * @param done - what to say when it worked.
729
+ */
730
+ const send = async (frame: string, fieldKey: string | undefined, done: string): Promise<void> => {
731
+ let line: string
732
+ try {
733
+ const answer = await deps.request(frame)
734
+ // A refusal carries the reason written to be relayed verbatim, so it is relayed rather than
735
+ // translated — including for a field frozen by its class.
736
+ line = answer.ok ? done : `${answer.code ?? 'refused'}: ${answer.reason ?? 'no reason given'}`
737
+ } catch (error) {
738
+ line = error instanceof Error ? error.message : String(error)
739
+ }
740
+ if (fieldKey === undefined) notice = line
741
+ else notes[fieldKey] = line
742
+ await refresh()
743
+ }
744
+
745
+ const onClick = (event: StripEvent): void => {
746
+ const action = event.target?.dataset?.action
747
+ if (action === undefined) return
748
+ const key = event.target?.dataset?.key
749
+ const value = key === undefined ? undefined : deps.root.querySelector(`[data-input="${key}"]`)?.value
750
+ const act = async (): Promise<void> => {
751
+ // Cleared per action: a notice from the last one is about the last one, and leaving it up would make
752
+ // a successful reconnect read as a failed one.
753
+ notice = ''
754
+ switch (action) {
755
+ case 'refresh': await refresh(); return
756
+ case 'disconnect': deps.disconnect(); await refresh(); return
757
+ case 'connect': {
758
+ const reached = await deps.connect()
759
+ if (reached.kind === 'failed') notice = reached.reason
760
+ await refresh()
761
+ return
762
+ }
763
+ case 'voice-start': await send('start', undefined, 'voice started'); return
764
+ case 'voice-stop': await send('stop', undefined, 'voice stopped'); return
765
+ case 'steer': await send(`steer ${value ?? ''}`, key, 'steered'); return
766
+ case 'set': await send(`set ${key ?? ''}=${value ?? ''}`, key, 'applied')
767
+ }
768
+ }
769
+ void act()
770
+ }
771
+
772
+ deps.root.addEventListener('click', onClick)
773
+
774
+ return { refresh, state: () => ({ reply, notes, notice, audio: deps.audio() }) }
775
+ }
776
+
777
+ /**
778
+ * The client plugin. Publishes the client and the strip on a global, and releases them with the plugin.
779
+ *
780
+ * `inject` is empty on purpose: nothing here needs another plugin, and a client face that needs nothing
781
+ * cannot be broken by another plugin's absence.
782
+ *
783
+ * The global is **no longer the on-switch**. S2 story 3 put a panel in the app, and the panel's buttons
784
+ * are how anyone starts a microphone; the global is now the seam between the injected markup and this
785
+ * bundle, which is a different thing from a user interface that only exists in a devtools console.
384
786
  *
385
787
  * @param ctx - the client context, used only to own the lifetime.
386
788
  */
387
789
  export function apply(ctx: ClientContextLike): void {
388
790
  const client = createAudioClient(defaultDeps())
791
+ let mounted = false
792
+
793
+ /**
794
+ * Render the panel into the element the injected markup provides.
795
+ *
796
+ * Called twice on purpose — once here, and once by the injected bootstrap script — because the two
797
+ * orders are both possible: this bundle is materialised by the module table, and the markup arrives with
798
+ * the body rows. Whichever runs second finds the panel already there and does nothing.
799
+ */
800
+ const mount = (): void => {
801
+ if (mounted) return
802
+ const root = stripRoot(globalThis as unknown as StripScope)
803
+ if (root === null) return
804
+ mounted = true
805
+ const strip = mountStrip({
806
+ root,
807
+ request: (frame) => client.request(frame),
808
+ connect: () => client.start(),
809
+ disconnect: () => { client.stop() },
810
+ audio: () => client.state(),
811
+ })
812
+ void strip.refresh()
813
+ }
814
+
389
815
  ;(globalThis as unknown as Record<string, unknown>)[GLOBAL_KEY] = {
390
816
  start: () => client.start(),
391
817
  stop: () => { client.stop() },
392
818
  state: () => client.state(),
819
+ request: (frame: string) => client.request(frame),
820
+ mount,
393
821
  }
822
+ mount()
394
823
  // A held microphone and a live audio graph must not outlive the plugin that opened them.
395
824
  ctx.effect(() => () => { client.stop() }, 'realtime-audio-client')
396
825
  }