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/lib/strip.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The strip: the panel this plugin puts in the host's own page.
|
|
3
|
+
*
|
|
4
|
+
* S2 story 3. Until it existed the plugin was driven from a `globalThis` key in a devtools console, and the
|
|
5
|
+
* roadmap's judgement on that was blunt — *a plugin driven from a console global will not be installed by
|
|
6
|
+
* anyone*. What changes is not decoration: the on-switch moves into the page, and `sessionId`, which cost
|
|
7
|
+
* two restarts and a false lead to change, becomes a picker.
|
|
8
|
+
*
|
|
9
|
+
* ## Where the behaviour lives, and why not here
|
|
10
|
+
*
|
|
11
|
+
* This module mints **rows** for `webserver/index-inject` — the door the page already receives its settings
|
|
12
|
+
* through. Two of the six row kinds carry UI: `html` for raw markup, `script` for an inline classic script.
|
|
13
|
+
* So the markup is here and the bootstrap is here, and **the panel's logic is not**: everything that
|
|
14
|
+
* decides what to render and what a button does lives in the client bundle
|
|
15
|
+
* (`src/client/index.ts`), where it is covered by tests. A panel implemented inside an injected string is a
|
|
16
|
+
* feature nothing can execute in CI, which is the same defect class as a build artefact no test reads.
|
|
17
|
+
*
|
|
18
|
+
* The bootstrap is therefore three statements, and it is still *executed* by a test — as a function with a
|
|
19
|
+
* fake scope — so the injected code is not exempt from the gate just because it is shipped as text.
|
|
20
|
+
*
|
|
21
|
+
* ## Two ways in, deliberately
|
|
22
|
+
*
|
|
23
|
+
* The bundle mounts the panel when it applies, and the injected script mounts it again in case it lands
|
|
24
|
+
* first. Both are idempotent, because the order between the module table and the body rows is not something
|
|
25
|
+
* this package controls, and a panel that appears only when two independent orderings agree is a panel that
|
|
26
|
+
* appears only on the author's machine.
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-realtime-audio-ws/strip
|
|
29
|
+
*/
|
|
30
|
+
/** The element the panel mounts on. Duplicated in the client half, which cannot import it. */
|
|
31
|
+
export const STRIP_ELEMENT_ID = 'dsh-realtime-strip';
|
|
32
|
+
/**
|
|
33
|
+
* The panel's container, as markup.
|
|
34
|
+
*
|
|
35
|
+
* Fixed to the bottom right and deliberately nearly unstyled: it sits inside the host's own page, so it
|
|
36
|
+
* inherits that page's font and colour rather than inventing a second visual language beside it. A
|
|
37
|
+
* translucent wash rather than a border, because a border here would be visual noise around a strip whose
|
|
38
|
+
* job is to be glanced at.
|
|
39
|
+
* @returns one `html` row's payload.
|
|
40
|
+
*/
|
|
41
|
+
export function stripMarkup() {
|
|
42
|
+
return [
|
|
43
|
+
`<div id="${STRIP_ELEMENT_ID}"`,
|
|
44
|
+
' style="position:fixed;right:12px;bottom:12px;z-index:40;',
|
|
45
|
+
'max-width:26rem;max-height:60vh;overflow:auto;padding:10px 12px;',
|
|
46
|
+
'font:inherit;color:inherit;line-height:1.4;',
|
|
47
|
+
'background:color-mix(in srgb, currentColor 8%, transparent);border-radius:10px">',
|
|
48
|
+
'</div>',
|
|
49
|
+
].join('');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The bootstrap the page runs after the markup.
|
|
53
|
+
*
|
|
54
|
+
* It calls the bundle's own `mount`, and does nothing when the bundle has not been materialised yet — in
|
|
55
|
+
* which case the bundle mounts the panel itself when it applies, or this is called again on
|
|
56
|
+
* `DOMContentLoaded`. Nothing here decides anything about the panel; it is a doorbell.
|
|
57
|
+
* @returns one `script` row's payload.
|
|
58
|
+
*/
|
|
59
|
+
export function stripBootstrap() {
|
|
60
|
+
return [
|
|
61
|
+
';(function () {',
|
|
62
|
+
" function mount() { var G = globalThis.__dshRealtimeAudio; if (G && typeof G.mount === 'function') G.mount() }",
|
|
63
|
+
' mount()',
|
|
64
|
+
" if (typeof document !== 'undefined' && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount)",
|
|
65
|
+
'})()',
|
|
66
|
+
].join('\n');
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The two rows this plugin contributes for its panel.
|
|
70
|
+
*
|
|
71
|
+
* `body` placement for both: the strip belongs to the page's chrome rather than to its head, and a `body`
|
|
72
|
+
* row is rendered after the module queue's own `head` rows, which is the ordering the bundle's bootstrap
|
|
73
|
+
* expects.
|
|
74
|
+
*
|
|
75
|
+
* Neither payload may contain a closing script or style tag — the table's own contract, because the row is
|
|
76
|
+
* spliced into the document text — and a test asserts that for both, alongside executing the bootstrap.
|
|
77
|
+
* @returns the rows, in the order they must execute.
|
|
78
|
+
*/
|
|
79
|
+
export function stripRows() {
|
|
80
|
+
return [
|
|
81
|
+
{ kind: 'html', placement: 'body', html: stripMarkup() },
|
|
82
|
+
{ kind: 'script', placement: 'body', text: stripBootstrap() },
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=strip.js.map
|
package/lib/strip.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"strip.js","sourceRoot":"","sources":["../src/strip.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAIH,8FAA8F;AAC9F,MAAM,CAAC,MAAM,gBAAgB,GAAG,oBAAoB,CAAA;AAEpD;;;;;;;;GAQG;AACH,MAAM,UAAU,WAAW;IACzB,OAAO;QACL,YAAY,gBAAgB,GAAG;QAC/B,2DAA2D;QAC3D,kEAAkE;QAClE,6CAA6C;QAC7C,kFAAkF;QAClF,QAAQ;KACT,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;AACZ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc;IAC5B,OAAO;QACL,iBAAiB;QACjB,iHAAiH;QACjH,WAAW;QACX,kIAAkI;QAClI,MAAM;KACP,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AACd,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,SAAS;IACvB,OAAO;QACL,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE;QACxD,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,EAAE,EAAE;KAC9D,CAAA;AACH,CAAC"}
|
package/lib/types.d.ts
CHANGED
|
@@ -74,7 +74,7 @@ export interface RealtimeAudioWsConfig {
|
|
|
74
74
|
* `Uint8Array`.
|
|
75
75
|
*/
|
|
76
76
|
export interface AudioSocket {
|
|
77
|
-
send(data: Uint8Array): void;
|
|
77
|
+
send(data: Uint8Array | string): void;
|
|
78
78
|
close(code?: number, reason?: string): void;
|
|
79
79
|
terminate(): void;
|
|
80
80
|
on(event: 'message', listener: (data: unknown, isBinary: boolean) => void): unknown;
|
package/lib/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,eAAO,MAAM,YAAY,wBAAwB,CAAA;AAEjD,6FAA6F;AAC7F,eAAO,MAAM,uBAAuB,SAAU,CAAA;AAE9C,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,0GAA0G;AAC1G,eAAO,MAAM,+BAA+B,OAAO,CAAA;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,8BAA8B,CAAA;AAEnE,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B;;;OAGG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,oBAAoB,EAAE,OAAO,CAAA;IACtC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;CACjC;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAAA;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,eAAO,MAAM,YAAY,wBAAwB,CAAA;AAEjD,6FAA6F;AAC7F,eAAO,MAAM,uBAAuB,SAAU,CAAA;AAE9C,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,0GAA0G;AAC1G,eAAO,MAAM,+BAA+B,OAAO,CAAA;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,8BAA8B,CAAA;AAEnE,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B;;;OAGG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,oBAAoB,EAAE,OAAO,CAAA;IACtC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;CACjC;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAAA;IACrC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3C,SAAS,IAAI,IAAI,CAAA;IACjB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAA;IACnF,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;CAC9E"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-realtime-audio-ws",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "The host end of the client half's transport: a WebSocket upgrade route that bridges microphone audio in and agent speech out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"ws": "^8.22.0",
|
|
55
|
-
"dsh-realtime
|
|
55
|
+
"dsh-realtime": "^0.2.3",
|
|
56
|
+
"dsh-realtime-agent": "^0.2.7"
|
|
56
57
|
},
|
|
57
58
|
"peerDependencies": {
|
|
58
59
|
"@deepseek-ai/cordis": "^4.0.3",
|
package/src/bridge.ts
CHANGED
|
@@ -14,6 +14,20 @@ export interface AudioSocketBridgeDeps {
|
|
|
14
14
|
readonly subscribeAudio: (listener: (pcm16: Uint8Array) => void) => () => void
|
|
15
15
|
/** Longest inbound frame accepted, in bytes. */
|
|
16
16
|
readonly maxFrameBytes: number
|
|
17
|
+
/**
|
|
18
|
+
* Handle one **control frame** — a text frame on this socket.
|
|
19
|
+
*
|
|
20
|
+
* Until S2 story 2 a text frame was not part of this contract, and the comment that said so was right
|
|
21
|
+
* about the wire as it was: the socket carried PCM16 in both directions and nothing else. It now
|
|
22
|
+
* carries a channel too, because it is the only duplex connection the client already holds, and one
|
|
23
|
+
* text frame in / one text frame out is a smaller contract than a second route with its own
|
|
24
|
+
* authentication would have been.
|
|
25
|
+
*
|
|
26
|
+
* `reply` writes exactly one text frame back, and silently drops it once the socket is gone: a control
|
|
27
|
+
* handler is asynchronous, and a reply to a closed socket would throw from inside a promise nobody
|
|
28
|
+
* awaits.
|
|
29
|
+
*/
|
|
30
|
+
readonly onControl: (frame: string, reply: (text: string) => void) => void
|
|
17
31
|
/** Called exactly once when the bridge stops, however it stops. */
|
|
18
32
|
readonly onDetach: () => void
|
|
19
33
|
}
|
|
@@ -35,6 +49,22 @@ export function toBytes(data: unknown): Uint8Array {
|
|
|
35
49
|
return new Uint8Array(Buffer.from(data as ArrayBufferLike))
|
|
36
50
|
}
|
|
37
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Normalise a `ws` message payload to text.
|
|
54
|
+
*
|
|
55
|
+
* The same three shapes {@link toBytes} handles, decoded as UTF-8 — a control frame arrives as a Buffer
|
|
56
|
+
* whichever way it was framed, and a fragmented one as an array of them.
|
|
57
|
+
*
|
|
58
|
+
* @param data - the payload as `ws` delivered it.
|
|
59
|
+
* @returns the payload as text.
|
|
60
|
+
*/
|
|
61
|
+
export function toText(data: unknown): string {
|
|
62
|
+
if (typeof data === 'string') return data
|
|
63
|
+
if (Array.isArray(data)) return Buffer.concat(data as readonly Uint8Array[]).toString('utf8')
|
|
64
|
+
if (data instanceof Uint8Array) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8')
|
|
65
|
+
return Buffer.from(data as ArrayBufferLike).toString('utf8')
|
|
66
|
+
}
|
|
67
|
+
|
|
38
68
|
/**
|
|
39
69
|
* Wire one accepted socket to the agent's audio events.
|
|
40
70
|
*
|
|
@@ -61,10 +91,36 @@ export function attachAudioSocket(client: AudioSocket, deps: AudioSocketBridgeDe
|
|
|
61
91
|
deps.onDetach()
|
|
62
92
|
}
|
|
63
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Answer one control frame, if the socket is still there.
|
|
96
|
+
*
|
|
97
|
+
* The guard is here rather than at the call site because this is the only place that knows whether the
|
|
98
|
+
* bridge is still live, and because a control handler settles asynchronously: a reply that arrives after
|
|
99
|
+
* the socket went away must be dropped, not thrown.
|
|
100
|
+
*
|
|
101
|
+
* **Total**, including the write: a socket can die between the check above and the send, and a throw here
|
|
102
|
+
* would escape into a promise nobody awaits — and, in the plugin, would break the reply queue for every
|
|
103
|
+
* frame behind it.
|
|
104
|
+
* @param text - the reply frame.
|
|
105
|
+
*/
|
|
106
|
+
const reply = (text: string): void => {
|
|
107
|
+
if (stopped) return
|
|
108
|
+
try {
|
|
109
|
+
client.send(text)
|
|
110
|
+
} catch {
|
|
111
|
+
stop()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
64
115
|
client.on('message', (data, isBinary) => {
|
|
65
|
-
// A text frame is
|
|
66
|
-
//
|
|
67
|
-
|
|
116
|
+
// A **text** frame is a control frame. This is the deliberate widening of the contract that used to
|
|
117
|
+
// say the opposite: the socket is the one duplex connection the client already holds, and a control
|
|
118
|
+
// plane that needs a second route with a second authentication is a control plane nobody builds.
|
|
119
|
+
// Checked before the audio path, because a text frame is never audio whatever it contains.
|
|
120
|
+
if (!isBinary) {
|
|
121
|
+
deps.onControl(toText(data), reply)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
68
124
|
const pcm16 = toBytes(data)
|
|
69
125
|
if (pcm16.byteLength > deps.maxFrameBytes) {
|
|
70
126
|
// 1009 = message too big. `ws` enforces its own `maxPayload` first, so reaching this means the bound
|