@omnivoice/page-agent 0.1.0 → 0.3.0

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.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * O PLUGUE DE VOZ. Conecta a sessão de voz ao page-agent e ao cérebro.
3
+ *
4
+ * Desenho (seção 5.1 da spec de voz):
5
+ * - Este módulo é o ÚNICO que conhece tanto VoiceSession quanto page-agent.
6
+ * - Abaixo dele fica a sessão de voz: testável sem navegador, sem LLM, sem page-agent.
7
+ * - Acima ficam observadores (painel, brain, rotina-executor) que consomem eventos.
8
+ *
9
+ * O que conecta: OBSERVAÇÃO (leitura) apenas.
10
+ * - Entrada de voz → prepara e passa ao agente como "input do usuário"
11
+ * - Resposta do agente → transforma em fala (não está aqui; é na sessão)
12
+ * - Contexto compartilhado: histórico de mensagens + estado do agente
13
+ *
14
+ * Um dono por estado mutável: VoiceSessionController é quem gerencia
15
+ * alternância voz ↔ chat, e ninguém mais mexe no state de turno.
16
+ */
17
+
18
+ import type { VoiceContext, Message, InputChannel, VoiceEvent, VoiceSnapshot } from "./types.ts";
19
+
20
+ /**
21
+ * Entrada de um usuário: voz transcrita ou texto digitado.
22
+ * Ambos são normalizados para "utterance" (enunciado).
23
+ */
24
+ export type UserInput = {
25
+ utterance: string;
26
+ channel: InputChannel;
27
+ confidence?: number; // 0-1 para transcrição de fala
28
+ timestamp: number;
29
+ };
30
+
31
+ /**
32
+ * Contrato que a sessão de voz expõe para o bridge.
33
+ * Apenas leitura: o bridge nunca escreve no estado da sessão.
34
+ */
35
+ export interface VoiceSessionReader {
36
+ readonly sessionId: string;
37
+ readonly context: VoiceContext;
38
+ readonly snapshot: () => VoiceSnapshot;
39
+
40
+ /** Enfileira entrada do usuário (voz ou texto). */
41
+ enqueueInput(input: UserInput): void;
42
+
43
+ /** Itera eventos públicos. */
44
+ events(): AsyncIterable<VoiceEvent>;
45
+
46
+ /** Muta/desmuta. */
47
+ setMuted(muted: boolean): void;
48
+ }
49
+
50
+ /**
51
+ * Observador de entrada: o bridge escuta eventos de voz e input de texto,
52
+ * normaliza para UserInput, e passa à sessão.
53
+ *
54
+ * Exemplo de uso:
55
+ * const bridge = new VoiceBridge(voiceSession, pageAgent);
56
+ * pageAgent.onUserInput((text) => bridge.onUserText(text));
57
+ * voiceSession.events().forEach((ev) => bridge.onVoiceEvent(ev));
58
+ */
59
+ export class VoiceBridge {
60
+ private session: VoiceSessionReader;
61
+ private messageBuffer: Message[] = [];
62
+
63
+ constructor(session: VoiceSessionReader) {
64
+ this.session = session;
65
+ }
66
+
67
+ /**
68
+ * Entrada de texto do usuário (chat).
69
+ * Normalizada para UserInput e enfileirada.
70
+ */
71
+ onUserText(text: string): void {
72
+ if (!text.trim()) return;
73
+
74
+ const input: UserInput = {
75
+ utterance: text.trim(),
76
+ channel: "chat",
77
+ timestamp: Date.now(),
78
+ };
79
+
80
+ this.session.enqueueInput(input);
81
+ this.recordMessage({
82
+ role: "user",
83
+ content: text,
84
+ channel: "chat",
85
+ timestamp: input.timestamp,
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Evento de voz: transcrição + confiança.
91
+ * Normaliza e enfileira se for um evento de "turno ouvido".
92
+ */
93
+ onVoiceEvent(event: VoiceEvent): void {
94
+ if (event.kind === "turn.heard") {
95
+ const { text, confidence } = event.data;
96
+ if (!text.trim()) return;
97
+
98
+ const input: UserInput = {
99
+ utterance: text.trim(),
100
+ channel: "voice",
101
+ confidence,
102
+ timestamp: Date.now(),
103
+ };
104
+
105
+ this.session.enqueueInput(input);
106
+ this.recordMessage({
107
+ role: "user",
108
+ content: text,
109
+ channel: "voice",
110
+ confidence,
111
+ timestamp: input.timestamp,
112
+ });
113
+ }
114
+
115
+ if (event.kind === "agent.finished") {
116
+ const { text } = event.data;
117
+ this.recordMessage({
118
+ role: "assistant",
119
+ content: text,
120
+ channel: this.session.context.channel,
121
+ timestamp: Date.now(),
122
+ });
123
+ }
124
+
125
+ if (event.kind === "channel.switched") {
126
+ const { from, to } = event.data;
127
+ // Nada a fazer aqui: o histórico não muda.
128
+ // O contexto já foi atualizado pela sessão.
129
+ console.debug(`[VoiceBridge] canais alternados: ${from} → ${to}`);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Contexto atual: tudo o que o page-agent precisa saber sobre voz.
135
+ * Leitura apenas; nunca muta.
136
+ */
137
+ get context(): VoiceContext {
138
+ return this.session.context;
139
+ }
140
+
141
+ /**
142
+ * Snapshot para o painel.
143
+ */
144
+ get snapshot(): VoiceSnapshot {
145
+ return this.session.snapshot();
146
+ }
147
+
148
+ /**
149
+ * Histórico de mensagens: construído incrementalmente
150
+ * conforme entradas chegam e respostas são produzidas.
151
+ */
152
+ get messages(): readonly Message[] {
153
+ return this.messageBuffer;
154
+ }
155
+
156
+ /**
157
+ * Muta/desmuta o microfone.
158
+ */
159
+ setMuted(muted: boolean): void {
160
+ this.session.setMuted(muted);
161
+ }
162
+
163
+ /**
164
+ * Registra uma mensagem no histórico local do bridge.
165
+ * Não persiste (isso é job do store se necessário).
166
+ */
167
+ private recordMessage(msg: Omit<Message, "timestamp"> & { timestamp: number }): void {
168
+ this.messageBuffer.push(msg as Message);
169
+
170
+ // Keeps recent history: discard older than 2 hours
171
+ const cutoff = Date.now() - 2 * 60 * 60 * 1000;
172
+ this.messageBuffer = this.messageBuffer.filter((m) => m.timestamp > cutoff);
173
+ }
174
+
175
+ /**
176
+ * Reset para testes ou novo começo de conversa.
177
+ */
178
+ clearHistory(): void {
179
+ this.messageBuffer = [];
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Controlador de sessão: gerencia transições de canal (voz ↔ chat)
185
+ * e manutenção de estado.
186
+ *
187
+ * Um dono por estado mutável: só aqui muda de "voice" para "chat".
188
+ * Ninguém mais muda VoiceContext.channel.
189
+ */
190
+ export class VoiceSessionController {
191
+ private session: VoiceSessionReader;
192
+ private bridge: VoiceBridge;
193
+
194
+ constructor(session: VoiceSessionReader, bridge: VoiceBridge) {
195
+ this.session = session;
196
+ this.bridge = bridge;
197
+ }
198
+
199
+ /**
200
+ * Muda de canal (voz → chat ou chat → voz).
201
+ * O contexto é preservado: histórico segue intacto.
202
+ * Só muda a ORIGEM do próximo input.
203
+ *
204
+ * Implementação: sinaliza à sessão e deixa ela gerir o event loop.
205
+ */
206
+ switchChannel(to: InputChannel): void {
207
+ const from = this.session.context.channel;
208
+ if (from === to) return; // noop
209
+
210
+ // A sessão trata essa mudança: emite event "channel.switched"
211
+ // e atualiza seu próprio context.channel.
212
+ // O bridge recebe e registra no histórico se necessário.
213
+ // (Implementação fica em session.ts)
214
+ console.debug(`[Controller] solicitando mudança: ${from} → ${to}`);
215
+ }
216
+
217
+ /**
218
+ * Contexto atual (apenas leitura).
219
+ */
220
+ get context(): VoiceContext {
221
+ return this.session.context;
222
+ }
223
+
224
+ /**
225
+ * Muta/desmuta.
226
+ */
227
+ setMuted(muted: boolean): void {
228
+ this.bridge.setMuted(muted);
229
+ }
230
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Configuração de voz lida do .env e environment.
3
+ * Padrão: tudo é imutável após criação (frozen dataclass).
4
+ * Semelhante a duplex/config.py de NoStopPageTest.
5
+ */
6
+
7
+ import type { SpeechMode } from "./types.ts";
8
+
9
+ /** Provedores de LLM disponíveis. */
10
+ export type LLMProvider = "openai" | "typesafe" | "local";
11
+
12
+ /** Backend de ASR local. */
13
+ export type ASRBackend = "auto" | "cpu" | "gpu" | "wasm";
14
+
15
+ /**
16
+ * Configuração de voz congelada após criação.
17
+ * Todos os valores sensíveis (.repr = false) nunca são logados.
18
+ */
19
+ export interface VoiceSettings {
20
+ readonly speechMode: SpeechMode;
21
+
22
+ // OpenAI Realtime
23
+ readonly openaiApiKey: string; // repr=false
24
+ readonly realtimeVoice: string; // e.g., "marin"
25
+ readonly nostopServerUrl: string; // URL do servidor NoStopPageTest (proxy para Realtime)
26
+
27
+ // LLM (rápida decisão + agente)
28
+ readonly quickProvider: LLMProvider;
29
+ readonly quickApiKey: string; // repr=false
30
+ readonly quickBaseUrl: string;
31
+ readonly quickModel: string;
32
+ readonly agentModel: string;
33
+
34
+ // LLM alternativo: TypeSafe
35
+ readonly jevApiKey: string; // repr=false
36
+ readonly jevBaseUrl: string;
37
+ readonly jevModel: string;
38
+
39
+ // ASR local (Nemotron)
40
+ readonly asrModel: string; // path ou identificador
41
+ readonly asrBackend: ASRBackend;
42
+ readonly asrLanguage: string; // e.g., "pt-BR"
43
+
44
+ // TTS local (Supertonic)
45
+ readonly tts: "supertonic" | "off";
46
+ readonly superttonicVoice: string; // e.g., "F4"
47
+ readonly superttonicThreads: number;
48
+ readonly superttonicSteps: number;
49
+
50
+ // Áudio
51
+ readonly mic: boolean;
52
+ readonly micFile?: string;
53
+ readonly endSilenceMs: number;
54
+ readonly duck: number; // 0-1, ganho enquanto usuário fala
55
+
56
+ // Sessão
57
+ readonly sessionId: string;
58
+ readonly sessionStore: "memory" | "sqlite" | "redis";
59
+ readonly maxSessions: number;
60
+
61
+ // Debug
62
+ readonly trace: boolean;
63
+ readonly mock: boolean;
64
+ }
65
+
66
+ /**
67
+ * Padrões globais: todos ficam aqui. Environment pode sobrescrever.
68
+ */
69
+ const DEFAULTS: Readonly<Omit<VoiceSettings, "sessionId">> = {
70
+ speechMode: "local",
71
+
72
+ openaiApiKey: "",
73
+ realtimeVoice: "marin",
74
+ nostopServerUrl: "http://localhost:8000",
75
+
76
+ quickProvider: "openai",
77
+ quickApiKey: "",
78
+ quickBaseUrl: "https://api.openai.com/v1",
79
+ quickModel: "gpt-4.5-mini",
80
+ agentModel: "gpt-4-turbo",
81
+
82
+ jevApiKey: "",
83
+ jevBaseUrl: "https://api.typesafe.ai/v1",
84
+ jevModel: "jev-latest",
85
+
86
+ asrModel: "models/nemotron-3.5-asr-streaming-0.6b-q8_0.gguf",
87
+ asrBackend: "auto",
88
+ asrLanguage: "pt-BR",
89
+
90
+ tts: "supertonic",
91
+ superttonicVoice: "F4",
92
+ superttonicThreads: 4,
93
+ superttonicSteps: 6,
94
+
95
+ mic: true,
96
+ endSilenceMs: 250,
97
+ duck: 0.0,
98
+
99
+ sessionStore: "memory",
100
+ maxSessions: 8,
101
+
102
+ trace: false,
103
+ mock: false,
104
+ };
105
+
106
+ /**
107
+ * Lê configuração do .env e environment, validando backends conhecidos.
108
+ * Se um backend não é reconhecido, mantém o default em vez de rejeitar.
109
+ */
110
+ export function readVoiceSettings(env: Record<string, string> = process.env): VoiceSettings {
111
+ const get = (key: string, fallback: string = ""): string => env[key] ?? fallback;
112
+ const getBool = (key: string, fallback: boolean = false): boolean => {
113
+ const val = get(key);
114
+ return val === "1" || val === "true" ? true : val === "0" || val === "false" ? false : fallback;
115
+ };
116
+ const getInt = (key: string, fallback: number = 0): number => {
117
+ const val = parseInt(get(key), 10);
118
+ return Number.isNaN(val) ? fallback : Math.max(1, val);
119
+ };
120
+ const getFloat = (key: string, fallback: number = 0): number => {
121
+ const val = parseFloat(get(key));
122
+ return Number.isNaN(val) ? fallback : val;
123
+ };
124
+
125
+ const speechMode = getSpeechChoice(get("SPEECH_MODE", get("SPEECH", DEFAULTS.speechMode)));
126
+ const sessionStore = (["memory", "sqlite", "redis"].includes(get("SESSION_STORE")) ? get("SESSION_STORE") : DEFAULTS.sessionStore) as VoiceSettings["sessionStore"];
127
+ const asrBackend = (["auto", "cpu", "gpu", "wasm"].includes(get("ASR_BACKEND")) ? get("ASR_BACKEND") : DEFAULTS.asrBackend) as ASRBackend;
128
+ const tts = (get("TTS") === "off" ? "off" : "supertonic") as "supertonic" | "off";
129
+ const quickProvider = (["openai", "typesafe", "local"].includes(get("QUICK_PROVIDER")) ? get("QUICK_PROVIDER") : DEFAULTS.quickProvider) as LLMProvider;
130
+
131
+ return Object.freeze({
132
+ speechMode,
133
+
134
+ openaiApiKey: get("OPENAI_API_KEY"),
135
+ realtimeVoice: get("REALTIME_VOICE", DEFAULTS.realtimeVoice),
136
+ nostopServerUrl: get("NOSTOP_SERVER_URL", DEFAULTS.nostopServerUrl),
137
+
138
+ quickProvider,
139
+ quickApiKey: get("QUICK_API_KEY", get("OPENAI_API_KEY")),
140
+ quickBaseUrl: get("QUICK_BASE_URL", DEFAULTS.quickBaseUrl),
141
+ quickModel: get("QUICK_MODEL", DEFAULTS.quickModel),
142
+ agentModel: get("AGENT_MODEL", DEFAULTS.agentModel),
143
+
144
+ jevApiKey: get("JEV_API_KEY", get("TYPESAFE_JEV_APIKEY")),
145
+ jevBaseUrl: get("JEV_BASE_URL", DEFAULTS.jevBaseUrl),
146
+ jevModel: get("JEV_MODEL", DEFAULTS.jevModel),
147
+
148
+ asrModel: get("ASR_MODEL", DEFAULTS.asrModel),
149
+ asrBackend,
150
+ asrLanguage: get("ASR_LANGUAGE", DEFAULTS.asrLanguage),
151
+
152
+ tts,
153
+ superttonicVoice: get("SUPERTONIC_VOICE", DEFAULTS.superttonicVoice),
154
+ superttonicThreads: getInt("SUPERTONIC_THREADS", DEFAULTS.superttonicThreads),
155
+ superttonicSteps: getInt("SUPERTONIC_STEPS", DEFAULTS.superttonicSteps),
156
+
157
+ mic: getBool("MIC", DEFAULTS.mic),
158
+ micFile: get("MIC_FILE"),
159
+ endSilenceMs: getInt("END_SILENCE_MS", DEFAULTS.endSilenceMs),
160
+ duck: getFloat("DUCK", DEFAULTS.duck),
161
+
162
+ sessionId: get("SESSION_ID", `session-${Date.now()}`),
163
+ sessionStore,
164
+ maxSessions: getInt("MAX_SESSIONS", DEFAULTS.maxSessions),
165
+
166
+ trace: getBool("TRACE"),
167
+ mock: getBool("MOCK"),
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Normaliza escolha de fala: aceita "1"/"local"/"realtime"/"2", case-insensitive.
173
+ * Qualquer coisa desconhecida vira "local".
174
+ */
175
+ function getSpeechChoice(choice: string): SpeechMode {
176
+ const normalized = choice.trim().toLowerCase();
177
+ if (normalized === "1" || normalized === "local") return "local";
178
+ if (normalized === "2" || normalized === "realtime") return "realtime";
179
+ return "local";
180
+ }
181
+
182
+ /**
183
+ * Valida se a configuração está pronta para rodar em um modo específico.
184
+ * Retorna mensagem de erro, ou undefined se está ok.
185
+ */
186
+ export function validateSettings(settings: VoiceSettings): string | undefined {
187
+ if (settings.speechMode === "realtime" && !settings.openaiApiKey) {
188
+ return "realtime mode requires OPENAI_API_KEY";
189
+ }
190
+
191
+ if (settings.quickProvider === "openai" && !settings.quickApiKey) {
192
+ return "OpenAI quick provider requires OPENAI_API_KEY or QUICK_API_KEY";
193
+ }
194
+
195
+ if (settings.quickProvider === "typesafe" && !settings.jevApiKey) {
196
+ return "TypeSafe quick provider requires TYPESAFE_JEV_APIKEY or JEV_API_KEY";
197
+ }
198
+
199
+ return undefined;
200
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Factory de engines de voz: seleciona entre local e realtime (OpenAI full-duplex).
3
+ * O RealtimeEngine entra via WebSocket para o servidor NoStopPageTest.
4
+ */
5
+
6
+ import type { VoiceEngine, SpeechMode } from "./types.ts";
7
+ import type { VoiceSettings } from "./config.ts";
8
+
9
+ /**
10
+ * Stub do LocalEngine: os modelos rodam no device.
11
+ * Implementação completa vem do POC NoStopPageTest/duplex/local_engine.py,
12
+ * mas aqui é um wrapper mínimo para manter a interface.
13
+ */
14
+ class LocalEngine implements VoiceEngine {
15
+ private settings: VoiceSettings;
16
+ private muted = false;
17
+
18
+ constructor(settings: VoiceSettings) {
19
+ this.settings = settings;
20
+ }
21
+
22
+ async enter(): Promise<void> {
23
+ console.log("[LocalEngine] Abrindo motor local (stub)");
24
+ // Implementar integração com Nemotron ASR + Supertonic TTS aqui
25
+ }
26
+
27
+ async exit(): Promise<void> {
28
+ console.log("[LocalEngine] Fechando motor local");
29
+ }
30
+
31
+ setMuted(muted: boolean): void {
32
+ this.muted = muted;
33
+ }
34
+
35
+ isMuted(): boolean {
36
+ return this.muted;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * RealtimeEngine: conecta ao OpenAI Realtime API full-duplex via servidor NoStopPageTest.
42
+ * O servidor faz proxy: navegador → servidor → OpenAI Realtime.
43
+ */
44
+ class RealtimeEngine implements VoiceEngine {
45
+ private settings: VoiceSettings;
46
+ private muted = false;
47
+ private socket: WebSocket | null = null;
48
+ private sessionId: string | null = null;
49
+
50
+ constructor(settings: VoiceSettings) {
51
+ this.settings = settings;
52
+ }
53
+
54
+ async enter(): Promise<void> {
55
+ console.log("[RealtimeEngine] Abrindo conexão OpenAI Realtime full-duplex");
56
+
57
+ // Obter session_id do servidor NoStopPageTest
58
+ const baseUrl = this.settings.nostopServerUrl || "http://localhost:8000";
59
+
60
+ try {
61
+ const leaseResponse = await fetch(`${baseUrl}/lease`, {
62
+ method: "GET",
63
+ credentials: "include",
64
+ });
65
+
66
+ if (!leaseResponse.ok) {
67
+ throw new Error(`Falha ao obter lease: ${leaseResponse.status}`);
68
+ }
69
+
70
+ const leaseData = await leaseResponse.json();
71
+ this.sessionId = leaseData.session_id;
72
+
73
+ if (!this.sessionId) {
74
+ throw new Error("Nenhum session_id retornado do servidor");
75
+ }
76
+
77
+ // Conectar ao WebSocket do Realtime
78
+ const wsUrl = new URL(`${baseUrl}/realtime`);
79
+ wsUrl.searchParams.set("session", this.sessionId);
80
+
81
+ this.socket = new WebSocket(wsUrl.href);
82
+
83
+ return new Promise((resolve, reject) => {
84
+ if (!this.socket) {
85
+ reject(new Error("WebSocket não foi criado"));
86
+ return;
87
+ }
88
+
89
+ const onOpen = () => {
90
+ console.log("[RealtimeEngine] WebSocket conectado");
91
+ this.socket?.removeEventListener("open", onOpen);
92
+ this.socket?.removeEventListener("error", onError);
93
+ resolve();
94
+ };
95
+
96
+ const onError = (event: Event) => {
97
+ console.error("[RealtimeEngine] Erro ao conectar", event);
98
+ this.socket?.removeEventListener("open", onOpen);
99
+ this.socket?.removeEventListener("error", onError);
100
+ reject(new Error("Falha ao conectar WebSocket"));
101
+ };
102
+
103
+ this.socket.addEventListener("open", onOpen);
104
+ this.socket.addEventListener("error", onError);
105
+ });
106
+ } catch (error) {
107
+ console.error("[RealtimeEngine.enter]", error);
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ async exit(): Promise<void> {
113
+ console.log("[RealtimeEngine] Fechando conexão");
114
+ if (this.socket) {
115
+ this.socket.close();
116
+ this.socket = null;
117
+ }
118
+ this.sessionId = null;
119
+ }
120
+
121
+ setMuted(muted: boolean): void {
122
+ this.muted = muted;
123
+ // Sinalizar ao servidor que o mic está mutado
124
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
125
+ this.socket.send(
126
+ JSON.stringify({
127
+ type: "mute",
128
+ muted,
129
+ })
130
+ );
131
+ }
132
+ }
133
+
134
+ isMuted(): boolean {
135
+ return this.muted;
136
+ }
137
+
138
+ getSocket(): WebSocket | null {
139
+ return this.socket;
140
+ }
141
+
142
+ getSessionId(): string | null {
143
+ return this.sessionId;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Factory: seleciona e cria a engine correta.
149
+ */
150
+ export async function createEngine(settings: VoiceSettings): Promise<VoiceEngine> {
151
+ if (settings.speechMode === "realtime") {
152
+ if (!settings.openaiApiKey) {
153
+ throw new Error(
154
+ "Realtime mode requer OPENAI_API_KEY. Configure a variável de ambiente."
155
+ );
156
+ }
157
+ return new RealtimeEngine(settings);
158
+ }
159
+
160
+ return new LocalEngine(settings);
161
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * PageAgent Voice Module: OpenAI Realtime full-duplex integration
3
+ *
4
+ * Export the complete voice system: session management, engines, config, and types.
5
+ */
6
+
7
+ export { VoiceSession, createVoiceSession } from "./session.ts";
8
+ export type { VoiceSessionReader, UserInput } from "./bridge.ts";
9
+
10
+ export { createEngine, LocalEngine, RealtimeEngine } from "./engines.ts";
11
+ export type { VoiceEngine, SpeechMode, InputChannel, TurnState, Message, VoiceContext, VoiceSnapshot, VoiceEvent } from "./types.ts";
12
+
13
+ export { readVoiceSettings, validateSettings } from "./config.ts";
14
+ export type { VoiceSettings, LLMProvider, ASRBackend } from "./config.ts";
15
+
16
+ export { createVoiceBridge } from "./bridge.ts";
17
+ export type { VoiceBridge } from "./bridge.ts";