@omnivoice/page-agent 0.3.0 → 0.5.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,1096 @@
1
+ class VoiceError extends Error {
2
+ constructor(code, message, options) {
3
+ super(message, options);
4
+ this.name = "VoiceError";
5
+ this.code = code;
6
+ }
7
+ }
8
+ function recognitionConstructor() {
9
+ const scope = globalThis;
10
+ return scope.SpeechRecognition ?? scope.webkitSpeechRecognition;
11
+ }
12
+ function createBrowserVoice(language, listenTimeoutMs = 3e4) {
13
+ const Recognition = recognitionConstructor();
14
+ let active;
15
+ return {
16
+ get supportsInput() {
17
+ return Recognition !== void 0;
18
+ },
19
+ listen() {
20
+ if (!Recognition) return Promise.reject(new VoiceError("UNSUPPORTED", "este navegador não oferece reconhecimento de fala"));
21
+ return new Promise((resolve, reject) => {
22
+ const recognition = new Recognition();
23
+ active = recognition;
24
+ let transcript = "";
25
+ let settled = false;
26
+ const finish = (outcome) => {
27
+ if (settled) return;
28
+ settled = true;
29
+ clearTimeout(timer);
30
+ if (active === recognition) active = void 0;
31
+ outcome();
32
+ };
33
+ const timer = setTimeout(() => {
34
+ try {
35
+ recognition.stop();
36
+ } catch {
37
+ }
38
+ finish(() => resolve(transcript.trim()));
39
+ }, listenTimeoutMs);
40
+ recognition.lang = language;
41
+ recognition.continuous = false;
42
+ recognition.interimResults = false;
43
+ recognition.maxAlternatives = 1;
44
+ recognition.onresult = (event) => {
45
+ for (let i = event.resultIndex; i < event.results.length; i++) {
46
+ const result = event.results[i];
47
+ if (result?.isFinal) transcript = result[0]?.transcript ?? transcript;
48
+ }
49
+ };
50
+ recognition.onerror = (event) => {
51
+ const code = event.error ?? "desconhecido";
52
+ if (code === "no-speech" || code === "aborted") {
53
+ finish(() => resolve(transcript.trim()));
54
+ return;
55
+ }
56
+ finish(() => reject(new VoiceError("MICROPHONE", `erro de reconhecimento de voz: ${code}`)));
57
+ };
58
+ recognition.onend = () => finish(() => resolve(transcript.trim()));
59
+ try {
60
+ recognition.start();
61
+ } catch (error) {
62
+ finish(() => reject(new VoiceError("MICROPHONE", "não foi possível iniciar o reconhecimento", { cause: error })));
63
+ }
64
+ });
65
+ },
66
+ stopListening() {
67
+ try {
68
+ active?.stop();
69
+ } catch {
70
+ }
71
+ },
72
+ speak(text) {
73
+ const synth = globalThis.speechSynthesis;
74
+ const clean = text.trim();
75
+ if (!synth || !clean) return;
76
+ synth.cancel();
77
+ const utterance = new SpeechSynthesisUtterance(clean);
78
+ utterance.lang = language;
79
+ synth.speak(utterance);
80
+ },
81
+ stopSpeaking() {
82
+ globalThis.speechSynthesis?.cancel();
83
+ }
84
+ };
85
+ }
86
+ class EventHub {
87
+ constructor() {
88
+ this.subscribers = /* @__PURE__ */ new Set();
89
+ this.closed = false;
90
+ }
91
+ get isClosed() {
92
+ return this.closed;
93
+ }
94
+ emit(event) {
95
+ if (this.closed) return;
96
+ for (const subscriber of this.subscribers) {
97
+ const waiter = subscriber.waiters.shift();
98
+ if (waiter) waiter({ value: event, done: false });
99
+ else subscriber.queue.push(event);
100
+ }
101
+ }
102
+ /** Encerra todos os iteradores: quem espera recebe `done`, quem tem fila a consome antes. */
103
+ close() {
104
+ if (this.closed) return;
105
+ this.closed = true;
106
+ for (const subscriber of this.subscribers) {
107
+ for (const waiter of subscriber.waiters.splice(0)) waiter({ value: void 0, done: true });
108
+ }
109
+ }
110
+ subscribe() {
111
+ const subscriber = { queue: [], waiters: [] };
112
+ this.subscribers.add(subscriber);
113
+ const hub = this;
114
+ return {
115
+ [Symbol.asyncIterator]() {
116
+ return {
117
+ next() {
118
+ const queued = subscriber.queue.shift();
119
+ if (queued !== void 0) return Promise.resolve({ value: queued, done: false });
120
+ if (hub.closed) {
121
+ hub.subscribers.delete(subscriber);
122
+ return Promise.resolve({ value: void 0, done: true });
123
+ }
124
+ return new Promise((resolve) => subscriber.waiters.push(resolve));
125
+ },
126
+ return() {
127
+ hub.subscribers.delete(subscriber);
128
+ return Promise.resolve({ value: void 0, done: true });
129
+ }
130
+ };
131
+ }
132
+ };
133
+ }
134
+ }
135
+ const TEXT_INPUT = "text_input";
136
+ const MUTE = "mute";
137
+ const REPLAY = "replay";
138
+ const IDLE_PAUSE_MS = 250;
139
+ class LocalEngine {
140
+ constructor(settings, deps = {}) {
141
+ this.capabilities = /* @__PURE__ */ new Set([TEXT_INPUT, MUTE]);
142
+ this.hub = new EventHub();
143
+ this.running = false;
144
+ this.turn = "idle";
145
+ this.settings = settings;
146
+ this.deps = deps;
147
+ this.muted = !settings.mic;
148
+ }
149
+ async enter() {
150
+ if (this.running) throw new VoiceError("CONNECTION_FAILED", "o motor local já foi aberto");
151
+ const voice = this.deps.voice ?? createBrowserVoice(this.settings.language);
152
+ if (!voice.supportsInput && !this.muted) {
153
+ throw new VoiceError("UNSUPPORTED", "este navegador não oferece reconhecimento de fala");
154
+ }
155
+ this.voice = voice;
156
+ this.running = true;
157
+ this.turn = "idle";
158
+ this.hub.emit({ kind: "session.ready", data: this.snapshot() });
159
+ void this.listenLoop(voice);
160
+ }
161
+ async exit() {
162
+ this.running = false;
163
+ this.turn = "idle";
164
+ this.voice?.stopListening();
165
+ this.voice?.stopSpeaking();
166
+ this.unmute?.();
167
+ this.hub.close();
168
+ }
169
+ snapshot() {
170
+ return {
171
+ mode: "local",
172
+ channel: "voice",
173
+ state: this.turn,
174
+ muted: this.muted,
175
+ sessionId: this.settings.sessionId,
176
+ messageCount: 0,
177
+ ...this.lastError ? { error: this.lastError } : {}
178
+ };
179
+ }
180
+ setMuted(muted) {
181
+ if (this.muted === muted) return;
182
+ this.muted = muted;
183
+ if (muted) this.voice?.stopListening();
184
+ else this.unmute?.();
185
+ }
186
+ submit(text) {
187
+ if (!this.running) throw new VoiceError("CLOSED", "o motor local está fechado");
188
+ const clean = text.trim();
189
+ if (clean) this.hub.emit({ kind: "turn.heard", data: { text: clean, channel: "chat" } });
190
+ }
191
+ /** Fala um texto com a síntese do navegador (a resposta do agente, por exemplo). */
192
+ speak(text) {
193
+ this.voice?.speak(text);
194
+ }
195
+ events() {
196
+ return this.hub.subscribe();
197
+ }
198
+ waitUnmuted() {
199
+ return new Promise((resolve) => {
200
+ this.unmute = () => {
201
+ this.unmute = void 0;
202
+ resolve();
203
+ };
204
+ });
205
+ }
206
+ async listenLoop(voice) {
207
+ while (this.running) {
208
+ if (this.muted) {
209
+ this.turn = "idle";
210
+ await this.waitUnmuted();
211
+ continue;
212
+ }
213
+ try {
214
+ this.turn = "listening";
215
+ const text = await voice.listen();
216
+ if (!this.running) return;
217
+ if (this.muted) continue;
218
+ if (text) {
219
+ this.hub.emit({ kind: "user.started", data: { channel: "voice" } });
220
+ this.hub.emit({ kind: "turn.heard", data: { text } });
221
+ } else {
222
+ await new Promise((resolve) => setTimeout(resolve, IDLE_PAUSE_MS));
223
+ }
224
+ } catch (error) {
225
+ if (!this.running) return;
226
+ this.lastError = error instanceof Error ? error.message : "erro de reconhecimento de voz";
227
+ this.hub.emit({
228
+ kind: "error",
229
+ data: { message: this.lastError, code: error instanceof VoiceError ? error.code : "AUDIO_ERROR" }
230
+ });
231
+ this.running = false;
232
+ this.turn = "idle";
233
+ this.hub.close();
234
+ return;
235
+ }
236
+ }
237
+ }
238
+ }
239
+ const CAPTURE_RATE = 16e3;
240
+ const CAPTURE_BLOCK = 512;
241
+ const CAPTURE_WORKLET = `
242
+ class OmnivoiceCapture extends AudioWorkletProcessor {
243
+ constructor() {
244
+ super();
245
+ this.block = 0;
246
+ this.samples = new Float32Array(${CAPTURE_BLOCK});
247
+ this.used = 0;
248
+ }
249
+ process(inputs) {
250
+ const input = inputs[0] && inputs[0][0];
251
+ if (!input) return true;
252
+ let offset = 0;
253
+ while (offset < input.length) {
254
+ const take = Math.min(input.length - offset, ${CAPTURE_BLOCK} - this.used);
255
+ this.samples.set(input.subarray(offset, offset + take), this.used);
256
+ this.used += take;
257
+ offset += take;
258
+ if (this.used === ${CAPTURE_BLOCK}) {
259
+ const pcm = new Int16Array(${CAPTURE_BLOCK});
260
+ for (let i = 0; i < ${CAPTURE_BLOCK}; i++) {
261
+ pcm[i] = Math.max(-32768, Math.min(32767, Math.round(this.samples[i] * 32768)));
262
+ }
263
+ this.port.postMessage({ block: this.block++, pcm: pcm.buffer, at: currentTime * 1000 }, [pcm.buffer]);
264
+ this.samples = new Float32Array(${CAPTURE_BLOCK});
265
+ this.used = 0;
266
+ }
267
+ }
268
+ return true;
269
+ }
270
+ }
271
+ registerProcessor("omnivoice-capture", OmnivoiceCapture);
272
+ `;
273
+ const PLAYBACK_WORKLET = `
274
+ class OmnivoicePlayback extends AudioWorkletProcessor {
275
+ constructor() {
276
+ super();
277
+ this.queue = [];
278
+ this.offset = 0;
279
+ this.rendered = 0;
280
+ this.marks = [];
281
+ this.port.onmessage = (event) => {
282
+ const message = event.data || {};
283
+ if (message.type === "audio") this.queue.push(new Float32Array(message.samples));
284
+ if (message.type === "mark") this.marks.push({ id: message.id, label: message.label, offset: message.offset });
285
+ if (message.type === "clear") { this.queue = []; this.offset = 0; this.marks = []; }
286
+ };
287
+ }
288
+ process(_inputs, outputs) {
289
+ const out = outputs[0][0];
290
+ out.fill(0);
291
+ let written = 0;
292
+ while (written < out.length && this.queue.length) {
293
+ const current = this.queue[0];
294
+ const take = Math.min(out.length - written, current.length - this.offset);
295
+ out.set(current.subarray(this.offset, this.offset + take), written);
296
+ written += take;
297
+ this.offset += take;
298
+ if (this.offset === current.length) { this.queue.shift(); this.offset = 0; }
299
+ }
300
+ const before = this.rendered;
301
+ this.rendered += written;
302
+ while (this.marks.length && this.marks[0].offset < this.rendered) {
303
+ const mark = this.marks.shift();
304
+ this.port.postMessage({ type: "reached", id: mark.id, label: mark.label, at: currentTime * 1000 });
305
+ }
306
+ if (this.rendered - before || written < out.length) {
307
+ this.port.postMessage({ type: "rendered", samples: this.rendered, at: currentTime * 1000 });
308
+ }
309
+ if (written < out.length && this.queue.length === 0) {
310
+ this.port.postMessage({ type: "underrun", samples: out.length - written });
311
+ }
312
+ return true;
313
+ }
314
+ }
315
+ registerProcessor("omnivoice-playback", OmnivoicePlayback);
316
+ `;
317
+ async function loadWorklet(context, source) {
318
+ const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
319
+ try {
320
+ await context.audioWorklet.addModule(url);
321
+ } finally {
322
+ URL.revokeObjectURL(url);
323
+ }
324
+ }
325
+ function requireAudioSupport() {
326
+ if (typeof AudioContext === "undefined" || typeof AudioWorkletNode === "undefined") {
327
+ throw new VoiceError("UNSUPPORTED", "este navegador não oferece AudioContext/AudioWorklet");
328
+ }
329
+ }
330
+ function isCaptureMessage(data) {
331
+ if (typeof data !== "object" || data === null) return false;
332
+ const message = data;
333
+ return typeof message["block"] === "number" && message["pcm"] instanceof ArrayBuffer && typeof message["at"] === "number";
334
+ }
335
+ function isPlaybackReport(data) {
336
+ return typeof data === "object" && data !== null && typeof data["type"] === "string";
337
+ }
338
+ const startBrowserMicrophone = async (onFrame) => {
339
+ requireAudioSupport();
340
+ if (!navigator.mediaDevices?.getUserMedia) {
341
+ throw new VoiceError("UNSUPPORTED", "este navegador não oferece getUserMedia (precisa de https ou localhost)");
342
+ }
343
+ const context = new AudioContext({ sampleRate: CAPTURE_RATE, latencyHint: "interactive" });
344
+ let stream;
345
+ try {
346
+ await loadWorklet(context, CAPTURE_WORKLET);
347
+ stream = await navigator.mediaDevices.getUserMedia({
348
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
349
+ });
350
+ const source = context.createMediaStreamSource(stream);
351
+ const node = new AudioWorkletNode(context, "omnivoice-capture");
352
+ node.port.onmessage = (event) => {
353
+ if (!isCaptureMessage(event.data)) return;
354
+ onFrame({ block: event.data.block, pcm: new Int16Array(event.data.pcm), at: event.data.at });
355
+ };
356
+ source.connect(node);
357
+ node.connect(context.destination);
358
+ await context.resume();
359
+ const tracks = stream.getTracks();
360
+ return {
361
+ async stop() {
362
+ node.port.onmessage = null;
363
+ source.disconnect();
364
+ node.disconnect();
365
+ for (const track of tracks) track.stop();
366
+ await context.close();
367
+ }
368
+ };
369
+ } catch (error) {
370
+ stream?.getTracks().forEach((track) => track.stop());
371
+ await context.close().catch(() => void 0);
372
+ if (error instanceof VoiceError) throw error;
373
+ throw new VoiceError("MICROPHONE", error instanceof Error ? error.message : "não foi possível abrir o microfone", {
374
+ cause: error
375
+ });
376
+ }
377
+ };
378
+ const startBrowserPlayback = async (rate, onReport) => {
379
+ requireAudioSupport();
380
+ const context = new AudioContext({ sampleRate: rate, latencyHint: "interactive" });
381
+ try {
382
+ await loadWorklet(context, PLAYBACK_WORKLET);
383
+ const node = new AudioWorkletNode(context, "omnivoice-playback");
384
+ node.port.onmessage = (event) => {
385
+ if (isPlaybackReport(event.data)) onReport(event.data);
386
+ };
387
+ node.connect(context.destination);
388
+ await context.resume();
389
+ return {
390
+ push(pcm16) {
391
+ const samples = new Float32Array(pcm16.length);
392
+ for (let i = 0; i < pcm16.length; i++) samples[i] = (pcm16[i] ?? 0) / 32768;
393
+ node.port.postMessage({ type: "audio", samples: samples.buffer }, [samples.buffer]);
394
+ },
395
+ mark(mark) {
396
+ node.port.postMessage({ type: "mark", id: mark.id, label: mark.label, offset: mark.offset });
397
+ },
398
+ clear() {
399
+ node.port.postMessage({ type: "clear" });
400
+ },
401
+ async close() {
402
+ node.port.onmessage = null;
403
+ node.disconnect();
404
+ await context.close();
405
+ }
406
+ };
407
+ } catch (error) {
408
+ await context.close().catch(() => void 0);
409
+ throw error;
410
+ }
411
+ };
412
+ const VOICE_DEFAULTS = Object.freeze({
413
+ speechMode: "local",
414
+ language: "pt-BR",
415
+ proxyServerUrl: "http://localhost:8765",
416
+ proxyLeaseEndpoint: "/lease",
417
+ proxyWebSocketEndpoint: "/ws",
418
+ proxySessionId: "",
419
+ connectTimeoutMs: 1e4,
420
+ mic: true,
421
+ trace: false
422
+ });
423
+ function createVoiceSettings(overrides = {}) {
424
+ return Object.freeze({
425
+ ...VOICE_DEFAULTS,
426
+ sessionId: `session-${Date.now()}`,
427
+ ...overrides
428
+ });
429
+ }
430
+ function readVoiceSettings(env) {
431
+ const text = (key, fallback) => {
432
+ const value = env[key];
433
+ return value === void 0 || value === "" ? fallback : value;
434
+ };
435
+ const flag = (key, fallback) => {
436
+ const value = env[key];
437
+ if (value === "1" || value === "true") return true;
438
+ if (value === "0" || value === "false") return false;
439
+ return fallback;
440
+ };
441
+ const positiveInt = (key, fallback) => {
442
+ const parsed = Number.parseInt(env[key] ?? "", 10);
443
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
444
+ };
445
+ return createVoiceSettings({
446
+ speechMode: parseSpeechMode(text("SPEECH_MODE", text("SPEECH", VOICE_DEFAULTS.speechMode))),
447
+ language: text("VOICE_LANGUAGE", VOICE_DEFAULTS.language),
448
+ proxyServerUrl: text("PROXY_SERVER_URL", VOICE_DEFAULTS.proxyServerUrl),
449
+ proxyLeaseEndpoint: text("PROXY_LEASE_ENDPOINT", VOICE_DEFAULTS.proxyLeaseEndpoint),
450
+ proxyWebSocketEndpoint: text("PROXY_WEBSOCKET_ENDPOINT", VOICE_DEFAULTS.proxyWebSocketEndpoint),
451
+ proxySessionId: text("PROXY_SESSION_ID", VOICE_DEFAULTS.proxySessionId),
452
+ connectTimeoutMs: positiveInt("CONNECT_TIMEOUT_MS", VOICE_DEFAULTS.connectTimeoutMs),
453
+ mic: flag("MIC", VOICE_DEFAULTS.mic),
454
+ trace: flag("TRACE", VOICE_DEFAULTS.trace),
455
+ ...env["SESSION_ID"] ? { sessionId: env["SESSION_ID"] } : {}
456
+ });
457
+ }
458
+ function parseSpeechMode(choice) {
459
+ const normalized = choice.trim().toLowerCase();
460
+ if (normalized === "2" || normalized === "realtime") return "realtime";
461
+ return "local";
462
+ }
463
+ function validateSettings(settings) {
464
+ if (settings.speechMode !== "realtime") return void 0;
465
+ let origin;
466
+ try {
467
+ origin = new URL(settings.proxyServerUrl);
468
+ } catch {
469
+ return `proxyServerUrl inválida: ${settings.proxyServerUrl}`;
470
+ }
471
+ if (origin.protocol !== "http:" && origin.protocol !== "https:") {
472
+ return `proxyServerUrl deve usar http: ou https: (recebido ${origin.protocol})`;
473
+ }
474
+ if (!settings.proxyWebSocketEndpoint) return "proxyWebSocketEndpoint vazio";
475
+ if (!settings.proxySessionId && !settings.proxyLeaseEndpoint) {
476
+ return "defina proxyLeaseEndpoint ou proxySessionId";
477
+ }
478
+ return void 0;
479
+ }
480
+ const OPEN = 1;
481
+ const browserSocket = (url, handlers) => {
482
+ const socket = new WebSocket(url);
483
+ socket.binaryType = "arraybuffer";
484
+ socket.addEventListener("open", () => handlers.onOpen());
485
+ socket.addEventListener("error", () => handlers.onError());
486
+ socket.addEventListener("close", (event) => handlers.onClose(event.code, event.reason));
487
+ socket.addEventListener("message", (event) => {
488
+ const data = event.data;
489
+ if (typeof data === "string" || data instanceof ArrayBuffer) handlers.onMessage(data);
490
+ });
491
+ return {
492
+ get isOpen() {
493
+ return socket.readyState === OPEN;
494
+ },
495
+ send(data) {
496
+ socket.send(typeof data === "string" ? data : new Uint8Array(data));
497
+ },
498
+ close(code, reason) {
499
+ socket.close(code, reason);
500
+ }
501
+ };
502
+ };
503
+ function joinServerUrl(base, endpoint) {
504
+ const url = new URL(base);
505
+ const prefix = url.pathname.replace(/\/+$/, "");
506
+ url.pathname = `${prefix}/${endpoint.replace(/^\/+/, "")}`;
507
+ url.search = "";
508
+ url.hash = "";
509
+ return url;
510
+ }
511
+ function asRecord(value) {
512
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
513
+ }
514
+ function turnStateFrom(serverState, fallback) {
515
+ switch (serverState) {
516
+ case "LISTENING":
517
+ case "INTERRUPTED":
518
+ return "listening";
519
+ case "TRANSCRIBING":
520
+ case "THINKING":
521
+ case "EXPLAINING":
522
+ return "processing";
523
+ case "SPEAKING":
524
+ return "speaking";
525
+ default:
526
+ return fallback;
527
+ }
528
+ }
529
+ class RealtimeEngine {
530
+ constructor(settings, deps = {}) {
531
+ this.capabilities = /* @__PURE__ */ new Set([MUTE]);
532
+ this.hub = new EventHub();
533
+ this.playbackRate = 0;
534
+ this.opened = false;
535
+ this.ready = false;
536
+ this.closing = false;
537
+ this.turn = "idle";
538
+ this.proxySessionId = "";
539
+ this.settings = settings;
540
+ this.deps = deps;
541
+ this.muted = !settings.mic;
542
+ }
543
+ async enter() {
544
+ if (this.opened) throw new VoiceError("CONNECTION_FAILED", "o motor realtime já foi aberto");
545
+ const problem = validateSettings(this.settings);
546
+ if (problem) throw new VoiceError("INVALID_SETTINGS", problem);
547
+ this.opened = true;
548
+ try {
549
+ this.proxySessionId = this.settings.proxySessionId || await this.lease();
550
+ const url = joinServerUrl(this.settings.proxyServerUrl, this.settings.proxyWebSocketEndpoint);
551
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
552
+ url.searchParams.set("session", this.proxySessionId);
553
+ const ready = new Promise((resolve, reject) => {
554
+ this.settle = { resolve, reject };
555
+ });
556
+ const timer = setTimeout(() => {
557
+ this.settle?.reject(new VoiceError("TIMEOUT", `o servidor de áudio não respondeu em ${this.settings.connectTimeoutMs} ms`));
558
+ }, this.settings.connectTimeoutMs);
559
+ const open = this.deps.socket ?? browserSocket;
560
+ this.socket = open(url.href, {
561
+ onOpen: () => this.trace("socket aberto"),
562
+ onMessage: (data) => void this.onMessage(data),
563
+ onError: () => this.fail(new VoiceError("CONNECTION_FAILED", `não foi possível conectar a ${url.origin}${url.pathname}`)),
564
+ onClose: (code, reason) => this.onClose(code, reason)
565
+ });
566
+ try {
567
+ await ready;
568
+ } finally {
569
+ clearTimeout(timer);
570
+ }
571
+ } catch (error) {
572
+ await this.teardown();
573
+ throw error;
574
+ }
575
+ }
576
+ async exit() {
577
+ this.closing = true;
578
+ await this.teardown();
579
+ }
580
+ snapshot() {
581
+ return {
582
+ mode: "realtime",
583
+ channel: "voice",
584
+ state: this.turn,
585
+ muted: this.muted,
586
+ sessionId: this.proxySessionId || this.settings.sessionId,
587
+ messageCount: 0,
588
+ ...this.lastError ? { error: this.lastError } : {}
589
+ };
590
+ }
591
+ setMuted(muted) {
592
+ if (this.muted === muted) return;
593
+ this.muted = muted;
594
+ this.sendJson({ type: "mute", on: muted });
595
+ if (!muted && this.ready) void this.ensureMicrophone();
596
+ }
597
+ submit(_text) {
598
+ throw new VoiceError("UNSUPPORTED", "o motor realtime não aceita texto digitado: fale pelo microfone");
599
+ }
600
+ events() {
601
+ return this.hub.subscribe();
602
+ }
603
+ async lease() {
604
+ const doFetch = this.deps.fetch ?? globalThis.fetch;
605
+ if (typeof doFetch !== "function") throw new VoiceError("UNSUPPORTED", "fetch não está disponível neste ambiente");
606
+ const url = joinServerUrl(this.settings.proxyServerUrl, this.settings.proxyLeaseEndpoint);
607
+ let response;
608
+ try {
609
+ response = await doFetch(url.href, { credentials: "include" });
610
+ } catch (error) {
611
+ throw new VoiceError("LEASE_FAILED", `não foi possível alcançar ${url.origin}${url.pathname}`, { cause: error });
612
+ }
613
+ const body = asRecord(await response.json().catch(() => void 0));
614
+ if (!response.ok) {
615
+ const reason = typeof body?.["error"] === "string" ? body["error"] : `HTTP ${response.status}`;
616
+ throw new VoiceError("LEASE_FAILED", `sessão de voz recusada: ${reason}`);
617
+ }
618
+ const id = body?.["session_id"];
619
+ if (typeof id !== "string" || id === "") throw new VoiceError("LEASE_FAILED", "o servidor não devolveu session_id");
620
+ return id;
621
+ }
622
+ async onMessage(data) {
623
+ if (typeof data !== "string") {
624
+ if (this.playback && data.byteLength >= 2) this.playback.push(new Int16Array(data, 0, Math.floor(data.byteLength / 2)));
625
+ return;
626
+ }
627
+ let message;
628
+ try {
629
+ message = asRecord(JSON.parse(data));
630
+ } catch {
631
+ return;
632
+ }
633
+ const rate = typeof message?.["rate"] === "number" ? message["rate"] : 0;
634
+ switch (message?.["type"]) {
635
+ case "ready":
636
+ try {
637
+ await this.startAudio(rate);
638
+ this.ready = true;
639
+ this.turn = "listening";
640
+ this.emit({ kind: "session.ready", data: this.snapshot() });
641
+ this.settle?.resolve();
642
+ } catch (error) {
643
+ this.fail(error instanceof VoiceError ? error : new VoiceError("CONNECTION_FAILED", "falha ao iniciar o áudio", { cause: error }));
644
+ }
645
+ return;
646
+ case "rate":
647
+ if (this.ready) await this.startPlayback(rate).catch((error) => this.report(error));
648
+ return;
649
+ case "state":
650
+ this.onState(message);
651
+ return;
652
+ case "mark": {
653
+ const { id, label, offset } = message;
654
+ if (typeof id === "number" && typeof label === "string" && typeof offset === "number") this.playback?.mark({ id, label, offset });
655
+ return;
656
+ }
657
+ case "clear":
658
+ this.playback?.clear();
659
+ return;
660
+ case "refused": {
661
+ const reason = typeof message["reason"] === "string" ? message["reason"] : "sem motivo informado";
662
+ this.fail(new VoiceError("REFUSED", `o servidor recusou a sessão: ${reason}`));
663
+ return;
664
+ }
665
+ default:
666
+ return;
667
+ }
668
+ }
669
+ onState(message) {
670
+ this.turn = turnStateFrom(message["state"], this.turn);
671
+ const detail = typeof message["detail"] === "string" ? message["detail"] : "";
672
+ switch (message["event"]) {
673
+ case "user.started":
674
+ this.emit({ kind: "user.started", data: { channel: "voice" } });
675
+ return;
676
+ case "agent.started":
677
+ this.emit({ kind: "agent.started", data: { turn: this.turn } });
678
+ return;
679
+ case "sound.started":
680
+ this.emit({ kind: "sound.started", data: {} });
681
+ return;
682
+ case "speaker.idle":
683
+ this.emit({ kind: "speaker.idle", data: {} });
684
+ return;
685
+ case "error":
686
+ this.lastError = detail || "erro no servidor de áudio";
687
+ this.emit({ kind: "error", data: { message: this.lastError, code: "SERVER_ERROR" } });
688
+ return;
689
+ default:
690
+ return;
691
+ }
692
+ }
693
+ async startAudio(rate) {
694
+ await this.startPlayback(rate);
695
+ if (!this.muted) await this.ensureMicrophone();
696
+ }
697
+ async startPlayback(rate) {
698
+ if (!Number.isFinite(rate) || rate <= 0) throw new VoiceError("CONNECTION_FAILED", `taxa de áudio inválida: ${rate}`);
699
+ if (this.playback && this.playbackRate === rate) return;
700
+ const previous = this.playback;
701
+ this.playback = void 0;
702
+ await previous?.close();
703
+ const open = this.deps.playback ?? startBrowserPlayback;
704
+ this.playback = await open(rate, (report) => this.sendPlaybackReport(report));
705
+ this.playbackRate = rate;
706
+ }
707
+ ensureMicrophone() {
708
+ if (this.microphone) return Promise.resolve();
709
+ this.microphoneStarting ??= (async () => {
710
+ const open = this.deps.microphone ?? startBrowserMicrophone;
711
+ try {
712
+ this.microphone = await open((frame) => this.sendFrame(frame));
713
+ if (this.closing) await this.stopMicrophone();
714
+ } finally {
715
+ this.microphoneStarting = void 0;
716
+ }
717
+ })();
718
+ return this.microphoneStarting;
719
+ }
720
+ async stopMicrophone() {
721
+ const microphone = this.microphone;
722
+ this.microphone = void 0;
723
+ await microphone?.stop();
724
+ }
725
+ sendFrame(frame) {
726
+ if (this.muted || !this.socket?.isOpen || frame.pcm.length !== CAPTURE_BLOCK) return;
727
+ const bytes = new Uint8Array(4 + frame.pcm.byteLength);
728
+ new DataView(bytes.buffer).setUint32(0, frame.block, true);
729
+ bytes.set(new Uint8Array(frame.pcm.buffer, frame.pcm.byteOffset, frame.pcm.byteLength), 4);
730
+ this.socket.send(bytes);
731
+ }
732
+ sendPlaybackReport(report) {
733
+ this.sendJson(report);
734
+ }
735
+ sendJson(message) {
736
+ if (this.socket?.isOpen) this.socket.send(JSON.stringify(message));
737
+ }
738
+ onClose(code, reason) {
739
+ if (this.closing) return;
740
+ const error = new VoiceError("CONNECTION_FAILED", `conexão encerrada pelo servidor (código ${code}${reason ? `: ${reason}` : ""})`);
741
+ if (!this.ready) {
742
+ this.settle?.reject(error);
743
+ return;
744
+ }
745
+ this.lastError = error.message;
746
+ this.emit({ kind: "error", data: { message: error.message, code: "CLOSED" } });
747
+ void this.teardown();
748
+ }
749
+ /** Falha durante a abertura rejeita `enter()`; depois dela vira evento de erro. */
750
+ fail(error) {
751
+ if (!this.ready) {
752
+ this.settle?.reject(error);
753
+ return;
754
+ }
755
+ this.report(error);
756
+ }
757
+ report(error) {
758
+ const message = error instanceof Error ? error.message : "erro de áudio";
759
+ this.lastError = message;
760
+ this.emit({ kind: "error", data: { message, code: error instanceof VoiceError ? error.code : "AUDIO_ERROR" } });
761
+ }
762
+ emit(event) {
763
+ this.hub.emit(event);
764
+ }
765
+ trace(message) {
766
+ if (this.settings.trace) console.debug(`[RealtimeEngine] ${message}`);
767
+ }
768
+ async teardown() {
769
+ this.closing = true;
770
+ this.ready = false;
771
+ this.turn = "idle";
772
+ const socket = this.socket;
773
+ const playback = this.playback;
774
+ this.socket = void 0;
775
+ this.playback = void 0;
776
+ await this.stopMicrophone().catch(() => void 0);
777
+ await playback?.close().catch(() => void 0);
778
+ if (socket?.isOpen) socket.close(1e3, "fim da sessão");
779
+ this.hub.close();
780
+ }
781
+ }
782
+ function createEngine(settings, deps = {}) {
783
+ return settings.speechMode === "realtime" ? new RealtimeEngine(settings, deps.realtime) : new LocalEngine(settings, deps.local);
784
+ }
785
+ const FORWARDED_EVENTS = /* @__PURE__ */ new Set([
786
+ "user.started",
787
+ "agent.started",
788
+ "agent.finished",
789
+ "sound.started",
790
+ "speaker.idle",
791
+ "error"
792
+ ]);
793
+ class VoiceSession {
794
+ constructor(settings, allowMutations = true) {
795
+ this.engine = null;
796
+ this.inputQueue = [];
797
+ this.hub = new EventHub();
798
+ this.isClosed = false;
799
+ this.settings = settings;
800
+ this.sessionId = settings.sessionId;
801
+ this.allowMutations = allowMutations;
802
+ this.context = {
803
+ mode: settings.speechMode,
804
+ channel: "voice",
805
+ turn: "idle",
806
+ sessionId: this.sessionId,
807
+ conversationHistory: [],
808
+ muted: !settings.mic,
809
+ lastTurnTime: void 0
810
+ };
811
+ }
812
+ /** Abre a sessão: abre a engine e passa a repassar os eventos dela. */
813
+ async abrir(engine) {
814
+ if (this.engine) throw new Error("sessão já está aberta");
815
+ const engineEvents = engine.events();
816
+ this.engine = engine;
817
+ this.isClosed = false;
818
+ try {
819
+ await engine.enter();
820
+ } catch (error) {
821
+ this.engine = null;
822
+ throw error;
823
+ }
824
+ this.emitPublic({ kind: "session.ready", data: this.snapshot() });
825
+ void this.processEngineEvents(engineEvents);
826
+ }
827
+ /** Fecha a sessão. */
828
+ async fechar() {
829
+ this.isClosed = true;
830
+ const engine = this.engine;
831
+ this.engine = null;
832
+ if (engine) {
833
+ try {
834
+ await engine.exit();
835
+ } catch (error) {
836
+ console.error("[VoiceSession.fechar]", error);
837
+ }
838
+ }
839
+ this.hub.close();
840
+ }
841
+ /** Snapshot atual para o painel. */
842
+ snapshot() {
843
+ return {
844
+ mode: this.context.mode,
845
+ channel: this.context.channel,
846
+ state: this.context.turn,
847
+ muted: this.context.muted,
848
+ sessionId: this.sessionId,
849
+ messageCount: this.context.conversationHistory.length,
850
+ lastMessageTime: this.context.lastTurnTime
851
+ };
852
+ }
853
+ /** Habilita/desabilita mutação de estado. Padrão: true (ativado). false = modo auditoria (read-only). */
854
+ setMutationsAllowed(allowed) {
855
+ this.allowMutations = allowed;
856
+ }
857
+ /** Enfileira entrada do usuário (voz transcrita ou texto). Sem efeito se mutações desabilitadas. */
858
+ enqueueInput(input) {
859
+ if (!this.allowMutations) return;
860
+ this.inputQueue.push(input);
861
+ this.processNextInput();
862
+ }
863
+ /** Muta/desmuta o microfone. Sem efeito se mutações desabilitadas. */
864
+ setMuted(muted) {
865
+ if (!this.allowMutations) return;
866
+ this.context.muted = muted;
867
+ this.engine?.setMuted(muted);
868
+ }
869
+ /** Eventos públicos a partir de agora, até a sessão fechar. Cada chamada tem a sua fila. */
870
+ events() {
871
+ return this.hub.subscribe();
872
+ }
873
+ /**
874
+ * Alterna entre voz e chat.
875
+ * O histórico é preservado; apenas a origem do próximo input muda.
876
+ * Sem efeito se mutações desabilitadas.
877
+ */
878
+ switchToChannel(channel) {
879
+ if (!this.allowMutations) return;
880
+ if (this.context.channel === channel) return;
881
+ const from = this.context.channel;
882
+ this.context.channel = channel;
883
+ this.emitPublic({ kind: "channel.switched", data: { from, to: channel } });
884
+ }
885
+ /** Processa a fila: atualiza histórico e avisa o bridge. */
886
+ processNextInput() {
887
+ if (!this.allowMutations) return;
888
+ const input = this.inputQueue.shift();
889
+ if (!input) return;
890
+ this.context.turn = "listening";
891
+ this.context.channel = input.channel;
892
+ this.context.lastTurnTime = input.timestamp;
893
+ this.context.conversationHistory.push({
894
+ role: "user",
895
+ content: input.utterance,
896
+ timestamp: input.timestamp,
897
+ channel: input.channel,
898
+ confidence: input.confidence
899
+ });
900
+ this.emitPublic({
901
+ kind: "turn.heard",
902
+ data: { text: input.utterance, confidence: input.confidence, channel: input.channel }
903
+ });
904
+ this.context.turn = "processing";
905
+ }
906
+ /** Repassa os eventos da engine enquanto a sessão estiver aberta. */
907
+ async processEngineEvents(stream) {
908
+ try {
909
+ for await (const engineEvent of stream) {
910
+ if (this.isClosed) break;
911
+ this.handleEngineEvent(engineEvent);
912
+ }
913
+ } catch (error) {
914
+ this.emitPublic({
915
+ kind: "error",
916
+ data: { message: error instanceof Error ? error.message : "erro desconhecido", code: "ENGINE_ERROR" }
917
+ });
918
+ }
919
+ }
920
+ handleEngineEvent(event) {
921
+ if (event.kind === "turn.heard") {
922
+ this.enqueueInput({
923
+ utterance: event.data.text,
924
+ channel: event.data.channel ?? "voice",
925
+ confidence: event.data.confidence,
926
+ timestamp: Date.now()
927
+ });
928
+ return;
929
+ }
930
+ if (FORWARDED_EVENTS.has(event.kind)) this.emitPublic(event);
931
+ if (event.kind === "agent.finished") {
932
+ this.context.conversationHistory.push({
933
+ role: "assistant",
934
+ content: event.data.text,
935
+ timestamp: Date.now(),
936
+ channel: this.context.channel
937
+ });
938
+ this.context.turn = "idle";
939
+ }
940
+ if (event.kind === "agent.started") this.context.turn = "speaking";
941
+ }
942
+ emitPublic(event) {
943
+ this.hub.emit(event);
944
+ }
945
+ }
946
+ async function createVoiceSession(settings, deps = {}) {
947
+ const session = new VoiceSession(settings);
948
+ await session.abrir(createEngine(settings, deps));
949
+ return session;
950
+ }
951
+ class VoiceBridge {
952
+ constructor(session) {
953
+ this.messageBuffer = [];
954
+ this.session = session;
955
+ }
956
+ /**
957
+ * Entrada de texto do usuário (chat): vira UserInput e é enfileirada na sessão.
958
+ * O registro no histórico da ponte acontece quando a sessão publicar o `turn.heard` correspondente.
959
+ */
960
+ onUserText(text) {
961
+ if (!text.trim()) return;
962
+ this.session.enqueueInput({
963
+ utterance: text.trim(),
964
+ channel: "chat",
965
+ timestamp: Date.now()
966
+ });
967
+ }
968
+ /**
969
+ * Evento público da sessão. `turn.heard` e `agent.finished` entram no histórico da ponte;
970
+ * nenhum evento aqui cria entrada nova na sessão.
971
+ */
972
+ onVoiceEvent(event) {
973
+ if (event.kind === "turn.heard") {
974
+ const { text, confidence, channel } = event.data;
975
+ if (!text.trim()) return;
976
+ this.recordMessage({
977
+ role: "user",
978
+ content: text,
979
+ channel: channel ?? "voice",
980
+ confidence,
981
+ timestamp: Date.now()
982
+ });
983
+ }
984
+ if (event.kind === "agent.finished") {
985
+ const { text } = event.data;
986
+ this.recordMessage({
987
+ role: "assistant",
988
+ content: text,
989
+ channel: this.session.context.channel,
990
+ timestamp: Date.now()
991
+ });
992
+ }
993
+ if (event.kind === "channel.switched") {
994
+ const { from, to } = event.data;
995
+ console.debug(`[VoiceBridge] canais alternados: ${from} → ${to}`);
996
+ }
997
+ }
998
+ /**
999
+ * Contexto atual: tudo o que o page-agent precisa saber sobre voz.
1000
+ * Leitura apenas; nunca muta.
1001
+ */
1002
+ get context() {
1003
+ return this.session.context;
1004
+ }
1005
+ /**
1006
+ * Snapshot para o painel.
1007
+ */
1008
+ get snapshot() {
1009
+ return this.session.snapshot();
1010
+ }
1011
+ /**
1012
+ * Histórico de mensagens: construído incrementalmente
1013
+ * conforme entradas chegam e respostas são produzidas.
1014
+ */
1015
+ get messages() {
1016
+ return this.messageBuffer;
1017
+ }
1018
+ /**
1019
+ * Muta/desmuta o microfone.
1020
+ */
1021
+ setMuted(muted) {
1022
+ this.session.setMuted(muted);
1023
+ }
1024
+ /**
1025
+ * Registra uma mensagem no histórico local do bridge.
1026
+ * Não persiste (isso é job do store se necessário).
1027
+ */
1028
+ recordMessage(msg) {
1029
+ this.messageBuffer.push(msg);
1030
+ const cutoff = Date.now() - 2 * 60 * 60 * 1e3;
1031
+ this.messageBuffer = this.messageBuffer.filter((m) => m.timestamp > cutoff);
1032
+ }
1033
+ /**
1034
+ * Reset para testes ou novo começo de conversa.
1035
+ */
1036
+ clearHistory() {
1037
+ this.messageBuffer = [];
1038
+ }
1039
+ }
1040
+ class VoiceSessionController {
1041
+ constructor(session, bridge) {
1042
+ this.session = session;
1043
+ this.bridge = bridge;
1044
+ }
1045
+ /**
1046
+ * Muda de canal (voz → chat ou chat → voz).
1047
+ * O contexto é preservado: histórico segue intacto.
1048
+ * Só muda a ORIGEM do próximo input.
1049
+ *
1050
+ * Implementação: sinaliza à sessão e deixa ela gerir o event loop.
1051
+ */
1052
+ switchChannel(to) {
1053
+ const from = this.session.context.channel;
1054
+ if (from === to) return;
1055
+ console.debug(`[Controller] solicitando mudança: ${from} → ${to}`);
1056
+ }
1057
+ /**
1058
+ * Contexto atual (apenas leitura).
1059
+ */
1060
+ get context() {
1061
+ return this.session.context;
1062
+ }
1063
+ /**
1064
+ * Muta/desmuta.
1065
+ */
1066
+ setMuted(muted) {
1067
+ this.bridge.setMuted(muted);
1068
+ }
1069
+ }
1070
+ export {
1071
+ CAPTURE_BLOCK,
1072
+ CAPTURE_RATE,
1073
+ EventHub,
1074
+ LocalEngine,
1075
+ MUTE,
1076
+ REPLAY,
1077
+ RealtimeEngine,
1078
+ TEXT_INPUT,
1079
+ VOICE_DEFAULTS,
1080
+ VoiceBridge,
1081
+ VoiceError,
1082
+ VoiceSession,
1083
+ VoiceSessionController,
1084
+ browserSocket,
1085
+ createBrowserVoice,
1086
+ createEngine,
1087
+ createVoiceSession,
1088
+ createVoiceSettings,
1089
+ joinServerUrl,
1090
+ parseSpeechMode,
1091
+ readVoiceSettings,
1092
+ startBrowserMicrophone,
1093
+ startBrowserPlayback,
1094
+ validateSettings
1095
+ };
1096
+ //# sourceMappingURL=index.js.map