@frockbot/protocol 0.3.13 → 0.3.15
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/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/voice-dictation.ts +126 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Version 1 of the composer dictation protocol.
|
|
2
|
+
//
|
|
3
|
+
// Two legs, one shape. The browser opens an authenticated WebSocket to the
|
|
4
|
+
// gateway, which hands it to the per-User `VoiceSession` Durable Object; that
|
|
5
|
+
// object opens the upstream OpenAI realtime *transcription* session and
|
|
6
|
+
// translates in both directions. This module is the wire between the browser
|
|
7
|
+
// and the Durable Object, and nothing here is the upstream's own protocol:
|
|
8
|
+
// the Durable Object never forwards a provider frame verbatim, so a change of
|
|
9
|
+
// provider is a change to one file on the server and none in the client.
|
|
10
|
+
//
|
|
11
|
+
// Audio travels as binary frames — PCM16 little-endian, 16 kHz, mono, the
|
|
12
|
+
// format OpenAI's realtime transcription sessions take — and every control
|
|
13
|
+
// frame is JSON text. That split is what lets the receiver decide by frame
|
|
14
|
+
// type rather than by parsing.
|
|
15
|
+
|
|
16
|
+
/** The `?version=` a client presents; a mismatch is refused at the door. */
|
|
17
|
+
export const VOICE_DICTATION_VERSION_V1 = 1 as const;
|
|
18
|
+
|
|
19
|
+
/** The sample rate the worklet resamples to and the upstream is told to expect. */
|
|
20
|
+
export const VOICE_DICTATION_SAMPLE_RATE_V1 = 16_000;
|
|
21
|
+
|
|
22
|
+
/** What the browser says. Audio is binary and carries no envelope. */
|
|
23
|
+
export type VoiceDictationClientFrameV1 =
|
|
24
|
+
/** Stop capturing, transcribe what is buffered, and answer `final`. */
|
|
25
|
+
| { schemaVersion: 1; type: "commit" }
|
|
26
|
+
/** Throw the buffer away. The draft is the client's business, not ours. */
|
|
27
|
+
| { schemaVersion: 1; type: "cancel" };
|
|
28
|
+
|
|
29
|
+
/** What the Durable Object says. */
|
|
30
|
+
export type VoiceDictationServerFrameV1 =
|
|
31
|
+
/** The upstream session is open and audio may start. */
|
|
32
|
+
| { schemaVersion: 1; type: "ready" }
|
|
33
|
+
/** Text to append to the draft as it is heard. */
|
|
34
|
+
| { schemaVersion: 1; type: "delta"; text: string }
|
|
35
|
+
/** One finished segment, replacing the deltas that built it. */
|
|
36
|
+
| { schemaVersion: 1; type: "transcript"; text: string }
|
|
37
|
+
/** Everything buffered has been transcribed; the client may send. */
|
|
38
|
+
| { schemaVersion: 1; type: "final" }
|
|
39
|
+
/**
|
|
40
|
+
* Dictation cannot continue, and `message` is plain English a person can
|
|
41
|
+
* act on — an exhausted quota, an unconfigured deployment, a refused
|
|
42
|
+
* upstream. The socket closes after it.
|
|
43
|
+
*/
|
|
44
|
+
| { schemaVersion: 1; type: "error"; message: string };
|
|
45
|
+
|
|
46
|
+
function frameObject(value: unknown, label: string): Record<string, unknown> {
|
|
47
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
48
|
+
throw new Error(`${label} must be an object`);
|
|
49
|
+
}
|
|
50
|
+
const frame = value as Record<string, unknown>;
|
|
51
|
+
if (frame.schemaVersion !== 1) {
|
|
52
|
+
throw new Error(`${label}.schemaVersion is unsupported`);
|
|
53
|
+
}
|
|
54
|
+
return frame;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function frameText(
|
|
58
|
+
frame: Record<string, unknown>,
|
|
59
|
+
key: string,
|
|
60
|
+
label: string,
|
|
61
|
+
): string {
|
|
62
|
+
const value = frame[key];
|
|
63
|
+
// Bounded because a delta is appended to a draft the composer then sends:
|
|
64
|
+
// an upstream that streams without end must not be able to grow the draft
|
|
65
|
+
// without end either.
|
|
66
|
+
if (typeof value !== "string" || value.length > 8_192) {
|
|
67
|
+
throw new Error(`${label}.${key} is invalid`);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function decodeVoiceDictationClientFrameV1(
|
|
73
|
+
value: unknown,
|
|
74
|
+
): VoiceDictationClientFrameV1 {
|
|
75
|
+
const label = "voice dictation client frame";
|
|
76
|
+
const frame = frameObject(value, label);
|
|
77
|
+
if (frame.type === "commit" || frame.type === "cancel") {
|
|
78
|
+
return { schemaVersion: 1, type: frame.type };
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`${label}.type is invalid`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function decodeVoiceDictationServerFrameV1(
|
|
84
|
+
value: unknown,
|
|
85
|
+
): VoiceDictationServerFrameV1 {
|
|
86
|
+
const label = "voice dictation server frame";
|
|
87
|
+
const frame = frameObject(value, label);
|
|
88
|
+
switch (frame.type) {
|
|
89
|
+
case "ready":
|
|
90
|
+
case "final":
|
|
91
|
+
return { schemaVersion: 1, type: frame.type };
|
|
92
|
+
case "delta":
|
|
93
|
+
return {
|
|
94
|
+
schemaVersion: 1,
|
|
95
|
+
type: "delta",
|
|
96
|
+
text: frameText(frame, "text", label),
|
|
97
|
+
};
|
|
98
|
+
case "transcript":
|
|
99
|
+
return {
|
|
100
|
+
schemaVersion: 1,
|
|
101
|
+
type: "transcript",
|
|
102
|
+
text: frameText(frame, "text", label),
|
|
103
|
+
};
|
|
104
|
+
case "error":
|
|
105
|
+
return {
|
|
106
|
+
schemaVersion: 1,
|
|
107
|
+
type: "error",
|
|
108
|
+
message: frameText(frame, "message", label),
|
|
109
|
+
};
|
|
110
|
+
default:
|
|
111
|
+
throw new Error(`${label}.type is invalid`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Parses a text frame off a socket. Invalid JSON is an invalid frame. */
|
|
116
|
+
export function parseVoiceDictationServerFrameV1(
|
|
117
|
+
text: string,
|
|
118
|
+
): VoiceDictationServerFrameV1 {
|
|
119
|
+
return decodeVoiceDictationServerFrameV1(JSON.parse(text) as unknown);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function parseVoiceDictationClientFrameV1(
|
|
123
|
+
text: string,
|
|
124
|
+
): VoiceDictationClientFrameV1 {
|
|
125
|
+
return decodeVoiceDictationClientFrameV1(JSON.parse(text) as unknown);
|
|
126
|
+
}
|