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
This commit is contained in:
Nexus Dev 2026-04-04 03:31:17 +00:00
parent cc05befdb0
commit 3673fa03a1

View file

@ -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<VoiceCapability> {
// 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<HardwareInfo> {
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<VoiceCapability>
): Promise<VoiceCapability> {
const timeout = new Promise<VoiceCapability>((resolve) =>
setTimeout(
() => resolve({ whisperAvailable: false, piperAvailable: false, voiceTierSufficient: false }),
3000
)
);
return Promise.race([probe, timeout]);
}