dsh-realtime-audio-ws 0.2.1 → 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/diagnostics.d.ts.map +1 -1
- package/lib/diagnostics.js +42 -4
- package/lib/diagnostics.js.map +1 -1
- 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/diagnostics.ts +42 -4
- 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/README.md
CHANGED
|
@@ -43,8 +43,27 @@ first — which is the only reason to trust it.
|
|
|
43
43
|
|
|
44
44
|
## The wire
|
|
45
45
|
|
|
46
|
-
Binary frames
|
|
47
|
-
the shipped adapter).
|
|
46
|
+
Binary frames carry audio, both directions, in the session's declared input/output format (PCM16, 24 kHz,
|
|
47
|
+
mono, for the shipped adapter). **Text frames carry control:**
|
|
48
|
+
|
|
49
|
+
| Frame | Answer |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `status` | this route, the live voice, every setting, the journal's last entry |
|
|
52
|
+
| `start` | the session that opened — an outcome, not an acknowledgement |
|
|
53
|
+
| `stop` | the state afterwards |
|
|
54
|
+
| `steer <sessionId>` | the setting that declares a live `sessionId`, changed |
|
|
55
|
+
| `set <key>=<value>` | the setting named by its `<owner>.<field>` key, changed |
|
|
56
|
+
|
|
57
|
+
One frame in, exactly one frame out, and **every** frame is answered — a verb it does not have, an argument
|
|
58
|
+
where none belongs, a field frozen by its class, a value the setting's own rules reject. The reply is JSON:
|
|
59
|
+
`{"ok":true,…}` with the fields for that verb, or `{"ok":false,"verb":…,"code":…,"reason":…}` with a reason
|
|
60
|
+
written to be relayed verbatim. `set realtime-agent.autoStart=true` answers with the *restart* it needs
|
|
61
|
+
rather than a silence that reads as a broken control.
|
|
62
|
+
|
|
63
|
+
This was a deliberate widening of a contract that used to say the opposite — a text frame was ignored,
|
|
64
|
+
because nothing on this wire was JSON. The socket is the only duplex connection the client already holds,
|
|
65
|
+
one text frame in / one text frame out is smaller than a second route with its own authentication, and the
|
|
66
|
+
audio path is untouched: the two are told apart by the frame's own type.
|
|
48
67
|
|
|
49
68
|
Frames are forwarded, never queued. A client that cannot keep up drops audio instead of accumulating it,
|
|
50
69
|
which matches the `realtime-agent/audio` contract — a queue that grows while nothing drains it presents
|
|
@@ -60,6 +79,14 @@ A frame larger than `maxFrameBytes` closes the connection with 1009 rather than
|
|
|
60
79
|
| `maxFrameBytes` | `480000` | Ten seconds of 24 kHz mono PCM16. Above this the connection is closed with 1009. |
|
|
61
80
|
| `maxConnections` | `1` | A second connection is closed with 1013 rather than queued. One microphone is the honest default. |
|
|
62
81
|
|
|
82
|
+
`openSessionOnConnect` (default `true`) is **live**: read at the moment an authenticated client connects
|
|
83
|
+
and at the moment the last one leaves, so the seam's settings surface turns it off and on without a
|
|
84
|
+
restart — `set realtime-audio-ws.openSessionOnConnect=false`. Freezing it was the failure this plugin's
|
|
85
|
+
own docs describe: with it off and no way to change it, a working microphone produces silence that looks
|
|
86
|
+
like a fault anywhere but in the config. The rest of this row's fields (`path`, `maxFrameBytes`,
|
|
87
|
+
`maxConnections`, `diagnosticsPath`) are claimed against the web server's registry or enforced by the
|
|
88
|
+
socket at load, so they are restart-bound and get no control.
|
|
89
|
+
|
|
63
90
|
## Where it waits
|
|
64
91
|
|
|
65
92
|
This row appears **unloaded** in a composition without the web server and connection services, and that is
|
|
@@ -70,20 +97,41 @@ visibly waits rather than one that loads and silently claims nothing. The bundle
|
|
|
70
97
|
## The client half
|
|
71
98
|
|
|
72
99
|
`dsh-realtime-audio-ws/client` is the browser face: it opens this socket, captures the microphone into it at
|
|
73
|
-
24 kHz PCM16,
|
|
74
|
-
|
|
100
|
+
24 kHz PCM16, plays what comes back, and drives the strip. No client services, one file.
|
|
101
|
+
|
|
102
|
+
**How it runs.** The strip in the app's own page is the way in — it is mounted by the injected row and by
|
|
103
|
+
the bundle itself, whichever lands second finding the panel already there:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
[ microphone: idle ] [ Connect microphone ] [ Refresh ]
|
|
107
|
+
voice: open · fake/gpt-live-1
|
|
108
|
+
realtime-responder.sessionId [ sess-1 ▾ ] [ Steer ] applied
|
|
109
|
+
realtime-responder.answerTimeoutMs [ 45000 ] [ Set ]
|
|
110
|
+
realtime-agent.model fixed when the session opens — reconnect to apply
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A live field gets a control, a session-bound field gets the value it will take plus the words "reconnect to
|
|
114
|
+
apply", and a restart-bound field gets no row at all — a control the protocol cannot honour is worse than no
|
|
115
|
+
control. Every `set` reports its outcome beside the field it belongs to, and a refusal is relayed verbatim
|
|
116
|
+
with the host's own code: `FROZEN_SETTING: "…" is claimed when the plugin loads — restart to change it`.
|
|
75
117
|
|
|
76
|
-
|
|
118
|
+
The same handle stays published on `globalThis[GLOBAL_KEY]` for the injected bootstrap and for anything else
|
|
119
|
+
that holds it — `start`, `stop`, `state`, `request`, `mount` — but it is no longer the on-switch:
|
|
77
120
|
|
|
78
121
|
```js
|
|
79
|
-
|
|
80
|
-
__dshRealtimeAudio.state()
|
|
81
|
-
__dshRealtimeAudio.
|
|
122
|
+
__dshRealtimeAudio.start() // asks for the microphone, then connects
|
|
123
|
+
__dshRealtimeAudio.state() // { kind: 'idle' | 'live' | 'failed' }
|
|
124
|
+
__dshRealtimeAudio.request('status') // one text frame in, exactly one reply out
|
|
82
125
|
```
|
|
83
126
|
|
|
84
127
|
`start()` **reports rather than throws**, so a refused permission or a missing API arrives as
|
|
85
128
|
`{ kind: 'failed', reason }` — the reason is what tells someone whether to grant something or to look
|
|
86
|
-
somewhere else.
|
|
129
|
+
somewhere else.
|
|
130
|
+
|
|
131
|
+
**Requests are serialised.** One frame is in flight at a time: the host answers in arrival order, and two
|
|
132
|
+
frames out at once would make pairing a reply with its request a guess the moment anything on the host
|
|
133
|
+
slowed down. A request with no socket is a rejection — reporting `{ok: false}` there would be
|
|
134
|
+
indistinguishable from the host refusing.
|
|
87
135
|
|
|
88
136
|
**Two decisions inside it worth knowing.**
|
|
89
137
|
|
|
@@ -99,6 +147,10 @@ will not open at 24 kHz the client fails and says which rate it got.
|
|
|
99
147
|
|
|
100
148
|
## What it does not do yet
|
|
101
149
|
|
|
102
|
-
**No format negotiation, and no reconnect.** A `ready` frame carrying sample rate and channel count
|
|
103
|
-
|
|
104
|
-
rejoined. Both are deliberately absent rather than half-built.
|
|
150
|
+
**No format negotiation, and no reconnect.** A `ready` frame carrying sample rate and channel count is the
|
|
151
|
+
obvious next control *frame* — the control channel is now there to carry one — and a dropped socket still
|
|
152
|
+
ends the conversation rather than being rejoined. Both are deliberately absent rather than half-built.
|
|
153
|
+
|
|
154
|
+
**The panel has no keyboard path and no permissions row.** The strip is clickable controls and text, so a
|
|
155
|
+
screen reader gets it, but nothing focuses it and the microphone's permission state is reported rather than
|
|
156
|
+
requested ahead of time. Both are increments, not gaps in the channel.
|
package/lib/bridge.d.ts
CHANGED
|
@@ -12,6 +12,20 @@ export interface AudioSocketBridgeDeps {
|
|
|
12
12
|
readonly subscribeAudio: (listener: (pcm16: Uint8Array) => void) => () => void;
|
|
13
13
|
/** Longest inbound frame accepted, in bytes. */
|
|
14
14
|
readonly maxFrameBytes: number;
|
|
15
|
+
/**
|
|
16
|
+
* Handle one **control frame** — a text frame on this socket.
|
|
17
|
+
*
|
|
18
|
+
* Until S2 story 2 a text frame was not part of this contract, and the comment that said so was right
|
|
19
|
+
* about the wire as it was: the socket carried PCM16 in both directions and nothing else. It now
|
|
20
|
+
* carries a channel too, because it is the only duplex connection the client already holds, and one
|
|
21
|
+
* text frame in / one text frame out is a smaller contract than a second route with its own
|
|
22
|
+
* authentication would have been.
|
|
23
|
+
*
|
|
24
|
+
* `reply` writes exactly one text frame back, and silently drops it once the socket is gone: a control
|
|
25
|
+
* handler is asynchronous, and a reply to a closed socket would throw from inside a promise nobody
|
|
26
|
+
* awaits.
|
|
27
|
+
*/
|
|
28
|
+
readonly onControl: (frame: string, reply: (text: string) => void) => void;
|
|
15
29
|
/** Called exactly once when the bridge stops, however it stops. */
|
|
16
30
|
readonly onDetach: () => void;
|
|
17
31
|
}
|
|
@@ -26,6 +40,16 @@ export interface AudioSocketBridgeDeps {
|
|
|
26
40
|
* @returns the payload as bytes.
|
|
27
41
|
*/
|
|
28
42
|
export declare function toBytes(data: unknown): Uint8Array;
|
|
43
|
+
/**
|
|
44
|
+
* Normalise a `ws` message payload to text.
|
|
45
|
+
*
|
|
46
|
+
* The same three shapes {@link toBytes} handles, decoded as UTF-8 — a control frame arrives as a Buffer
|
|
47
|
+
* whichever way it was framed, and a fragmented one as an array of them.
|
|
48
|
+
*
|
|
49
|
+
* @param data - the payload as `ws` delivered it.
|
|
50
|
+
* @returns the payload as text.
|
|
51
|
+
*/
|
|
52
|
+
export declare function toText(data: unknown): string;
|
|
29
53
|
/**
|
|
30
54
|
* Wire one accepted socket to the agent's audio events.
|
|
31
55
|
*
|
package/lib/bridge.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../src/bridge.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAE7C,MAAM,WAAW,qBAAqB;IACpC,6DAA6D;IAC7D,QAAQ,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAA;IAC7C,qEAAqE;IACrE,QAAQ,CAAC,cAAc,EAAE,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,KAAK,MAAM,IAAI,CAAA;IAC9E,gDAAgD;IAChD,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,mEAAmE;IACnE,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,CAAA;CAC9B;AAED;;;;;;;;;GASG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,OAAO,GAAG,UAAU,CAKjD;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,qBAAqB,GAAG,MAAM,IAAI,
|
|
1
|
+
{"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../src/bridge.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAE7C,MAAM,WAAW,qBAAqB;IACpC,6DAA6D;IAC7D,QAAQ,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAA;IAC7C,qEAAqE;IACrE,QAAQ,CAAC,cAAc,EAAE,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,KAAK,MAAM,IAAI,CAAA;IAC9E,gDAAgD;IAChD,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,KAAK,IAAI,CAAA;IAC1E,mEAAmE;IACnE,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,CAAA;CAC9B;AAED;;;;;;;;;GASG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,OAAO,GAAG,UAAU,CAKjD;AAED;;;;;;;;GAQG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAK5C;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,qBAAqB,GAAG,MAAM,IAAI,CAkE9F"}
|
package/lib/bridge.js
CHANGED
|
@@ -23,6 +23,24 @@ export function toBytes(data) {
|
|
|
23
23
|
return new Uint8Array(data);
|
|
24
24
|
return new Uint8Array(Buffer.from(data));
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Normalise a `ws` message payload to text.
|
|
28
|
+
*
|
|
29
|
+
* The same three shapes {@link toBytes} handles, decoded as UTF-8 — a control frame arrives as a Buffer
|
|
30
|
+
* whichever way it was framed, and a fragmented one as an array of them.
|
|
31
|
+
*
|
|
32
|
+
* @param data - the payload as `ws` delivered it.
|
|
33
|
+
* @returns the payload as text.
|
|
34
|
+
*/
|
|
35
|
+
export function toText(data) {
|
|
36
|
+
if (typeof data === 'string')
|
|
37
|
+
return data;
|
|
38
|
+
if (Array.isArray(data))
|
|
39
|
+
return Buffer.concat(data).toString('utf8');
|
|
40
|
+
if (data instanceof Uint8Array)
|
|
41
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8');
|
|
42
|
+
return Buffer.from(data).toString('utf8');
|
|
43
|
+
}
|
|
26
44
|
/**
|
|
27
45
|
* Wire one accepted socket to the agent's audio events.
|
|
28
46
|
*
|
|
@@ -48,11 +66,37 @@ export function attachAudioSocket(client, deps) {
|
|
|
48
66
|
unsubscribe();
|
|
49
67
|
deps.onDetach();
|
|
50
68
|
};
|
|
69
|
+
/**
|
|
70
|
+
* Answer one control frame, if the socket is still there.
|
|
71
|
+
*
|
|
72
|
+
* The guard is here rather than at the call site because this is the only place that knows whether the
|
|
73
|
+
* bridge is still live, and because a control handler settles asynchronously: a reply that arrives after
|
|
74
|
+
* the socket went away must be dropped, not thrown.
|
|
75
|
+
*
|
|
76
|
+
* **Total**, including the write: a socket can die between the check above and the send, and a throw here
|
|
77
|
+
* would escape into a promise nobody awaits — and, in the plugin, would break the reply queue for every
|
|
78
|
+
* frame behind it.
|
|
79
|
+
* @param text - the reply frame.
|
|
80
|
+
*/
|
|
81
|
+
const reply = (text) => {
|
|
82
|
+
if (stopped)
|
|
83
|
+
return;
|
|
84
|
+
try {
|
|
85
|
+
client.send(text);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
stop();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
51
91
|
client.on('message', (data, isBinary) => {
|
|
52
|
-
// A text frame is
|
|
53
|
-
//
|
|
54
|
-
|
|
92
|
+
// A **text** frame is a control frame. This is the deliberate widening of the contract that used to
|
|
93
|
+
// say the opposite: the socket is the one duplex connection the client already holds, and a control
|
|
94
|
+
// plane that needs a second route with a second authentication is a control plane nobody builds.
|
|
95
|
+
// Checked before the audio path, because a text frame is never audio whatever it contains.
|
|
96
|
+
if (!isBinary) {
|
|
97
|
+
deps.onControl(toText(data), reply);
|
|
55
98
|
return;
|
|
99
|
+
}
|
|
56
100
|
const pcm16 = toBytes(data);
|
|
57
101
|
if (pcm16.byteLength > deps.maxFrameBytes) {
|
|
58
102
|
// 1009 = message too big. `ws` enforces its own `maxPayload` first, so reaching this means the bound
|
package/lib/bridge.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bridge.js","sourceRoot":"","sources":["../src/bridge.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"bridge.js","sourceRoot":"","sources":["../src/bridge.ts"],"names":[],"mappings":"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"}
|
package/lib/client/bundle.js
CHANGED
|
@@ -28,7 +28,7 @@ window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
|
|
|
28
28
|
* actually been read, rather than shipping a capture path that a policy might silently kill.
|
|
29
29
|
*/
|
|
30
30
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
-
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;
|
|
31
|
+
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;
|
|
32
32
|
exports.socketUrl = socketUrl;
|
|
33
33
|
exports.pageAuthority = pageAuthority;
|
|
34
34
|
exports.withToken = withToken;
|
|
@@ -36,6 +36,9 @@ window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
|
|
|
36
36
|
exports.float32FromPcm16 = float32FromPcm16;
|
|
37
37
|
exports.defaultDeps = defaultDeps;
|
|
38
38
|
exports.createAudioClient = createAudioClient;
|
|
39
|
+
exports.stripRoot = stripRoot;
|
|
40
|
+
exports.renderStrip = renderStrip;
|
|
41
|
+
exports.mountStrip = mountStrip;
|
|
39
42
|
exports.apply = apply;
|
|
40
43
|
/** Browser-side plugin name. */
|
|
41
44
|
exports.name = 'realtime-audio-client';
|
|
@@ -170,12 +173,20 @@ window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
|
|
|
170
173
|
let processor;
|
|
171
174
|
let silence;
|
|
172
175
|
let playsAt = 0;
|
|
176
|
+
/** The control request waiting for its reply, if any. The host answers one frame with one frame. */
|
|
177
|
+
let pending;
|
|
178
|
+
/** The tail of the request chain, so two frames are never in flight at once. */
|
|
179
|
+
let chain = Promise.resolve();
|
|
173
180
|
const fail = (reason) => {
|
|
174
181
|
current = { kind: 'failed', reason };
|
|
175
182
|
return current;
|
|
176
183
|
};
|
|
177
184
|
/** Release everything held. Idempotent, because stop, an error and a close all reach it. */
|
|
178
185
|
const release = () => {
|
|
186
|
+
// A request whose socket is going away must fail rather than hang: the panel disables half of itself
|
|
187
|
+
// while one is in flight, and a promise that never settles would leave it disabled for ever.
|
|
188
|
+
pending?.reject(new Error('the audio socket closed before the host answered'));
|
|
189
|
+
pending = undefined;
|
|
179
190
|
socket?.close();
|
|
180
191
|
socket = undefined;
|
|
181
192
|
processor?.disconnect();
|
|
@@ -276,8 +287,26 @@ window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
|
|
|
276
287
|
socket = opened;
|
|
277
288
|
opened.binaryType = 'arraybuffer';
|
|
278
289
|
opened.onmessage = (event) => {
|
|
279
|
-
|
|
290
|
+
// Binary frames are audio; a string is the answer to a control frame. The frame's own type is what
|
|
291
|
+
// tells them apart, which is why the control channel cost this path nothing.
|
|
292
|
+
if (event.data instanceof ArrayBuffer) {
|
|
280
293
|
play(new Uint8Array(event.data));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (typeof event.data !== 'string')
|
|
297
|
+
return;
|
|
298
|
+
const waiting = pending;
|
|
299
|
+
pending = undefined;
|
|
300
|
+
if (waiting === undefined)
|
|
301
|
+
return;
|
|
302
|
+
try {
|
|
303
|
+
waiting.resolve(JSON.parse(event.data));
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// A reply that is not JSON is a host fault, and the caller has to hear about it rather than be left
|
|
307
|
+
// waiting: the same rule the channel itself follows, one side out.
|
|
308
|
+
waiting.reject(new Error('the host answered a control frame with something that is not a reply'));
|
|
309
|
+
}
|
|
281
310
|
};
|
|
282
311
|
opened.onclose = () => {
|
|
283
312
|
release();
|
|
@@ -288,23 +317,329 @@ window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
|
|
|
288
317
|
opened.onerror = () => { resolve(fail('the host refused the audio socket')); };
|
|
289
318
|
});
|
|
290
319
|
};
|
|
291
|
-
|
|
320
|
+
/**
|
|
321
|
+
* Send one frame and wait for its reply.
|
|
322
|
+
*
|
|
323
|
+
* The socket is looked up at the moment of use rather than closed over, so a request made after a
|
|
324
|
+
* reconnect goes out on the socket that exists now — the same rule every other live value in this
|
|
325
|
+
* project follows.
|
|
326
|
+
* @param frame - the control frame.
|
|
327
|
+
* @returns the parsed reply.
|
|
328
|
+
*/
|
|
329
|
+
const sendFrame = (frame) => {
|
|
330
|
+
const current = socket;
|
|
331
|
+
if (current === undefined) {
|
|
332
|
+
return Promise.reject(new Error('not connected: connect the microphone first'));
|
|
333
|
+
}
|
|
334
|
+
return new Promise((resolve, reject) => {
|
|
335
|
+
pending = { resolve, reject };
|
|
336
|
+
current.send(frame);
|
|
337
|
+
});
|
|
338
|
+
};
|
|
339
|
+
const request = (frame) => {
|
|
340
|
+
// Chained, not concurrent: the host answers in arrival order, and a client that pipelined would be
|
|
341
|
+
// pairing replies with frames by guesswork. The chain is kept alive across a failure, because one
|
|
342
|
+
// refused frame must not silently stop every frame behind it.
|
|
343
|
+
const next = chain.then(() => sendFrame(frame));
|
|
344
|
+
chain = next.catch(() => undefined);
|
|
345
|
+
return next;
|
|
346
|
+
};
|
|
347
|
+
return { start, stop, state: () => current, request };
|
|
348
|
+
}
|
|
349
|
+
// ---- the strip: the panel this plugin puts in the app ---------------------------------------------------
|
|
350
|
+
/** The element the injected markup provides. Duplicated in the host half, which mints the row. */
|
|
351
|
+
exports.STRIP_ELEMENT_ID = 'dsh-realtime-strip';
|
|
352
|
+
/**
|
|
353
|
+
* Where the injected markup puts the panel, or `null` when this page does not carry it.
|
|
354
|
+
* @param scope - the page scope, injectable so both cases are testable without a DOM.
|
|
355
|
+
* @returns the panel's element, or `null`.
|
|
356
|
+
*/
|
|
357
|
+
function stripRoot(scope) {
|
|
358
|
+
return scope.document?.getElementById(exports.STRIP_ELEMENT_ID) ?? null;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Escape text for an element's body and for a quoted attribute at once.
|
|
362
|
+
*
|
|
363
|
+
* One function rather than two because the two contexts differ only in which characters break out, and
|
|
364
|
+
* every value a panel renders came from a plugin's own configuration — a session id, a model name, or
|
|
365
|
+
* `instructions` somebody typed.
|
|
366
|
+
* @param text - the text to escape.
|
|
367
|
+
* @returns the text, safe in either position.
|
|
368
|
+
*/
|
|
369
|
+
function escapeHtml(text) {
|
|
370
|
+
return text
|
|
371
|
+
.replaceAll('&', '&')
|
|
372
|
+
.replaceAll('<', '<')
|
|
373
|
+
.replaceAll('>', '>')
|
|
374
|
+
.replaceAll('"', '"');
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Show one value as text.
|
|
378
|
+
* @param value - a setting's value as `status` reported it.
|
|
379
|
+
* @returns the text to render.
|
|
380
|
+
*/
|
|
381
|
+
function show(value) {
|
|
382
|
+
if (value === undefined || value === null)
|
|
383
|
+
return '—';
|
|
384
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Render the whole panel.
|
|
388
|
+
*
|
|
389
|
+
* A pure function of the state, because that is what makes the panel testable at all: the injected script
|
|
390
|
+
* is three lines and the DOM is one assignment, so everything worth checking lives here.
|
|
391
|
+
* @param state - what the panel is showing.
|
|
392
|
+
* @returns the markup for the panel's element.
|
|
393
|
+
*/
|
|
394
|
+
function renderStrip(state) {
|
|
395
|
+
const reply = state.reply;
|
|
396
|
+
const lines = [`<p data-dsh-strip-voice>${escapeHtml(summariseVoice(reply, state.audio))}</p>`];
|
|
397
|
+
if (reply !== null)
|
|
398
|
+
lines.push(`<p data-dsh-strip-route>${escapeHtml(summariseRoute(reply))}</p>`);
|
|
399
|
+
if (state.notice !== '')
|
|
400
|
+
lines.push(`<p data-dsh-strip-notice>${escapeHtml(state.notice)}</p>`);
|
|
401
|
+
lines.push(`<p data-dsh-strip-buttons>${buttons(state.audio)}</p>`);
|
|
402
|
+
// Restart-bound settings are omitted rather than shown read-only: the gate says no control at all, and a
|
|
403
|
+
// row that cannot be changed is a row a reader will try to change.
|
|
404
|
+
const settings = (reply?.settings ?? []).filter(entry => entry.scope !== 'restart');
|
|
405
|
+
lines.push(settings.length === 0
|
|
406
|
+
? '<p data-dsh-strip-empty>no settings to show</p>'
|
|
407
|
+
: `<ul>${settings.map(entry => settingRow(entry, state.notes)).join('')}</ul>`);
|
|
408
|
+
return lines.join('');
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* The voice line: whether a session is live, and what it is.
|
|
412
|
+
* @param reply - the last status, if any.
|
|
413
|
+
* @param audio - what the microphone is doing.
|
|
414
|
+
* @returns one line of plain text.
|
|
415
|
+
*/
|
|
416
|
+
function summariseVoice(reply, audio) {
|
|
417
|
+
const microphone = audio.kind === 'failed' ? `microphone: failed — ${audio.reason}` : `microphone: ${audio.kind}`;
|
|
418
|
+
if (reply === null)
|
|
419
|
+
return `no answer yet · ${microphone}`;
|
|
420
|
+
const voice = reply.voice;
|
|
421
|
+
// `null` is "nobody answered the question", which is a composition without an agent row — deliberately
|
|
422
|
+
// not the same statement as a session that is merely closed.
|
|
423
|
+
if (voice === null || voice === undefined)
|
|
424
|
+
return `voice: no agent is mounted in this profile · ${microphone}`;
|
|
425
|
+
const where = voice.provider === undefined ? '' : ` · ${voice.provider}/${voice.model ?? '?'}`;
|
|
426
|
+
const which = voice.sessionId === undefined ? '' : ` · ${voice.sessionId}`;
|
|
427
|
+
return `voice: ${voice.open ? 'open' : 'closed'}${where}${which} · ${microphone}`;
|
|
292
428
|
}
|
|
293
429
|
/**
|
|
294
|
-
* The
|
|
430
|
+
* The route line: what this socket is, and the last thing the journal recorded.
|
|
431
|
+
* @param reply - the last status.
|
|
432
|
+
* @returns one line of plain text.
|
|
433
|
+
*/
|
|
434
|
+
function summariseRoute(reply) {
|
|
435
|
+
const last = reply.journal?.last?.kind;
|
|
436
|
+
return [
|
|
437
|
+
reply.audio?.path ?? 'the audio route',
|
|
438
|
+
`${String(reply.audio?.clients ?? 0)} socket(s)`,
|
|
439
|
+
`journal ${String(reply.journal?.size ?? 0)}`,
|
|
440
|
+
...last === undefined ? [] : [`last ${last}`],
|
|
441
|
+
].join(' · ');
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* The buttons above the settings.
|
|
295
445
|
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
446
|
+
* The microphone's own button is the on-switch this sprint exists to provide: what used to be a
|
|
447
|
+
* `globalThis` call in a devtools console is now the first control in the panel.
|
|
448
|
+
* @param audio - what the microphone is doing.
|
|
449
|
+
* @returns the markup.
|
|
450
|
+
*/
|
|
451
|
+
function buttons(audio) {
|
|
452
|
+
const live = audio.kind === 'live';
|
|
453
|
+
return [
|
|
454
|
+
`<button data-action="${live ? 'disconnect' : 'connect'}">${live ? 'Disconnect microphone' : 'Connect microphone'}</button>`,
|
|
455
|
+
'<button data-action="voice-start">Start voice</button>',
|
|
456
|
+
'<button data-action="voice-stop">Stop voice</button>',
|
|
457
|
+
'<button data-action="refresh">Refresh</button>',
|
|
458
|
+
].join(' ');
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* One setting's row.
|
|
462
|
+
*
|
|
463
|
+
* Three classes, three treatments, and the gate's rule is the reason: a live field gets a control, a
|
|
464
|
+
* session-bound field says what it would take to change it, and a restart-bound field is not rendered.
|
|
465
|
+
* @param entry - the setting as `status` reported it.
|
|
466
|
+
* @param notes - what the last change to each setting produced.
|
|
467
|
+
* @returns the markup for one row.
|
|
468
|
+
*/
|
|
469
|
+
function settingRow(entry, notes) {
|
|
470
|
+
const key = escapeHtml(entry.key);
|
|
471
|
+
const label = `<span data-dsh-strip-label>${escapeHtml(entry.describe ?? entry.field)}</span>`;
|
|
472
|
+
const note = notes[entry.key] === undefined
|
|
473
|
+
? ''
|
|
474
|
+
: `<em data-note="${key}">${escapeHtml(notes[entry.key])}</em>`;
|
|
475
|
+
if (entry.scope !== 'live') {
|
|
476
|
+
return `<li data-key="${key}" data-scope="session">${label}<b>${escapeHtml(show(entry.value))}</b>`
|
|
477
|
+
+ `<em>fixed when the session opens — reconnect to apply</em>${note}</li>`;
|
|
478
|
+
}
|
|
479
|
+
// The channel has a verb for exactly one purpose — steering at a session — and this is the field it
|
|
480
|
+
// means. Everything else goes through `set <key>=<value>`.
|
|
481
|
+
const action = entry.field === 'sessionId' && entry.kind === 'string' ? 'steer' : 'set';
|
|
482
|
+
const control = entry.choices !== undefined && entry.choices.length > 0
|
|
483
|
+
? `<select data-input="${key}">${choiceOptions(entry, entry.choices)}</select>`
|
|
484
|
+
: `<input data-input="${key}" value="${escapeHtml(show(entry.value))}">`;
|
|
485
|
+
const name = action === 'steer' ? 'Steer' : 'Set';
|
|
486
|
+
return `<li data-key="${key}" data-scope="live">${label}${control}`
|
|
487
|
+
+ `<button data-action="${action}" data-key="${key}">${name}</button>${note}</li>`;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* A picker's options, with the value in force always among them.
|
|
491
|
+
* @param entry - the setting, for its value.
|
|
492
|
+
* @param candidates - the values it declared. Passed in rather than read here: this is only ever called
|
|
493
|
+
* for a setting that has a non-empty list, and a fallback for the case that cannot happen is a branch
|
|
494
|
+
* the coverage gate rightly flags as dead.
|
|
495
|
+
* @returns the markup for each option.
|
|
496
|
+
*/
|
|
497
|
+
function choiceOptions(entry, candidates) {
|
|
498
|
+
const current = show(entry.value);
|
|
499
|
+
// A value that is not among the candidates is offered anyway. A select that silently showed the first
|
|
500
|
+
// candidate instead of the value actually in force would be lying about the state, which is the one
|
|
501
|
+
// thing a control plane must not do.
|
|
502
|
+
const all = candidates.includes(current) ? candidates : [current, ...candidates];
|
|
503
|
+
return all.map(choice => `<option value="${escapeHtml(choice)}"${choice === current ? ' selected' : ''}>${escapeHtml(choice)}</option>`).join('');
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Mount the panel on its element.
|
|
507
|
+
*
|
|
508
|
+
* Reads `status` once, then renders — and renders again after every action, because a control that
|
|
509
|
+
* reported an outcome without showing the state it produced would leave the reader unsure which of the two
|
|
510
|
+
* they are looking at.
|
|
511
|
+
* @param deps - the element, the transport and the microphone's own controls.
|
|
512
|
+
* @returns the panel's handle.
|
|
513
|
+
*/
|
|
514
|
+
function mountStrip(deps) {
|
|
515
|
+
let reply = null;
|
|
516
|
+
const notes = {};
|
|
517
|
+
let notice = '';
|
|
518
|
+
const paint = () => {
|
|
519
|
+
deps.root.innerHTML = renderStrip({ reply, notes, notice, audio: deps.audio() });
|
|
520
|
+
};
|
|
521
|
+
const refresh = async () => {
|
|
522
|
+
try {
|
|
523
|
+
reply = await deps.request('status');
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
// Nothing was sent, or nothing came back: the panel says which, rather than showing a stale state as
|
|
527
|
+
// if it were current.
|
|
528
|
+
notice = error instanceof Error ? error.message : String(error);
|
|
529
|
+
}
|
|
530
|
+
paint();
|
|
531
|
+
};
|
|
532
|
+
/**
|
|
533
|
+
* Send one frame and report what came back, then re-render.
|
|
534
|
+
* @param frame - the control frame.
|
|
535
|
+
* @param fieldKey - the setting the change was about, when it was about one.
|
|
536
|
+
* @param done - what to say when it worked.
|
|
537
|
+
*/
|
|
538
|
+
const send = async (frame, fieldKey, done) => {
|
|
539
|
+
let line;
|
|
540
|
+
try {
|
|
541
|
+
const answer = await deps.request(frame);
|
|
542
|
+
// A refusal carries the reason written to be relayed verbatim, so it is relayed rather than
|
|
543
|
+
// translated — including for a field frozen by its class.
|
|
544
|
+
line = answer.ok ? done : `${answer.code ?? 'refused'}: ${answer.reason ?? 'no reason given'}`;
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
line = error instanceof Error ? error.message : String(error);
|
|
548
|
+
}
|
|
549
|
+
if (fieldKey === undefined)
|
|
550
|
+
notice = line;
|
|
551
|
+
else
|
|
552
|
+
notes[fieldKey] = line;
|
|
553
|
+
await refresh();
|
|
554
|
+
};
|
|
555
|
+
const onClick = (event) => {
|
|
556
|
+
const action = event.target?.dataset?.action;
|
|
557
|
+
if (action === undefined)
|
|
558
|
+
return;
|
|
559
|
+
const key = event.target?.dataset?.key;
|
|
560
|
+
const value = key === undefined ? undefined : deps.root.querySelector(`[data-input="${key}"]`)?.value;
|
|
561
|
+
const act = async () => {
|
|
562
|
+
// Cleared per action: a notice from the last one is about the last one, and leaving it up would make
|
|
563
|
+
// a successful reconnect read as a failed one.
|
|
564
|
+
notice = '';
|
|
565
|
+
switch (action) {
|
|
566
|
+
case 'refresh':
|
|
567
|
+
await refresh();
|
|
568
|
+
return;
|
|
569
|
+
case 'disconnect':
|
|
570
|
+
deps.disconnect();
|
|
571
|
+
await refresh();
|
|
572
|
+
return;
|
|
573
|
+
case 'connect': {
|
|
574
|
+
const reached = await deps.connect();
|
|
575
|
+
if (reached.kind === 'failed')
|
|
576
|
+
notice = reached.reason;
|
|
577
|
+
await refresh();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
case 'voice-start':
|
|
581
|
+
await send('start', undefined, 'voice started');
|
|
582
|
+
return;
|
|
583
|
+
case 'voice-stop':
|
|
584
|
+
await send('stop', undefined, 'voice stopped');
|
|
585
|
+
return;
|
|
586
|
+
case 'steer':
|
|
587
|
+
await send(`steer ${value ?? ''}`, key, 'steered');
|
|
588
|
+
return;
|
|
589
|
+
case 'set': await send(`set ${key ?? ''}=${value ?? ''}`, key, 'applied');
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
void act();
|
|
593
|
+
};
|
|
594
|
+
deps.root.addEventListener('click', onClick);
|
|
595
|
+
return { refresh, state: () => ({ reply, notes, notice, audio: deps.audio() }) };
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* The client plugin. Publishes the client and the strip on a global, and releases them with the plugin.
|
|
599
|
+
*
|
|
600
|
+
* `inject` is empty on purpose: nothing here needs another plugin, and a client face that needs nothing
|
|
601
|
+
* cannot be broken by another plugin's absence.
|
|
602
|
+
*
|
|
603
|
+
* The global is **no longer the on-switch**. S2 story 3 put a panel in the app, and the panel's buttons
|
|
604
|
+
* are how anyone starts a microphone; the global is now the seam between the injected markup and this
|
|
605
|
+
* bundle, which is a different thing from a user interface that only exists in a devtools console.
|
|
298
606
|
*
|
|
299
607
|
* @param ctx - the client context, used only to own the lifetime.
|
|
300
608
|
*/
|
|
301
609
|
function apply(ctx) {
|
|
302
610
|
const client = createAudioClient(defaultDeps());
|
|
611
|
+
let mounted = false;
|
|
612
|
+
/**
|
|
613
|
+
* Render the panel into the element the injected markup provides.
|
|
614
|
+
*
|
|
615
|
+
* Called twice on purpose — once here, and once by the injected bootstrap script — because the two
|
|
616
|
+
* orders are both possible: this bundle is materialised by the module table, and the markup arrives with
|
|
617
|
+
* the body rows. Whichever runs second finds the panel already there and does nothing.
|
|
618
|
+
*/
|
|
619
|
+
const mount = () => {
|
|
620
|
+
if (mounted)
|
|
621
|
+
return;
|
|
622
|
+
const root = stripRoot(globalThis);
|
|
623
|
+
if (root === null)
|
|
624
|
+
return;
|
|
625
|
+
mounted = true;
|
|
626
|
+
const strip = mountStrip({
|
|
627
|
+
root,
|
|
628
|
+
request: (frame) => client.request(frame),
|
|
629
|
+
connect: () => client.start(),
|
|
630
|
+
disconnect: () => { client.stop(); },
|
|
631
|
+
audio: () => client.state(),
|
|
632
|
+
});
|
|
633
|
+
void strip.refresh();
|
|
634
|
+
};
|
|
303
635
|
globalThis[exports.GLOBAL_KEY] = {
|
|
304
636
|
start: () => client.start(),
|
|
305
637
|
stop: () => { client.stop(); },
|
|
306
638
|
state: () => client.state(),
|
|
639
|
+
request: (frame) => client.request(frame),
|
|
640
|
+
mount,
|
|
307
641
|
};
|
|
642
|
+
mount();
|
|
308
643
|
// A held microphone and a live audio graph must not outlive the plugin that opened them.
|
|
309
644
|
ctx.effect(() => () => { client.stop(); }, 'realtime-audio-client');
|
|
310
645
|
}
|