From 3673fa03a1a7fd4ccd749aa6680369086b3adb34 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 4 Apr 2026 03:31:17 +0000 Subject: [PATCH] feat(39-02): voice capability probe in hardware service - Add VoiceCapability interface with whisperAvailable, piperAvailable, voiceTierSufficient - Extend HardwareInfo with voiceCapability field - Add detectVoiceCapability() probing whisper-cpp/whisper and piper with 2s timeout each - voiceTierSufficient: true for apple_silicon/gpu, or cpu_only with >= 4GB free RAM - Wrap voice probe in 3s timeout to avoid slowing hardware detection - Route automatically includes voiceCapability via existing HardwareInfo return --- server/src/services/hardware.ts | 88 +++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/server/src/services/hardware.ts b/server/src/services/hardware.ts index e064a773..86350478 100644 --- a/server/src/services/hardware.ts +++ b/server/src/services/hardware.ts @@ -1,8 +1,16 @@ import os from "node:os"; +import { execFile as execFileCb } from "node:child_process"; import si from "systeminformation"; export type HardwareTier = "gpu" | "apple_silicon" | "cpu_only"; +export interface VoiceCapability { + whisperAvailable: boolean; + piperAvailable: boolean; + /** true if hardware tier >= apple_silicon OR (cpu_only with >= 4GB free RAM) */ + voiceTierSufficient: boolean; +} + export interface HardwareInfo { totalGb: number; freeGb: number; @@ -13,6 +21,7 @@ export interface HardwareInfo { unifiedMemory: boolean; hardwareTier: HardwareTier; cpuModel: string | null; + voiceCapability: VoiceCapability; } const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes @@ -26,6 +35,63 @@ export function _resetHardwareCache(): void { cacheExpiry = 0; } +/** Promisified execFile wrapper for consistent mocking in tests. */ +function execFileAsync( + cmd: string, + args: string[], + opts: { timeout?: number } +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFileCb(cmd, args, opts as never, (err, stdout, stderr) => { + if (err) { + reject(err); + } else { + resolve({ + stdout: Buffer.isBuffer(stdout) ? stdout.toString() : String(stdout ?? ""), + stderr: Buffer.isBuffer(stderr) ? stderr.toString() : String(stderr ?? ""), + }); + } + }); + }); +} + +/** Probe for Whisper and Piper binary availability. */ +async function detectVoiceCapability( + hardwareTier: HardwareTier, + freeGb: number +): Promise { + // Probe whisper-cpp first, fall back to whisper (openai-whisper) + let whisperAvailable = false; + try { + await execFileAsync("whisper-cpp", ["--help"], { timeout: 2000 }); + whisperAvailable = true; + } catch { + try { + await execFileAsync("whisper", ["--help"], { timeout: 2000 }); + whisperAvailable = true; + } catch { + whisperAvailable = false; + } + } + + // Probe piper + let piperAvailable = false; + try { + await execFileAsync("piper", ["--help"], { timeout: 2000 }); + piperAvailable = true; + } catch { + piperAvailable = false; + } + + // voiceTierSufficient: true for apple_silicon/gpu, or cpu_only with >= 4GB free + const voiceTierSufficient = + hardwareTier === "apple_silicon" || + hardwareTier === "gpu" || + (hardwareTier === "cpu_only" && freeGb >= 4); + + return { whisperAvailable, piperAvailable, voiceTierSufficient }; +} + export function hardwareService() { async function detect(): Promise { const now = Date.now(); @@ -43,6 +109,9 @@ export function hardwareService() { // Apple Silicon detection: darwin platform + CPU brand starting with "Apple" if (platform === "darwin" && cpuModel?.startsWith("Apple")) { + const voiceCapability = await withVoiceTimeout( + detectVoiceCapability("apple_silicon", freeGb) + ); const info: HardwareInfo = { totalGb, freeGb, @@ -53,6 +122,7 @@ export function hardwareService() { unifiedMemory: true, hardwareTier: "apple_silicon", cpuModel, + voiceCapability, }; cache = info; cacheExpiry = now + CACHE_TTL_MS; @@ -84,6 +154,10 @@ export function hardwareService() { hardwareTier = "cpu_only"; } + const voiceCapability = await withVoiceTimeout( + detectVoiceCapability(hardwareTier, freeGb) + ); + const info: HardwareInfo = { totalGb, freeGb, @@ -94,6 +168,7 @@ export function hardwareService() { unifiedMemory: false, hardwareTier, cpuModel, + voiceCapability, }; cache = info; @@ -103,3 +178,16 @@ export function hardwareService() { return { detect }; } + +/** Wrap voice capability probe with 3s timeout; return safe defaults on failure. */ +async function withVoiceTimeout( + probe: Promise +): Promise { + const timeout = new Promise((resolve) => + setTimeout( + () => resolve({ whisperAvailable: false, piperAvailable: false, voiceTierSufficient: false }), + 3000 + ) + ); + return Promise.race([probe, timeout]); +}