import { mkdtempSync } from "node:fs"; import { rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { BridgeCaptureConfig } from "../config/types"; import { formatError, truncate } from "../utils/text"; import type { AudioRecorder, RecordingHandle } from "./types"; import { maxPcm16LeAmplitude, SILENCE_MAX_AMPLITUDE } from "./wav"; const bridgeUrl = (endpoint: string, path: string) => `${endpoint.replace(/\/+$/, "")}${path}`; const headersFrom = (config: BridgeCaptureConfig): Record => { if (!config.token) return {}; return { authorization: `Bearer ${config.token}` }; }; type BridgeRequestOptions = { method: "GET" | "POST"; timeoutSeconds: number; }; const requestBridge = async (config: BridgeCaptureConfig, path: string, options: BridgeRequestOptions): Promise => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), options.timeoutSeconds * 1000); try { const response = await fetch(bridgeUrl(config.endpoint, path), { method: options.method, headers: headersFrom(config), signal: controller.signal, }); if (!response.ok) { const body = await response.text().catch(() => ""); throw new Error(`STT bridge ${options.method} ${path} failed (${response.status}): ${truncate(body)}`); } return response; } catch (error) { if ((error as Error).name === "AbortError") { throw new Error(`STT bridge ${options.method} ${path} timed out after ${options.timeoutSeconds}s`); } throw error; } finally { clearTimeout(timeout); } }; const discardBody = async (response: Response): Promise => { await response.arrayBuffer().catch(() => {}); }; export const checkBridgeRecorderHealth = async (config: BridgeCaptureConfig): Promise => { const response = await requestBridge(config, "/health", { method: "GET", timeoutSeconds: Math.min(config.requestTimeoutSeconds, 10) }); await discardBody(response); }; export const createBridgeRecorder = (config: BridgeCaptureConfig): AudioRecorder => ({ async start() { const tempDir = mkdtempSync(join(tmpdir(), "pi-voice-stt-")); const outputPath = join(tempDir, "recording.wav"); let finished = false; try { const response = await requestBridge(config, "/start", { method: "POST", timeoutSeconds: config.requestTimeoutSeconds }); await discardBody(response); } catch (error) { await rm(tempDir, { force: true, recursive: true }).catch(() => {}); throw error; } const stop = async () => { if (finished) return outputPath; let response: Response; try { response = await requestBridge(config, "/stop", { method: "POST", timeoutSeconds: config.requestTimeoutSeconds }); } catch (error) { throw new Error(`STT bridge could not stop recording: ${formatError(error)}`); } const audio = Buffer.from(await response.arrayBuffer()); await writeFile(outputPath, audio); const size = (await stat(outputPath)).size; if (size < config.minBytes) { throw new Error(`Bridge recording is too small (${size} bytes). Check Mac microphone permission/device.`); } const maxAmplitude = maxPcm16LeAmplitude(audio); if (maxAmplitude !== undefined && maxAmplitude <= SILENCE_MAX_AMPLITUDE) { throw new Error("Bridge recording is silent. On macOS, make sure the bridge is launched by an app with microphone permission (Terminal/cmux) and that the selected input device is correct."); } finished = true; return outputPath; }; const dispose = async () => { if (!finished) { const response = await requestBridge(config, "/cancel", { method: "POST", timeoutSeconds: Math.min(config.requestTimeoutSeconds, 10) }).catch((error: unknown) => { console.warn(`Pi Voice STT bridge cleanup failed: ${formatError(error)}`); return undefined; }); if (response) await discardBody(response); finished = true; } await rm(tempDir, { force: true, recursive: true }).catch((error: unknown) => { console.warn(`Pi Voice STT bridge temp cleanup failed: ${formatError(error)}`); }); }; return { outputPath, stop, dispose, } satisfies RecordingHandle; }, });