@frockbot/plugin-voice 0.0.0 → 0.3.21

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/src/bot.ts ADDED
@@ -0,0 +1,158 @@
1
+ // The Bot half of Voice answer delivery.
2
+ //
3
+ // A Voice ask is an ordinary agent-lane Turn owned by the target Bot. Once its
4
+ // `turn/end` is durable, this projection takes the first text `send_to_user`
5
+ // from that Turn and puts it in a bounded Bot-local outbox before crossing to
6
+ // the User Durable Object. The User ledger is therefore never dependent on a
7
+ // caller or socket remaining resident while the Bot works.
8
+ import type { SessionEvent } from "@frockbot/kernel-contracts";
9
+ import {
10
+ decodeVoiceAnswerDeliveryV1,
11
+ VOICE_ANSWER_OUTBOX_MAX_V1,
12
+ type VoiceAnswerDeliveryV1,
13
+ } from "./shared.js";
14
+
15
+ export const VOICE_ANSWER_OUTBOX_KEY_V1 = "voice:answer-outbox:v1";
16
+
17
+ export interface VoiceAnswerSinkV1 {
18
+ recordVoiceAnswer(delivery: VoiceAnswerDeliveryV1): Promise<void>;
19
+ }
20
+
21
+ export interface VoiceAnswerOutboxStorageV1 {
22
+ get<T>(key: string): Promise<T | undefined>;
23
+ put<T>(key: string, value: T): Promise<void>;
24
+ delete(key: string): Promise<boolean>;
25
+ }
26
+
27
+ interface StoredVoiceAnswerOutboxV1 {
28
+ schemaVersion: 1;
29
+ deliveries: VoiceAnswerDeliveryV1[];
30
+ truncated: boolean;
31
+ }
32
+
33
+ function decodeOutboxV1(value: unknown): StoredVoiceAnswerOutboxV1 {
34
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
35
+ return { schemaVersion: 1, deliveries: [], truncated: false };
36
+ }
37
+ const candidate = value as Partial<StoredVoiceAnswerOutboxV1>;
38
+ if (candidate.schemaVersion !== 1 || !Array.isArray(candidate.deliveries)) {
39
+ return { schemaVersion: 1, deliveries: [], truncated: true };
40
+ }
41
+ const deliveries: VoiceAnswerDeliveryV1[] = [];
42
+ for (const delivery of candidate.deliveries.slice(
43
+ -VOICE_ANSWER_OUTBOX_MAX_V1,
44
+ )) {
45
+ try {
46
+ deliveries.push(decodeVoiceAnswerDeliveryV1(delivery));
47
+ } catch {
48
+ // The authoritative run remains reconstructable. `truncated` makes an
49
+ // invalid derived row visible instead of silently accepting it.
50
+ }
51
+ }
52
+ return {
53
+ schemaVersion: 1,
54
+ deliveries,
55
+ truncated:
56
+ candidate.truncated === true ||
57
+ candidate.deliveries.length !== deliveries.length,
58
+ };
59
+ }
60
+
61
+ export function voiceAnswerFromSettledTurnV1(input: {
62
+ userId: string;
63
+ botId: string;
64
+ runId: string;
65
+ turn: number;
66
+ origin?: { kind: string; messageId?: string };
67
+ events: readonly SessionEvent[];
68
+ }): VoiceAnswerDeliveryV1 | undefined {
69
+ if (input.origin?.kind !== "voice" || !input.origin.messageId) return;
70
+ const ended = input.events.find(
71
+ (event) => event.type === "turn/end" && event.turn === input.turn,
72
+ );
73
+ if (!ended || ended.type !== "turn/end") return;
74
+ const sent = input.events.find(
75
+ (event) =>
76
+ event.type === "send/to-user" &&
77
+ event.turn === input.turn &&
78
+ event.payload.type === "text",
79
+ );
80
+ const base = {
81
+ schemaVersion: 1 as const,
82
+ userId: input.userId,
83
+ askId: input.origin.messageId,
84
+ botId: input.botId,
85
+ runId: input.runId,
86
+ at: sent?.timestamp ?? ended.timestamp,
87
+ };
88
+ return sent?.type === "send/to-user" && sent.payload.type === "text"
89
+ ? { ...base, outcome: "answered", answer: sent.payload.text }
90
+ : {
91
+ ...base,
92
+ outcome: "failed",
93
+ reason:
94
+ ended.outcome === "completed"
95
+ ? "The Bot finished without a text answer."
96
+ : "The Bot could not answer that Voice question.",
97
+ };
98
+ }
99
+
100
+ export class VoiceAnswerOutboxV1 {
101
+ constructor(private readonly storage: VoiceAnswerOutboxStorageV1) {}
102
+
103
+ private async read(): Promise<StoredVoiceAnswerOutboxV1> {
104
+ return decodeOutboxV1(
105
+ await this.storage.get<unknown>(VOICE_ANSWER_OUTBOX_KEY_V1),
106
+ );
107
+ }
108
+
109
+ private async write(outbox: StoredVoiceAnswerOutboxV1): Promise<void> {
110
+ if (outbox.deliveries.length === 0 && !outbox.truncated) {
111
+ await this.storage.delete(VOICE_ANSWER_OUTBOX_KEY_V1);
112
+ return;
113
+ }
114
+ await this.storage.put(VOICE_ANSWER_OUTBOX_KEY_V1, outbox);
115
+ }
116
+
117
+ async append(delivery: VoiceAnswerDeliveryV1): Promise<void> {
118
+ const decoded = decodeVoiceAnswerDeliveryV1(delivery);
119
+ const stored = await this.read();
120
+ if (
121
+ stored.deliveries.some(
122
+ (held) => held.askId === decoded.askId && held.runId === decoded.runId,
123
+ )
124
+ ) {
125
+ return;
126
+ }
127
+ stored.deliveries.push(decoded);
128
+ if (stored.deliveries.length > VOICE_ANSWER_OUTBOX_MAX_V1) {
129
+ stored.deliveries = stored.deliveries.slice(-VOICE_ANSWER_OUTBOX_MAX_V1);
130
+ stored.truncated = true;
131
+ }
132
+ await this.write(stored);
133
+ }
134
+
135
+ async drain(sink: VoiceAnswerSinkV1): Promise<void> {
136
+ const stored = await this.read();
137
+ for (const delivery of stored.deliveries) {
138
+ await sink.recordVoiceAnswer(delivery);
139
+ const current = await this.read();
140
+ await this.write({
141
+ ...current,
142
+ deliveries: current.deliveries.filter(
143
+ (candidate) =>
144
+ candidate.askId !== delivery.askId ||
145
+ candidate.runId !== delivery.runId,
146
+ ),
147
+ });
148
+ }
149
+ }
150
+
151
+ async state(): Promise<{ pending: number; truncated: boolean }> {
152
+ const stored = await this.read();
153
+ return {
154
+ pending: stored.deliveries.length,
155
+ truncated: stored.truncated,
156
+ };
157
+ }
158
+ }
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ import { UiButton } from "@frockbot/client-ui";
3
+ import { computed, inject } from "vue";
4
+ import { voiceClientStateKey } from "./state.js";
5
+
6
+ const provided = inject(voiceClientStateKey);
7
+ if (!provided) throw new Error("Voice client state was not provided");
8
+ const voice = provided;
9
+ const quota = computed(() => {
10
+ const minutes = Math.ceil(voice.value.quotaRemainingSeconds / 60);
11
+ return `${minutes} minute${minutes === 1 ? "" : "s"} left this month`;
12
+ });
13
+ const activity = computed(() =>
14
+ [
15
+ ...voice.value.transcript.map((entry) => ({
16
+ id: entry.id,
17
+ at: entry.at,
18
+ kind: entry.speaker,
19
+ label: entry.speaker === "user" ? "You" : "Voice",
20
+ text: entry.text,
21
+ })),
22
+ ...voice.value.tools.map((entry) => ({
23
+ id: entry.id,
24
+ at: entry.at,
25
+ kind: "tool" as const,
26
+ label: "Looked up",
27
+ text: entry.label,
28
+ })),
29
+ ].sort((left, right) => left.at.localeCompare(right.at)),
30
+ );
31
+ </script>
32
+
33
+ <template>
34
+ <section class="voice-surface">
35
+ <header class="voice-surface__status">
36
+ <span
37
+ class="voice-surface__pulse"
38
+ :class="`voice-surface__pulse--${voice.status}`"
39
+ aria-hidden="true"
40
+ ></span>
41
+ <div>
42
+ <h3>{{ voice.status }}</h3>
43
+ <p>{{ quota }}</p>
44
+ </div>
45
+ <UiButton
46
+ :variant="voice.status === 'offline' ? 'primary' : 'ghost'"
47
+ @click="voice.toggle()"
48
+ >{{ voice.status === "offline" ? "Turn on" : "Turn off" }}</UiButton
49
+ >
50
+ </header>
51
+
52
+ <p v-if="voice.message" class="voice-surface__message" role="status">
53
+ {{ voice.message }}
54
+ </p>
55
+ <p v-if="activity.length === 0" class="voice-surface__empty">
56
+ What you say and what Voice answers will appear here.
57
+ </p>
58
+ <ol v-else class="voice-surface__activity" aria-label="This Voice session">
59
+ <li
60
+ v-for="entry in activity"
61
+ :key="entry.id"
62
+ :class="`voice-surface__entry--${entry.kind}`"
63
+ class="voice-surface__entry"
64
+ >
65
+ <span>{{ entry.label }}</span>
66
+ <p>{{ entry.text }}</p>
67
+ </li>
68
+ </ol>
69
+ </section>
70
+ </template>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ import { UiIcon } from "@frockbot/client-ui";
3
+ import { computed, inject, onMounted } from "vue";
4
+ import { voiceClientStateKey } from "./state.js";
5
+
6
+ const provided = inject(voiceClientStateKey);
7
+ if (!provided) throw new Error("Voice client state was not provided");
8
+ const voice = provided;
9
+ const active = computed(() => voice.value.status !== "offline");
10
+ const label = computed(() =>
11
+ active.value ? "Turn Voice off" : "Turn Voice on",
12
+ );
13
+ onMounted(() => {
14
+ void voice.value.refresh();
15
+ });
16
+ </script>
17
+
18
+ <template>
19
+ <button
20
+ type="button"
21
+ class="voice-toggle"
22
+ :class="`voice-toggle--${voice.status}`"
23
+ :aria-label="label"
24
+ :aria-pressed="active"
25
+ :title="`${label} — ${voice.status}`"
26
+ @click="voice.toggle()"
27
+ @contextmenu.prevent="voice.open()"
28
+ >
29
+ <UiIcon name="mic" />
30
+ <span class="voice-toggle__state" aria-hidden="true"></span>
31
+ </button>
32
+ </template>
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ clientSurfaceRegistryKey,
4
+ type ClientPluginContext,
5
+ type ClientSurfaceRegistry,
6
+ } from "@frockbot/client-core";
7
+ import { computed, ref } from "vue";
8
+ import { voiceClientPlugin } from "./index.js";
9
+ import { voiceClientStateKey, type VoiceClientStateV1 } from "./state.js";
10
+
11
+ function surfaceRegistry(): ClientSurfaceRegistry {
12
+ const activeId = ref<string>();
13
+ return {
14
+ activeId,
15
+ active: computed(() => undefined),
16
+ register: () => () => {},
17
+ has: () => false,
18
+ open: (id) => {
19
+ activeId.value = id;
20
+ },
21
+ close: () => {
22
+ activeId.value = undefined;
23
+ },
24
+ };
25
+ }
26
+
27
+ function mount(
28
+ hostedRequest: NonNullable<ClientPluginContext["transport"]["hostedRequest"]>,
29
+ ): { calls: string[]; state: { value: VoiceClientStateV1 } } {
30
+ const calls: string[] = [];
31
+ const surfaces = surfaceRegistry();
32
+ let state: unknown;
33
+ const context: ClientPluginContext = {
34
+ transport: {
35
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
36
+ hostedRequest: (path, method, body) => {
37
+ calls.push(path);
38
+ return hostedRequest(path, method, body);
39
+ },
40
+ },
41
+ inject: (key) => {
42
+ if (key === clientSurfaceRegistryKey) return surfaces as never;
43
+ throw new Error("unexpected client provider");
44
+ },
45
+ provide: (key, value) => {
46
+ if (key === voiceClientStateKey) state = value;
47
+ return () => {};
48
+ },
49
+ slot: () => () => {},
50
+ };
51
+ voiceClientPlugin(context);
52
+ return {
53
+ calls,
54
+ state: state as { value: VoiceClientStateV1 },
55
+ };
56
+ }
57
+
58
+ describe("Voice client contribution", () => {
59
+ test("does not read User voice state before authenticated chrome mounts", () => {
60
+ const mounted = mount(() => Promise.reject(new Error("unauthenticated")));
61
+
62
+ expect(mounted.calls).toEqual([]);
63
+ });
64
+
65
+ test("reads User voice state when authenticated chrome asks for it", async () => {
66
+ const mounted = mount(() =>
67
+ Promise.resolve({
68
+ schemaVersion: 1,
69
+ ledger: {
70
+ schemaVersion: 1,
71
+ state: {
72
+ schemaVersion: 1,
73
+ enabled: false,
74
+ updatedAt: "2026-09-04T00:00:00.000Z",
75
+ },
76
+ sessions: [],
77
+ pendingAnswers: [],
78
+ },
79
+ quota: {
80
+ schemaVersion: 1,
81
+ month: "2026-09",
82
+ usedSeconds: 120,
83
+ limitSeconds: 3_600,
84
+ remainingSeconds: 3_480,
85
+ },
86
+ }),
87
+ );
88
+
89
+ await mounted.state.value.refresh();
90
+
91
+ expect(mounted.calls).toEqual(["/api/voice"]);
92
+ expect(mounted.state.value.quotaRemainingSeconds).toBe(3_480);
93
+ expect(mounted.state.value.quotaLimitSeconds).toBe(3_600);
94
+ });
95
+ });
@@ -0,0 +1,330 @@
1
+ import {
2
+ clientSurfaceRegistryKey,
3
+ type ClientPlugin,
4
+ type VoiceAssistantSessionV1,
5
+ } from "@frockbot/client-core";
6
+ import { defineClientContribution } from "@frockbot/kernel-contracts/contributions";
7
+ import {
8
+ startVoiceMicrophoneV1,
9
+ voiceCaptureSupportedV1,
10
+ voiceMicrophoneRefusalV1,
11
+ type VoiceMicrophoneV1,
12
+ } from "@frockbot/plugin-shell/client/voice-microphone";
13
+ import { showClientNotificationV1 } from "@frockbot/plugin-shell/client/notify";
14
+ import { VOICE_ASSISTANT_INPUT_SAMPLE_RATE_V1 } from "@frockbot/protocol";
15
+ import { ref } from "vue";
16
+ import {
17
+ decodeVoiceAssistantViewV1,
18
+ VOICE_MAX_TOOL_CALLS_V1,
19
+ VOICE_MAX_TRANSCRIPT_ENTRIES_V1,
20
+ type VoiceToolNameV1,
21
+ } from "../shared.js";
22
+ import { createVoicePlaybackV1, type VoicePlaybackV1 } from "./playback.js";
23
+ import { deliverPendingVoiceNotificationsV1 } from "./pending-notifications.js";
24
+ import VoiceSurface from "./VoiceSurface.vue";
25
+ import VoiceToggle from "./VoiceToggle.vue";
26
+ import { voiceClientStateKey, type VoiceClientStateV1 } from "./state.js";
27
+ import "./styles.css";
28
+
29
+ export const VOICE_SURFACE_ID_V1 = "voice";
30
+ const DEVICE_KEY_V1 = "frockbot.voice.device.v1";
31
+ const TOOL_NAMES: readonly VoiceToolNameV1[] = [
32
+ "list_bots",
33
+ "bot_activity",
34
+ "memory_search",
35
+ "pending_answers",
36
+ "ask_bot",
37
+ ];
38
+ const VOICE_PENDING_POLL_INTERVAL_MS_V1 = 15_000;
39
+
40
+ function deviceIdV1(): string {
41
+ if (typeof window === "undefined") return crypto.randomUUID();
42
+ try {
43
+ const stored = window.localStorage.getItem(DEVICE_KEY_V1);
44
+ if (stored && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(stored)) {
45
+ return stored;
46
+ }
47
+ const created = crypto.randomUUID();
48
+ window.localStorage.setItem(DEVICE_KEY_V1, created);
49
+ return created;
50
+ } catch {
51
+ return crypto.randomUUID();
52
+ }
53
+ }
54
+
55
+ export const voiceClientPlugin: ClientPlugin = (ctx) => {
56
+ const request = ctx.transport.hostedRequest?.bind(ctx.transport);
57
+ const openAssistant = ctx.transport.openVoiceAssistant?.bind(ctx.transport);
58
+ const surfaces = ctx.inject(clientSurfaceRegistryKey);
59
+ let microphone: VoiceMicrophoneV1 | undefined;
60
+ let playback: VoicePlaybackV1 | undefined;
61
+ let session: VoiceAssistantSessionV1 | undefined;
62
+ let serverEnded = false;
63
+ /** Invalidates a permission prompt or socket callback from an older toggle. */
64
+ let attempt = 0;
65
+ let quotaTicker: ReturnType<typeof setInterval> | undefined;
66
+ let refreshInFlight: Promise<void> | undefined;
67
+
68
+ const releaseMedia = async (): Promise<void> => {
69
+ if (quotaTicker !== undefined) clearInterval(quotaTicker);
70
+ quotaTicker = undefined;
71
+ const heldMicrophone = microphone;
72
+ const heldPlayback = playback;
73
+ microphone = undefined;
74
+ playback = undefined;
75
+ await Promise.all([heldMicrophone?.stop(), heldPlayback?.close()]);
76
+ };
77
+
78
+ const notifyOffline = async (message: string): Promise<void> => {
79
+ await showClientNotificationV1({
80
+ title: "Voice is offline",
81
+ body: message,
82
+ });
83
+ };
84
+
85
+ const refresh = (): Promise<void> => {
86
+ if (!request) return Promise.resolve();
87
+ if (refreshInFlight) return refreshInFlight;
88
+ const currentAttempt = attempt;
89
+ refreshInFlight = request("/api/voice")
90
+ .then(decodeVoiceAssistantViewV1)
91
+ .then(async (view) => {
92
+ // The monthly ceiling is stable even if the User turns Voice on while
93
+ // this read is in flight. Live remaining time is owned by the socket.
94
+ state.value.quotaLimitSeconds = view.quota.limitSeconds;
95
+ if (currentAttempt !== attempt || state.value.status !== "offline") {
96
+ return;
97
+ }
98
+ state.value.enabled = view.ledger.state.enabled;
99
+ state.value.quotaRemainingSeconds = view.quota.remainingSeconds;
100
+ const latest = view.ledger.state.activeSessionId
101
+ ? view.ledger.sessions.find(
102
+ (entry) => entry.sessionId === view.ledger.state.activeSessionId,
103
+ )
104
+ : view.ledger.sessions[0];
105
+ if (latest) {
106
+ state.value.session = latest;
107
+ state.value.transcript = latest.transcript;
108
+ state.value.tools = latest.toolCalls;
109
+ }
110
+ if (view.ledger.state.enabled) {
111
+ state.value.message =
112
+ "Voice is ready to resume. Turn it on to reconnect this device.";
113
+ } else {
114
+ await deliverPendingVoiceNotificationsV1(view.ledger.pendingAnswers);
115
+ }
116
+ })
117
+ .catch(() => {
118
+ if (currentAttempt === attempt && state.value.status === "offline") {
119
+ state.value.message = "Voice status couldn't be loaded.";
120
+ }
121
+ })
122
+ .finally(() => {
123
+ refreshInFlight = undefined;
124
+ });
125
+ return refreshInFlight;
126
+ };
127
+
128
+ const state = ref<VoiceClientStateV1>({
129
+ enabled: false,
130
+ status: "offline",
131
+ level: 0,
132
+ quotaRemainingSeconds: 0,
133
+ quotaLimitSeconds: 0,
134
+ transcript: [],
135
+ tools: [],
136
+ refresh,
137
+ open() {
138
+ surfaces.open(VOICE_SURFACE_ID_V1);
139
+ },
140
+ async toggle() {
141
+ state.value.open();
142
+ if (state.value.status !== "offline") {
143
+ attempt += 1;
144
+ state.value.enabled = false;
145
+ state.value.status = "offline";
146
+ state.value.message = "Voice is off.";
147
+ const stopping = session;
148
+ session = undefined;
149
+ stopping?.stop();
150
+ await releaseMedia();
151
+ return;
152
+ }
153
+ if (!openAssistant || !voiceCaptureSupportedV1()) {
154
+ state.value.message = "Voice isn't available on this device.";
155
+ return;
156
+ }
157
+ state.value.status = "connecting";
158
+ state.value.message = undefined;
159
+ state.value.enabled = true;
160
+ serverEnded = false;
161
+ const currentAttempt = ++attempt;
162
+ let openingSession: VoiceAssistantSessionV1 | undefined;
163
+ let openedMicrophone: VoiceMicrophoneV1 | undefined;
164
+ let openedPlayback: VoicePlaybackV1 | undefined;
165
+ try {
166
+ openedMicrophone = await startVoiceMicrophoneV1({
167
+ sampleRate: VOICE_ASSISTANT_INPUT_SAMPLE_RATE_V1,
168
+ audio(audio) {
169
+ openingSession?.sendAudio(audio);
170
+ },
171
+ level(level) {
172
+ if (currentAttempt !== attempt) return;
173
+ state.value.level = level;
174
+ },
175
+ });
176
+ if (currentAttempt !== attempt) {
177
+ await openedMicrophone.stop();
178
+ return;
179
+ }
180
+ openedPlayback = createVoicePlaybackV1();
181
+ openingSession = openAssistant(deviceIdV1(), {
182
+ ready(sessionId, quotaRemainingSeconds) {
183
+ if (currentAttempt !== attempt) return;
184
+ if (quotaTicker !== undefined) clearInterval(quotaTicker);
185
+ const quotaStartedAt = Date.now();
186
+ state.value.session = {
187
+ sessionId,
188
+ startedAt: new Date().toISOString(),
189
+ };
190
+ state.value.transcript = [];
191
+ state.value.tools = [];
192
+ state.value.quotaRemainingSeconds = quotaRemainingSeconds;
193
+ state.value.status = "listening";
194
+ quotaTicker = setInterval(() => {
195
+ if (currentAttempt !== attempt) return;
196
+ const elapsed = Math.floor((Date.now() - quotaStartedAt) / 1_000);
197
+ state.value.quotaRemainingSeconds = Math.max(
198
+ 0,
199
+ quotaRemainingSeconds - elapsed,
200
+ );
201
+ }, 1_000);
202
+ },
203
+ state(liveState) {
204
+ if (currentAttempt !== attempt) return;
205
+ state.value.status = liveState;
206
+ },
207
+ transcript(entry) {
208
+ if (currentAttempt !== attempt) return;
209
+ state.value.transcript = [
210
+ ...state.value.transcript,
211
+ { schemaVersion: 1 as const, ...entry },
212
+ ].slice(-VOICE_MAX_TRANSCRIPT_ENTRIES_V1);
213
+ },
214
+ tool(entry) {
215
+ if (currentAttempt !== attempt) return;
216
+ if (!TOOL_NAMES.includes(entry.name as VoiceToolNameV1)) return;
217
+ state.value.tools = [
218
+ ...state.value.tools,
219
+ {
220
+ schemaVersion: 1 as const,
221
+ ...entry,
222
+ name: entry.name as VoiceToolNameV1,
223
+ },
224
+ ].slice(-VOICE_MAX_TOOL_CALLS_V1);
225
+ },
226
+ audio(audio) {
227
+ if (currentAttempt !== attempt) return;
228
+ openedPlayback?.play(audio);
229
+ },
230
+ interrupted() {
231
+ if (currentAttempt !== attempt) return;
232
+ openedPlayback?.interrupt();
233
+ state.value.status = "listening";
234
+ },
235
+ offline(reason, message) {
236
+ if (currentAttempt !== attempt) return;
237
+ attempt += 1;
238
+ serverEnded = true;
239
+ session = undefined;
240
+ state.value.enabled = false;
241
+ state.value.status = "offline";
242
+ state.value.message = message;
243
+ void releaseMedia();
244
+ if (reason === "quota" || reason === "error") {
245
+ void notifyOffline(message);
246
+ }
247
+ },
248
+ failed(message) {
249
+ if (currentAttempt !== attempt || serverEnded) return;
250
+ attempt += 1;
251
+ serverEnded = true;
252
+ session = undefined;
253
+ state.value.enabled = false;
254
+ state.value.status = "offline";
255
+ state.value.message = message;
256
+ void releaseMedia();
257
+ void notifyOffline(message);
258
+ },
259
+ closed() {
260
+ if (currentAttempt !== attempt) return;
261
+ attempt += 1;
262
+ session = undefined;
263
+ if (serverEnded || state.value.status === "offline") return;
264
+ state.value.status = "offline";
265
+ state.value.message =
266
+ "Voice is still ready. Turn it on to reconnect this device.";
267
+ void releaseMedia();
268
+ },
269
+ });
270
+ if (currentAttempt !== attempt) {
271
+ openingSession.close();
272
+ await Promise.all([openedMicrophone.stop(), openedPlayback.close()]);
273
+ return;
274
+ }
275
+ microphone = openedMicrophone;
276
+ playback = openedPlayback;
277
+ session = openingSession;
278
+ } catch (error) {
279
+ if (currentAttempt !== attempt) {
280
+ openingSession?.close();
281
+ await Promise.all([
282
+ openedMicrophone?.stop(),
283
+ openedPlayback?.close(),
284
+ ]);
285
+ return;
286
+ }
287
+ attempt += 1;
288
+ state.value.enabled = false;
289
+ state.value.status = "offline";
290
+ state.value.message = voiceMicrophoneRefusalV1(error);
291
+ openingSession?.stop();
292
+ session = undefined;
293
+ await Promise.all([openedMicrophone?.stop(), openedPlayback?.close()]);
294
+ }
295
+ },
296
+ });
297
+
298
+ const pendingPoll = setInterval(() => {
299
+ if (state.value.status === "offline") void refresh();
300
+ }, VOICE_PENDING_POLL_INTERVAL_MS_V1);
301
+
302
+ return [
303
+ ctx.provide(voiceClientStateKey, state),
304
+ surfaces.register({
305
+ id: VOICE_SURFACE_ID_V1,
306
+ title: "Voice",
307
+ component: VoiceSurface,
308
+ placement: "panel",
309
+ }),
310
+ ctx.slot({
311
+ slot: "frockbot.header-actions",
312
+ order: 20,
313
+ component: VoiceToggle,
314
+ }),
315
+ () => {
316
+ clearInterval(pendingPoll);
317
+ attempt += 1;
318
+ session?.close();
319
+ session = undefined;
320
+ void releaseMedia();
321
+ },
322
+ ];
323
+ };
324
+
325
+ export const clientContribution = defineClientContribution<ClientPlugin>({
326
+ specifier: "@frockbot/plugin-voice/client",
327
+ plugin: voiceClientPlugin,
328
+ });
329
+
330
+ export default voiceClientPlugin;