dsh-realtime-audio-ws 0.2.2 → 0.2.4

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