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.
- package/README.md +64 -12
- package/lib/bridge.d.ts +24 -0
- package/lib/bridge.d.ts.map +1 -1
- package/lib/bridge.js +47 -3
- package/lib/bridge.js.map +1 -1
- package/lib/client/bundle.js +341 -6
- package/lib/client/index.js +341 -6
- package/lib/control.d.ts +173 -0
- package/lib/control.d.ts.map +1 -0
- package/lib/control.js +286 -0
- package/lib/control.js.map +1 -0
- package/lib/index.d.ts +4 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +60 -3
- package/lib/index.js.map +1 -1
- package/lib/injection.d.ts +18 -0
- package/lib/injection.d.ts.map +1 -1
- package/lib/injection.js.map +1 -1
- package/lib/strip.d.ts +64 -0
- package/lib/strip.d.ts.map +1 -0
- package/lib/strip.js +85 -0
- package/lib/strip.js.map +1 -0
- package/lib/types.d.ts +1 -1
- package/lib/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/bridge.ts +59 -3
- package/src/client/index.ts +435 -6
- package/src/control.ts +402 -0
- package/src/index.ts +78 -5
- package/src/injection.ts +14 -0
- package/src/strip.ts +90 -0
- package/src/types.ts +1 -1
package/src/client/index.ts
CHANGED
|
@@ -83,7 +83,8 @@ export interface ContextLike {
|
|
|
83
83
|
}
|
|
84
84
|
export interface SocketLike {
|
|
85
85
|
binaryType?: string
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
484
|
+
* The minimum of a DOM element this file uses.
|
|
381
485
|
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
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('&', '&')
|
|
557
|
+
.replaceAll('<', '<')
|
|
558
|
+
.replaceAll('>', '>')
|
|
559
|
+
.replaceAll('"', '"')
|
|
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
|
}
|