pi-meta-oauth 0.1.0 → 0.2.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,845 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { META_PROVIDER_ID } from "./meta.ts";
6
+ import { isKeyRelease, isKeyRepeat, matchesKey } from "@earendil-works/pi-tui";
7
+ import { spawn } from "node:child_process";
8
+ import { randomUUID } from "node:crypto";
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ statSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { homedir, platform } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const AGENT_DIR = join(homedir(), ".pi/agent");
21
+ const SETTINGS_FILE = join(AGENT_DIR, "pi-meta-oauth-voice.json");
22
+ const EXTENSION_DIR = dirname(fileURLToPath(import.meta.url));
23
+ const HELPER_DIR = join(EXTENSION_DIR, "voice");
24
+ const HELPER_SOURCE = join(HELPER_DIR, "macos-audio.swift");
25
+ const HELPER_PLIST = join(HELPER_DIR, "Info.plist");
26
+ const HELPER_ENTITLEMENTS = join(HELPER_DIR, "Entitlements.plist");
27
+ const HELPER_BINARY = join(AGENT_DIR, "bin/pi-meta-oauth-voice-v1");
28
+
29
+ const DEFAULT_ASR_ENDPOINT =
30
+ "wss://shortwave.facebook.com/voyager/v1/asr/duplex";
31
+ const DEFAULT_ASR_MODEL = "prod_tbh";
32
+ const AUDIO_FRAME_BYTES = 3_200;
33
+ const ASR_PREBUFFER_FRAMES = 6;
34
+ const MAX_QUEUED_AUDIO_BYTES = 2 * 1024 * 1024;
35
+ const TRAILING_SILENCE_FRAMES = 6;
36
+ const MAX_RECORDING_MS = 2 * 60 * 1_000;
37
+ const FINAL_TRANSCRIPT_TIMEOUT_MS = 6_000;
38
+
39
+ type Settings = { enabled: boolean };
40
+ type VoicePhase = "idle" | "preparing" | "recording" | "stopping";
41
+ type HelperEvent = Record<string, unknown> & { type?: string };
42
+
43
+ type AsrTranscript = {
44
+ transcript?: unknown;
45
+ final?: unknown;
46
+ };
47
+
48
+ function loadSettings(): Settings {
49
+ if (existsSync(SETTINGS_FILE)) {
50
+ try {
51
+ const value = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as {
52
+ enabled?: unknown;
53
+ };
54
+ if (typeof value.enabled === "boolean") return { enabled: value.enabled };
55
+ } catch {
56
+ // Fall through to the platform default.
57
+ }
58
+ }
59
+ return { enabled: platform() === "darwin" };
60
+ }
61
+
62
+ function saveSettings(settings: Settings): void {
63
+ mkdirSync(AGENT_DIR, { recursive: true });
64
+ writeFileSync(SETTINGS_FILE, `${JSON.stringify(settings, null, 2)}\n`);
65
+ }
66
+
67
+ function combineEditorText(original: string, transcript: string): string {
68
+ if (!original.trim()) return transcript.trim();
69
+ if (!transcript.trim()) return original;
70
+ return `${original.replace(/\s+$/, "")} ${transcript.trim()}`;
71
+ }
72
+
73
+ export function pcmAudioLevel(chunk: Buffer): number {
74
+ if (chunk.length < 2) return 0;
75
+ let sumSquares = 0;
76
+ let samples = 0;
77
+ for (let offset = 0; offset + 1 < chunk.length; offset += 4) {
78
+ const normalized = chunk.readInt16LE(offset) / 32_768;
79
+ sumSquares += normalized * normalized;
80
+ samples += 1;
81
+ }
82
+ if (samples === 0) return 0;
83
+ const rms = Math.sqrt(sumSquares / samples);
84
+ return Math.min(1, Math.log10(1 + rms * 90));
85
+ }
86
+
87
+ function hypothesisWords(value: string): string[] {
88
+ return value
89
+ .trim()
90
+ .split(/\s+/)
91
+ .map((word) => word.toLocaleLowerCase().replace(/[^\p{L}\p{N}'’]/gu, ""))
92
+ .filter(Boolean);
93
+ }
94
+
95
+ function chooseTranscript(previous: string, incoming: string): string {
96
+ const next = incoming.trim();
97
+ if (!next) return previous;
98
+ if (!previous.trim()) return next;
99
+
100
+ const previousWords = hypothesisWords(previous);
101
+ const nextWords = hypothesisWords(next);
102
+ const sharedLength = Math.min(previousWords.length, nextWords.length);
103
+ let commonPrefix = 0;
104
+ while (
105
+ commonPrefix < sharedLength &&
106
+ previousWords[commonPrefix] === nextWords[commonPrefix]
107
+ ) {
108
+ commonPrefix += 1;
109
+ }
110
+
111
+ if (commonPrefix === sharedLength || commonPrefix >= 2) {
112
+ const isRegression =
113
+ nextWords.length < previousWords.length &&
114
+ nextWords.every((word, index) => previousWords[index] === word);
115
+ return isRegression ? previous : next;
116
+ }
117
+ return next.length >= previous.length ? next : previous;
118
+ }
119
+
120
+ export function formatAuthorization(apiKey: string): string {
121
+ return apiKey.startsWith("OAuth ") ? apiKey : `OAuth ${apiKey}`;
122
+ }
123
+
124
+ export function asrEndpoint(sessionId: string): string {
125
+ const configured =
126
+ process.env.PI_META_VOICE_ASR_ENDPOINT ??
127
+ process.env.MUSE_VOICE_ASR_ENDPOINT ??
128
+ DEFAULT_ASR_ENDPOINT;
129
+ let endpoint: URL;
130
+ try {
131
+ endpoint = new URL(configured);
132
+ } catch {
133
+ throw new Error("Voice ASR endpoint is not a valid URL");
134
+ }
135
+ const localInsecureEndpoint =
136
+ endpoint.protocol === "ws:" &&
137
+ (endpoint.hostname === "localhost" || endpoint.hostname === "127.0.0.1");
138
+ if (endpoint.protocol !== "wss:" && !localInsecureEndpoint) {
139
+ throw new Error(
140
+ "Voice ASR endpoint must use wss:// (or ws://localhost for testing)",
141
+ );
142
+ }
143
+ endpoint.searchParams.set("sessionId", sessionId);
144
+ return endpoint.toString();
145
+ }
146
+
147
+ async function socketMessageText(data: unknown): Promise<string | undefined> {
148
+ if (typeof data === "string") return data;
149
+ if (data instanceof Blob) return data.text();
150
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
151
+ if (ArrayBuffer.isView(data)) {
152
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString(
153
+ "utf8",
154
+ );
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ function parseJsonObject(text: string): Record<string, unknown> | undefined {
160
+ try {
161
+ const value = JSON.parse(text) as unknown;
162
+ if (value && typeof value === "object" && !Array.isArray(value)) {
163
+ return value as Record<string, unknown>;
164
+ }
165
+ } catch {
166
+ // Ignore non-JSON ASR messages.
167
+ }
168
+ return undefined;
169
+ }
170
+
171
+ async function runProcess(command: string, args: string[]): Promise<void> {
172
+ await new Promise<void>((resolve, reject) => {
173
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
174
+ let stdout = "";
175
+ let stderr = "";
176
+ child.stdout?.on("data", (chunk: Buffer) => {
177
+ stdout = `${stdout}${chunk.toString("utf8")}`.slice(-8_000);
178
+ });
179
+ child.stderr?.on("data", (chunk: Buffer) => {
180
+ stderr = `${stderr}${chunk.toString("utf8")}`.slice(-8_000);
181
+ });
182
+ child.once("error", reject);
183
+ child.once("close", (code) => {
184
+ if (code === 0) {
185
+ resolve();
186
+ return;
187
+ }
188
+ reject(
189
+ new Error(
190
+ (
191
+ stderr ||
192
+ stdout ||
193
+ `${command} exited with code ${code ?? "unknown"}`
194
+ ).trim(),
195
+ ),
196
+ );
197
+ });
198
+ });
199
+ }
200
+
201
+ function helperNeedsBuild(): boolean {
202
+ if (!existsSync(HELPER_BINARY)) return true;
203
+ const binaryTime = statSync(HELPER_BINARY).mtimeMs;
204
+ return [HELPER_SOURCE, HELPER_PLIST, HELPER_ENTITLEMENTS].some(
205
+ (path) => !existsSync(path) || statSync(path).mtimeMs > binaryTime,
206
+ );
207
+ }
208
+
209
+ async function ensureMacOSHelper(): Promise<string> {
210
+ if (platform() !== "darwin") {
211
+ throw new Error(
212
+ "Muse-style voice input is currently available only on macOS",
213
+ );
214
+ }
215
+ if (
216
+ !existsSync(HELPER_SOURCE) ||
217
+ !existsSync(HELPER_PLIST) ||
218
+ !existsSync(HELPER_ENTITLEMENTS)
219
+ ) {
220
+ throw new Error("The macOS microphone helper sources are incomplete");
221
+ }
222
+ if (!helperNeedsBuild()) return HELPER_BINARY;
223
+
224
+ mkdirSync(join(AGENT_DIR, "bin"), { recursive: true });
225
+ await runProcess("/usr/bin/xcrun", [
226
+ "swiftc",
227
+ HELPER_SOURCE,
228
+ "-o",
229
+ HELPER_BINARY,
230
+ "-framework",
231
+ "AVFoundation",
232
+ "-Xlinker",
233
+ "-sectcreate",
234
+ "-Xlinker",
235
+ "__TEXT",
236
+ "-Xlinker",
237
+ "__info_plist",
238
+ "-Xlinker",
239
+ HELPER_PLIST,
240
+ ]);
241
+ await runProcess("/usr/bin/codesign", [
242
+ "--force",
243
+ "--sign",
244
+ "-",
245
+ "--entitlements",
246
+ HELPER_ENTITLEMENTS,
247
+ HELPER_BINARY,
248
+ ]);
249
+ return HELPER_BINARY;
250
+ }
251
+
252
+ class MetaVoiceController {
253
+ private readonly pi: ExtensionAPI;
254
+ private readonly settings = loadSettings();
255
+ private phase: VoicePhase = "idle";
256
+ private helper: ReturnType<typeof spawn> | null = null;
257
+ private socket: WebSocket | null = null;
258
+ private removeTerminalInput: (() => void) | null = null;
259
+ private renderInterval: ReturnType<typeof setInterval> | null = null;
260
+ private recordingLimitTimer: ReturnType<typeof setTimeout> | null = null;
261
+ private trailingSilenceTimer: ReturnType<typeof setInterval> | null = null;
262
+ private drainTimer: ReturnType<typeof setTimeout> | null = null;
263
+ private finalTimeout: ReturnType<typeof setTimeout> | null = null;
264
+ private startTime = 0;
265
+ private audioLevel = 0;
266
+ private originalEditorText = "";
267
+ private transcript = "";
268
+ private stdoutBuffer = "";
269
+ private stderr = "";
270
+ private pcmRemainder = Buffer.alloc(0);
271
+ private queuedAudio: Buffer[] = [];
272
+ private queuedAudioBytes = 0;
273
+ private generation = 0;
274
+ private asrReady = false;
275
+ private streamingStarted = false;
276
+ private captureStopped = false;
277
+ private stopRequested = false;
278
+ private trailingSilenceStarted = false;
279
+ private awaitingDrain = false;
280
+
281
+ constructor(pi: ExtensionAPI) {
282
+ this.pi = pi;
283
+ }
284
+
285
+ register(): void {
286
+ this.pi.registerCommand("voice", {
287
+ description: "Inspect Muse-style Meta voice dictation state",
288
+ handler: async (_args, ctx) => this.showState(ctx),
289
+ });
290
+ this.pi.registerCommand("voice-on", {
291
+ description: "Enable toggle-based Meta voice dictation",
292
+ handler: async (_args, ctx) => this.enable(ctx),
293
+ });
294
+ this.pi.registerCommand("voice-off", {
295
+ description: "Disable toggle-based Meta voice dictation",
296
+ handler: async (_args, ctx) => this.disable(ctx),
297
+ });
298
+ this.pi.on("session_start", (_event, ctx) => this.startSession(ctx));
299
+ this.pi.on("session_shutdown", (_event, ctx) => this.shutdown(ctx));
300
+ }
301
+
302
+ private startSession(ctx: ExtensionContext): void {
303
+ this.removeTerminalInput?.();
304
+ this.removeTerminalInput = null;
305
+ if (ctx.mode === "tui") {
306
+ this.removeTerminalInput = ctx.ui.onTerminalInput((data) =>
307
+ this.handleTerminalInput(ctx, data),
308
+ );
309
+ }
310
+ this.renderIdleStatus(ctx);
311
+ }
312
+
313
+ private handleTerminalInput(
314
+ ctx: ExtensionContext,
315
+ data: string,
316
+ ): { consume: true } | undefined {
317
+ if (!matchesKey(data, "alt+v")) return undefined;
318
+ if (isKeyRelease(data) || isKeyRepeat(data)) return { consume: true };
319
+
320
+ if (!this.settings.enabled) {
321
+ ctx.ui.notify("Voice is off — run /voice-on", "warning");
322
+ return { consume: true };
323
+ }
324
+
325
+ if (this.phase === "idle") void this.start(ctx);
326
+ else this.stop(ctx);
327
+ return { consume: true };
328
+ }
329
+
330
+ private showState(ctx: ExtensionContext): void {
331
+ const state = this.settings.enabled ? "on" : "off";
332
+ const detail = this.settings.enabled
333
+ ? "Press Alt+V to start recording and press it again to stop. Audio is sent to Muse's internal Meta ASR; only the transcript is inserted into the editor."
334
+ : "Run /voice-on to enable toggle-based dictation.";
335
+ ctx.ui.notify(
336
+ `Voice: ${state} — ${detail}`,
337
+ this.settings.enabled ? "info" : "warning",
338
+ );
339
+ }
340
+
341
+ private enable(ctx: ExtensionContext): void {
342
+ if (platform() !== "darwin") {
343
+ ctx.ui.notify(
344
+ "Meta voice input is currently available only on macOS",
345
+ "error",
346
+ );
347
+ return;
348
+ }
349
+ this.settings.enabled = true;
350
+ saveSettings(this.settings);
351
+ this.renderIdleStatus(ctx);
352
+ ctx.ui.notify(
353
+ "Meta voice dictation enabled — press Alt+V to start, then Alt+V again to stop",
354
+ "info",
355
+ );
356
+ }
357
+
358
+ private disable(ctx: ExtensionContext): void {
359
+ this.cancelActiveSession();
360
+ this.settings.enabled = false;
361
+ saveSettings(this.settings);
362
+ this.renderIdleStatus(ctx);
363
+ ctx.ui.setWidget("muse-voice", undefined);
364
+ ctx.ui.notify("Meta voice dictation disabled", "info");
365
+ }
366
+
367
+ private renderIdleStatus(ctx: ExtensionContext): void {
368
+ ctx.ui.setStatus(
369
+ "muse-voice",
370
+ this.settings.enabled ? "⌥V · Meta voice" : "voice: off",
371
+ );
372
+ }
373
+
374
+ private renderRecordingStatus(ctx: ExtensionContext): void {
375
+ if (this.phase !== "recording") return;
376
+ const filled = Math.max(0, Math.min(8, Math.round(this.audioLevel * 8)));
377
+ const meter = `${"█".repeat(filled)}${"░".repeat(8 - filled)}`;
378
+ ctx.ui.setStatus(
379
+ "muse-voice",
380
+ ctx.ui.theme.fg("success", `● [${meter}] Meta voice · ⌥V stop`),
381
+ );
382
+ }
383
+
384
+ private renderTranscript(ctx: ExtensionContext): void {
385
+ ctx.ui.setEditorText(
386
+ combineEditorText(this.originalEditorText, this.transcript),
387
+ );
388
+ if (this.phase === "idle") return;
389
+
390
+ const elapsed = ((Date.now() - this.startTime) / 1_000).toFixed(1);
391
+ const tail = this.transcript ? this.transcript.slice(-160) : "…listening";
392
+ const headline =
393
+ this.phase === "stopping"
394
+ ? `◌ ${elapsed}s Meta ASR — transcribing…`
395
+ : `● ${elapsed}s Meta ASR — press Alt+V to stop`;
396
+ ctx.ui.setWidget("muse-voice", [headline, tail], {
397
+ placement: "belowEditor",
398
+ });
399
+ }
400
+
401
+ private async start(ctx: ExtensionContext): Promise<void> {
402
+ if (this.phase !== "idle") return;
403
+ this.phase = "preparing";
404
+ const currentGeneration = ++this.generation;
405
+ this.resetUtterance(ctx);
406
+ ctx.ui.setStatus("muse-voice", "voice: preparing…");
407
+ this.renderTranscript(ctx);
408
+
409
+ try {
410
+ const [binary, apiKey] = await Promise.all([
411
+ ensureMacOSHelper(),
412
+ ctx.modelRegistry.getApiKeyForProvider(META_PROVIDER_ID),
413
+ ]);
414
+ if (currentGeneration !== this.generation) return;
415
+ if (!apiKey) {
416
+ throw new Error("Meta credentials are unavailable; run `/login meta`");
417
+ }
418
+ if (this.stopRequested) {
419
+ this.complete(ctx);
420
+ return;
421
+ }
422
+
423
+ this.connectSocket(ctx, apiKey, currentGeneration);
424
+ this.spawnHelper(ctx, binary, currentGeneration);
425
+ this.renderInterval = setInterval(() => {
426
+ this.renderTranscript(ctx);
427
+ this.renderRecordingStatus(ctx);
428
+ this.audioLevel *= 0.78;
429
+ }, 120);
430
+ this.recordingLimitTimer = setTimeout(() => {
431
+ ctx.ui.notify(
432
+ "Voice recording reached the two-minute limit",
433
+ "warning",
434
+ );
435
+ this.stop(ctx);
436
+ }, MAX_RECORDING_MS);
437
+ } catch (error) {
438
+ if (currentGeneration !== this.generation) return;
439
+ this.fail(ctx, error instanceof Error ? error : new Error(String(error)));
440
+ }
441
+ }
442
+
443
+ private resetUtterance(ctx: ExtensionContext): void {
444
+ this.clearTimers();
445
+ this.originalEditorText = ctx.ui.getEditorText();
446
+ this.audioLevel = 0;
447
+ this.transcript = "";
448
+ this.stdoutBuffer = "";
449
+ this.stderr = "";
450
+ this.pcmRemainder = Buffer.alloc(0);
451
+ this.queuedAudio = [];
452
+ this.queuedAudioBytes = 0;
453
+ this.asrReady = false;
454
+ this.streamingStarted = false;
455
+ this.captureStopped = false;
456
+ this.stopRequested = false;
457
+ this.trailingSilenceStarted = false;
458
+ this.awaitingDrain = false;
459
+ this.startTime = Date.now();
460
+ }
461
+
462
+ private connectSocket(
463
+ ctx: ExtensionContext,
464
+ apiKey: string,
465
+ currentGeneration: number,
466
+ ): void {
467
+ const sessionId = `${randomUUID()}-0`;
468
+ const socket = new WebSocket(asrEndpoint(sessionId));
469
+ this.socket = socket;
470
+ socket.addEventListener("open", () => {
471
+ if (currentGeneration !== this.generation) return;
472
+ socket.send(
473
+ JSON.stringify({
474
+ mode: "DEFAULT",
475
+ authorization: { accessToken: formatAuthorization(apiKey) },
476
+ audioEncoding: "PCM_16KHZ",
477
+ model:
478
+ process.env.PI_META_VOICE_ASR_MODEL ??
479
+ process.env.MUSE_VOICE_ASR_MODEL ??
480
+ DEFAULT_ASR_MODEL,
481
+ }),
482
+ );
483
+ });
484
+ socket.addEventListener("message", (event) => {
485
+ if (currentGeneration !== this.generation) return;
486
+ void this.handleSocketMessage(ctx, event.data);
487
+ });
488
+ socket.addEventListener("error", () => {
489
+ if (currentGeneration !== this.generation) return;
490
+ this.fail(ctx, new Error("could not connect to Muse's Meta ASR service"));
491
+ });
492
+ socket.addEventListener("close", (event) => {
493
+ if (currentGeneration !== this.generation || this.phase === "idle")
494
+ return;
495
+ if (this.phase === "stopping" && this.transcript) {
496
+ this.complete(ctx);
497
+ return;
498
+ }
499
+ this.fail(
500
+ ctx,
501
+ new Error(
502
+ event.reason ||
503
+ `Meta ASR connection closed (${event.code || "unknown"})`,
504
+ ),
505
+ );
506
+ });
507
+ }
508
+
509
+ private async handleSocketMessage(
510
+ ctx: ExtensionContext,
511
+ data: unknown,
512
+ ): Promise<void> {
513
+ const text = await socketMessageText(data);
514
+ if (text === undefined) return;
515
+ const payload = parseJsonObject(text);
516
+ if (!payload) return;
517
+ this.handleAsrPayload(ctx, payload);
518
+ }
519
+
520
+ private handleAsrPayload(
521
+ ctx: ExtensionContext,
522
+ payload: Record<string, unknown>,
523
+ ): void {
524
+ if (typeof payload.sessionId === "string") {
525
+ this.asrReady = true;
526
+ this.flushQueuedAudio(this.captureStopped);
527
+ if (this.captureStopped) this.startTrailingSilence(ctx);
528
+ return;
529
+ }
530
+ if (payload.error) {
531
+ const error = payload.error as { message?: unknown };
532
+ const message =
533
+ typeof error.message === "string"
534
+ ? error.message
535
+ : "Meta ASR rejected the voice stream";
536
+ this.fail(ctx, new Error(message));
537
+ return;
538
+ }
539
+ if (payload.transcript && typeof payload.transcript === "object") {
540
+ this.applyTranscriptUpdate(ctx, payload.transcript as AsrTranscript);
541
+ }
542
+ }
543
+
544
+ private applyTranscriptUpdate(
545
+ ctx: ExtensionContext,
546
+ update: AsrTranscript,
547
+ ): void {
548
+ if (typeof update.transcript === "string") {
549
+ this.transcript = chooseTranscript(this.transcript, update.transcript);
550
+ }
551
+ this.renderTranscript(ctx);
552
+ if (update.final === true) {
553
+ this.complete(ctx);
554
+ } else if (this.awaitingDrain) {
555
+ this.scheduleDrainCompletion(ctx);
556
+ }
557
+ }
558
+
559
+ private spawnHelper(
560
+ ctx: ExtensionContext,
561
+ binary: string,
562
+ currentGeneration: number,
563
+ ): void {
564
+ const helper = spawn(binary, [], { stdio: ["pipe", "pipe", "pipe"] });
565
+ this.helper = helper;
566
+ helper.stdout?.on("data", (chunk: Buffer) =>
567
+ this.parseHelperOutput(ctx, chunk, currentGeneration),
568
+ );
569
+ helper.stderr?.on("data", (chunk: Buffer) => {
570
+ this.stderr = `${this.stderr}${chunk.toString("utf8")}`.slice(-4_000);
571
+ });
572
+ helper.once("error", (error) => {
573
+ if (currentGeneration === this.generation) this.fail(ctx, error);
574
+ });
575
+ helper.once("close", (code) => {
576
+ if (currentGeneration !== this.generation) return;
577
+ this.helper = null;
578
+ if (this.captureStopped || this.phase === "stopping") {
579
+ this.markCaptureStopped(ctx);
580
+ return;
581
+ }
582
+ this.fail(
583
+ ctx,
584
+ new Error(
585
+ this.stderr.trim() ||
586
+ `microphone helper exited with code ${code ?? "unknown"}`,
587
+ ),
588
+ );
589
+ });
590
+ }
591
+
592
+ private parseHelperOutput(
593
+ ctx: ExtensionContext,
594
+ chunk: Buffer,
595
+ currentGeneration: number,
596
+ ): void {
597
+ if (currentGeneration !== this.generation) return;
598
+ this.stdoutBuffer += chunk.toString("utf8");
599
+ const lines = this.stdoutBuffer.split("\n");
600
+ this.stdoutBuffer = lines.pop() ?? "";
601
+ for (const line of lines) {
602
+ if (!line.trim()) continue;
603
+ try {
604
+ this.handleHelperEvent(ctx, JSON.parse(line) as HelperEvent);
605
+ } catch {
606
+ // Ignore non-protocol output from macOS audio frameworks.
607
+ }
608
+ }
609
+ }
610
+
611
+ private handleHelperEvent(ctx: ExtensionContext, event: HelperEvent): void {
612
+ switch (event.type) {
613
+ case "ready":
614
+ this.phase = this.stopRequested ? "stopping" : "recording";
615
+ if (this.stopRequested) {
616
+ ctx.ui.setStatus("muse-voice", "voice: transcribing…");
617
+ } else {
618
+ this.renderRecordingStatus(ctx);
619
+ }
620
+ this.renderTranscript(ctx);
621
+ if (this.stopRequested && this.helper?.stdin?.writable) {
622
+ this.helper.stdin.write("stop\n");
623
+ }
624
+ return;
625
+ case "audio":
626
+ if (typeof event.data === "string") {
627
+ this.acceptPcm(Buffer.from(event.data, "base64"));
628
+ }
629
+ return;
630
+ case "stopped":
631
+ this.markCaptureStopped(ctx);
632
+ return;
633
+ case "error":
634
+ this.fail(
635
+ ctx,
636
+ new Error(
637
+ typeof event.message === "string"
638
+ ? event.message
639
+ : "macOS microphone capture failed",
640
+ ),
641
+ );
642
+ return;
643
+ default:
644
+ return;
645
+ }
646
+ }
647
+
648
+ private acceptPcm(chunk: Buffer): void {
649
+ if (this.captureStopped || chunk.length === 0) return;
650
+ this.audioLevel = Math.max(pcmAudioLevel(chunk), this.audioLevel * 0.65);
651
+ this.pcmRemainder = Buffer.concat([this.pcmRemainder, chunk]);
652
+ while (this.pcmRemainder.length >= AUDIO_FRAME_BYTES) {
653
+ const frame = this.pcmRemainder.subarray(0, AUDIO_FRAME_BYTES);
654
+ this.pcmRemainder = this.pcmRemainder.subarray(AUDIO_FRAME_BYTES);
655
+ this.sendOrQueueAudio(Buffer.from(frame));
656
+ }
657
+ }
658
+
659
+ private sendOrQueueAudio(frame: Buffer): void {
660
+ if (
661
+ this.streamingStarted &&
662
+ this.asrReady &&
663
+ this.socket?.readyState === WebSocket.OPEN
664
+ ) {
665
+ this.socket.send(frame);
666
+ return;
667
+ }
668
+
669
+ this.queuedAudio.push(frame);
670
+ this.queuedAudioBytes += frame.length;
671
+ while (this.queuedAudioBytes > MAX_QUEUED_AUDIO_BYTES) {
672
+ const removed = this.queuedAudio.shift();
673
+ if (!removed) break;
674
+ this.queuedAudioBytes -= removed.length;
675
+ }
676
+ this.flushQueuedAudio();
677
+ }
678
+
679
+ private flushQueuedAudio(force = false): void {
680
+ if (!this.asrReady || this.socket?.readyState !== WebSocket.OPEN) return;
681
+ if (
682
+ !this.streamingStarted &&
683
+ !force &&
684
+ this.queuedAudio.length < ASR_PREBUFFER_FRAMES
685
+ ) {
686
+ return;
687
+ }
688
+ this.streamingStarted = true;
689
+ for (const frame of this.queuedAudio) this.socket.send(frame);
690
+ this.queuedAudio = [];
691
+ this.queuedAudioBytes = 0;
692
+ }
693
+
694
+ private stop(ctx: ExtensionContext): void {
695
+ if (this.phase === "idle" || this.phase === "stopping") return;
696
+ this.stopRequested = true;
697
+ this.phase = "stopping";
698
+ if (this.recordingLimitTimer) clearTimeout(this.recordingLimitTimer);
699
+ this.recordingLimitTimer = null;
700
+ ctx.ui.setStatus("muse-voice", "voice: transcribing…");
701
+ this.renderTranscript(ctx);
702
+
703
+ if (this.helper?.stdin?.writable) this.helper.stdin.write("stop\n");
704
+ this.finalTimeout = setTimeout(
705
+ () => this.complete(ctx),
706
+ FINAL_TRANSCRIPT_TIMEOUT_MS,
707
+ );
708
+ }
709
+
710
+ private markCaptureStopped(ctx: ExtensionContext): void {
711
+ if (this.captureStopped) return;
712
+ this.captureStopped = true;
713
+ if (this.pcmRemainder.length > 0) {
714
+ const padded = Buffer.alloc(AUDIO_FRAME_BYTES);
715
+ this.pcmRemainder.copy(padded);
716
+ this.pcmRemainder = Buffer.alloc(0);
717
+ this.sendOrQueueAudio(padded);
718
+ }
719
+ this.flushQueuedAudio(true);
720
+ if (this.asrReady) this.startTrailingSilence(ctx);
721
+ }
722
+
723
+ private startTrailingSilence(ctx: ExtensionContext): void {
724
+ if (
725
+ this.trailingSilenceStarted ||
726
+ this.socket?.readyState !== WebSocket.OPEN
727
+ ) {
728
+ return;
729
+ }
730
+ this.trailingSilenceStarted = true;
731
+ let remaining = TRAILING_SILENCE_FRAMES;
732
+ this.trailingSilenceTimer = setInterval(() => {
733
+ if (this.socket?.readyState !== WebSocket.OPEN) {
734
+ this.complete(ctx);
735
+ return;
736
+ }
737
+ if (remaining > 0) {
738
+ this.socket.send(Buffer.alloc(AUDIO_FRAME_BYTES));
739
+ remaining -= 1;
740
+ return;
741
+ }
742
+ if (this.trailingSilenceTimer) clearInterval(this.trailingSilenceTimer);
743
+ this.trailingSilenceTimer = null;
744
+ this.awaitingDrain = true;
745
+ this.scheduleDrainCompletion(ctx);
746
+ }, 100);
747
+ }
748
+
749
+ private scheduleDrainCompletion(ctx: ExtensionContext): void {
750
+ if (this.drainTimer) clearTimeout(this.drainTimer);
751
+ this.drainTimer = setTimeout(() => this.complete(ctx), 450);
752
+ }
753
+
754
+ private complete(ctx: ExtensionContext): void {
755
+ if (this.phase === "idle") return;
756
+ const transcript = this.transcript.trim();
757
+ this.phase = "idle";
758
+ this.generation += 1;
759
+ this.clearTimers();
760
+ this.closeResources();
761
+ ctx.ui.setEditorText(
762
+ combineEditorText(this.originalEditorText, transcript),
763
+ );
764
+ ctx.ui.setWidget("muse-voice", undefined);
765
+ this.renderIdleStatus(ctx);
766
+ if (transcript) {
767
+ ctx.ui.notify(
768
+ "✓ Meta voice transcript ready — press Enter to submit",
769
+ "info",
770
+ );
771
+ } else if (this.stopRequested) {
772
+ ctx.ui.notify(
773
+ "No speech was detected — check microphone access and try again",
774
+ "warning",
775
+ );
776
+ }
777
+ }
778
+
779
+ private fail(ctx: ExtensionContext, error: Error): void {
780
+ if (this.phase === "idle") return;
781
+ const partial = this.transcript.trim();
782
+ this.phase = "idle";
783
+ this.generation += 1;
784
+ this.clearTimers();
785
+ this.closeResources();
786
+ ctx.ui.setEditorText(combineEditorText(this.originalEditorText, partial));
787
+ ctx.ui.setWidget("muse-voice", undefined);
788
+ this.renderIdleStatus(ctx);
789
+ ctx.ui.notify(`Meta voice failed: ${error.message}`, "error");
790
+ }
791
+
792
+ private clearTimers(): void {
793
+ if (this.renderInterval) clearInterval(this.renderInterval);
794
+ if (this.recordingLimitTimer) clearTimeout(this.recordingLimitTimer);
795
+ if (this.trailingSilenceTimer) clearInterval(this.trailingSilenceTimer);
796
+ if (this.drainTimer) clearTimeout(this.drainTimer);
797
+ if (this.finalTimeout) clearTimeout(this.finalTimeout);
798
+ this.renderInterval = null;
799
+ this.recordingLimitTimer = null;
800
+ this.trailingSilenceTimer = null;
801
+ this.drainTimer = null;
802
+ this.finalTimeout = null;
803
+ }
804
+
805
+ private closeResources(): void {
806
+ const helper = this.helper;
807
+ this.helper = null;
808
+ if (helper && helper.exitCode === null) {
809
+ try {
810
+ helper.kill("SIGTERM");
811
+ } catch {
812
+ // Helper may already have exited.
813
+ }
814
+ }
815
+ const socket = this.socket;
816
+ this.socket = null;
817
+ if (socket && socket.readyState < WebSocket.CLOSING) {
818
+ try {
819
+ socket.close(1000, "voice complete");
820
+ } catch {
821
+ // Socket may already have closed.
822
+ }
823
+ }
824
+ }
825
+
826
+ private cancelActiveSession(): void {
827
+ if (this.phase === "idle") return;
828
+ this.phase = "idle";
829
+ this.generation += 1;
830
+ this.clearTimers();
831
+ this.closeResources();
832
+ }
833
+
834
+ private shutdown(ctx: ExtensionContext): void {
835
+ this.removeTerminalInput?.();
836
+ this.removeTerminalInput = null;
837
+ this.cancelActiveSession();
838
+ ctx.ui.setWidget("muse-voice", undefined);
839
+ ctx.ui.setStatus("muse-voice", undefined);
840
+ }
841
+ }
842
+
843
+ export default function metaVoice(pi: ExtensionAPI): void {
844
+ new MetaVoiceController(pi).register();
845
+ }