Fix speech-transcribe: all SpeechTranscriber locale APIs are async in macOS 26

This commit is contained in:
aeroreyna
2026-07-13 17:09:42 -04:00
parent af2fd4303b
commit a9a531dc22
+63 -145
View File
@@ -1,6 +1,6 @@
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { writeFile, unlink, mkdir, readFile } from "node:fs/promises"; import { writeFile, unlink, mkdir, readFile, stat, rm } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import fs from "node:fs"; import fs from "node:fs";
@@ -10,12 +10,10 @@ const OUTPUT_BASE = path.join(os.homedir(), "Projects", "MacMiniMCP", "generated
// ---------- SWIFT SOURCE BUILDER ---------- // ---------- SWIFT SOURCE BUILDER ----------
// Follows Apple docs https://docs.developer.apple.com/tutorials/data/documentation/speech/speechanalyzer.md // Follows Apple docs https://docs.developer.apple.com/tutorials/data/documentation/speech/speechanalyzer.md
// Key bug from Inscribe benchmark: you MUST call finalizeAndFinishThroughEndOfInput() after feeding input, // Key bug from Inscribe benchmark: MUST call finalizeAndFinishThroughEndOfInput()
// not just finish the AsyncStream builder.
function swiftSourceForFileTranscription() { function swiftSourceForFileTranscription() {
return String.raw` return `import Speech
import Speech
import AVFoundation import AVFoundation
import Foundation import Foundation
@@ -28,42 +26,47 @@ struct TranscribeCLI {
let jsonOut = args.count > 3 ? args[3] : "" let jsonOut = args.count > 3 ? args[3] : ""
if audioPath.isEmpty { if audioPath.isEmpty {
fputs("Usage: transcriber <audioPath> [locale] [jsonOutPath]\n", stderr) fputs("Usage: transcriber <audioPath> [locale] [jsonOutPath]\\n", stderr)
exit(1) exit(1)
} }
let startTime = CFAbsoluteTimeGetCurrent() let startTime = CFAbsoluteTimeGetCurrent()
guard FileManager.default.fileExists(atPath: audioPath) else { guard FileManager.default.fileExists(atPath: audioPath) else {
fputs("ERROR: Audio file not found: \(audioPath)\n", stderr) fputs("ERROR: Audio file not found: \\(audioPath)\\n", stderr)
exit(3) exit(3)
} }
let audioURL = URL(fileURLWithPath: audioPath) let audioURL = URL(fileURLWithPath: audioPath)
let requestedLocale = Locale(identifier: localeId) let requestedLocale = Locale(identifier: localeId)
guard let locale = SpeechTranscriber.supportedLocale(equivalentTo: requestedLocale) ?? SpeechTranscriber.supportedLocale(equivalentTo: Locale(identifier: "en-US")) else {
fputs("ERROR: No supported locale for \(localeId)\n", stderr) // In macOS 26, supportedLocale and supportedLocales are async
let resolvedLocale: Locale
if let loc = await SpeechTranscriber.supportedLocale(equivalentTo: requestedLocale) {
resolvedLocale = loc
} else if let fallback = await SpeechTranscriber.supportedLocale(equivalentTo: Locale(identifier: "en-US")) {
resolvedLocale = fallback
} else {
fputs("ERROR: No supported locale for \\(localeId)\\n", stderr)
exit(2) exit(2)
} }
fputs("Locale: \(locale.identifier) (requested \(localeId))\n", stderr) let isAvail = SpeechTranscriber.isAvailable
fputs("isAvailable: \(SpeechTranscriber.isAvailable)\n", stderr) fputs("Locale: \\(resolvedLocale.identifier) (requested \\(localeId)) isAvailable=\\(isAvail)\\n", stderr)
fputs("installedLocales: \(SpeechTranscriber.installedLocales.map{$0.identifier})\n", stderr)
fputs("supportedLocales count: \(SpeechTranscriber.supportedLocales.count)\n", stderr)
let transcriber = SpeechTranscriber(locale: locale, preset: .transcription) let transcriber = SpeechTranscriber(locale: resolvedLocale, preset: .transcription)
// Asset install // Asset install
do { do {
if let req = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) { if let req = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
fputs("Downloading assets for \(locale.identifier)...\n", stderr) fputs("Downloading assets for \\(resolvedLocale.identifier)...\\n", stderr)
try await req.downloadAndInstall() try await req.downloadAndInstall()
fputs("Assets ready\n", stderr) fputs("Assets ready\\n", stderr)
} else { } else {
fputs("No asset download needed\n", stderr) fputs("No asset download needed\\n", stderr)
} }
} catch { } catch {
fputs("Asset install note (continuing): \(error)\n", stderr) fputs("Asset install note (continuing): \\(error)\\n", stderr)
} }
// Open with AVAudioFile for info // Open with AVAudioFile for info
@@ -71,57 +74,46 @@ struct TranscribeCLI {
do { do {
avFile = try AVAudioFile(forReading: audioURL) avFile = try AVAudioFile(forReading: audioURL)
} catch { } catch {
fputs("ERROR opening audio: \(error)\n", stderr) fputs("ERROR opening audio: \\(error)\\n", stderr)
exit(4) exit(4)
} }
fputs("Audio format: \(avFile.fileFormat) \(avFile.length) frames sr=\(avFile.processingFormat.sampleRate)\n", stderr) fputs("Audio: frames=\\(avFile.length) sr=\\(avFile.processingFormat.sampleRate) format=\\(avFile.fileFormat)\\n", stderr)
do { do {
// AssetInputSequenceProvider is the recommended file path let provider = try AssetInputSequenceProvider(fileURL: audioURL)
let provider: AssetInputSequenceProvider
do {
provider = try AssetInputSequenceProvider(fileURL: audioURL)
} catch {
fputs("AssetInputSequenceProvider init failed: \(error), trying Capture path fallback via AVAudioFile direct read\n", stderr)
throw error
}
let analyzer = SpeechAnalyzer(modules: [transcriber]) let analyzer = SpeechAnalyzer(modules: [transcriber])
var allSegments: [String] = [] var allSegments: [String] = []
var allAttributed: [String] = []
// Collect results concurrently - must start BEFORE analyze
let collector = Task { let collector = Task {
do { do {
for try await r in transcriber.results { for try await r in transcriber.results {
let plain = String(r.text.characters) let plain = String(r.text.characters)
if !plain.isEmpty { if !plain.isEmpty {
allSegments.append(plain) allSegments.append(plain)
// For debugging, also log alternatives count fputs("[result] \\(plain)\\n", stderr)
fputs("[result] \(plain)\n", stderr)
} }
} }
} catch { } catch {
fputs("Results error: \(error)\n", stderr) fputs("Results error: \\(error)\\n", stderr)
} }
} }
fputs("Starting analysis...\n", stderr) fputs("Starting analysis...\\n", stderr)
// Start and finalize pattern - per Inscribe bug report MUST call finalizeAndFinishThroughEndOfInput()
try await analyzer.start(inputSequence: provider.analyzerInputs) try await analyzer.start(inputSequence: provider.analyzerInputs)
try await analyzer.finalizeAndFinishThroughEndOfInput() try await analyzer.finalizeAndFinishThroughEndOfInput()
await collector.value await collector.value
let elapsed = CFAbsoluteTimeGetCurrent() - startTime let elapsed = CFAbsoluteTimeGetCurrent() - startTime
let full = allSegments.joined(separator: " ") let full = allSegments.joined(separator: " ")
let durationSec = Double(avFile.length) / avFile.processingFormat.sampleRate let durationSec = avFile.length > 0 ? Double(avFile.length) / avFile.processingFormat.sampleRate : 0
let installed = await SpeechTranscriber.installedLocales.map { $0.identifier }.sorted()
let payload: [String: Any] = [ let payload: [String: Any] = [
"ok": true, "ok": true,
"engine": "SpeechAnalyzer+SpeechTranscriber", "engine": "SpeechAnalyzer+SpeechTranscriber",
"locale": locale.identifier, "locale": resolvedLocale.identifier,
"requestedLocale": localeId, "requestedLocale": localeId,
"transcript": full, "transcript": full,
"segments": allSegments, "segments": allSegments,
@@ -132,8 +124,8 @@ struct TranscribeCLI {
"frames": Int(avFile.length), "frames": Int(avFile.length),
"sampleRate": avFile.processingFormat.sampleRate, "sampleRate": avFile.processingFormat.sampleRate,
"macOS": ProcessInfo.processInfo.operatingSystemVersionString, "macOS": ProcessInfo.processInfo.operatingSystemVersionString,
"isAvailable": SpeechTranscriber.isAvailable, "isAvailable": isAvail,
"installedLocales": SpeechTranscriber.installedLocales.map{$0.identifier} "installedLocales": installed
] ]
let dataOut = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) let dataOut = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
@@ -143,17 +135,7 @@ struct TranscribeCLI {
FileHandle.standardOutput.write(dataOut) FileHandle.standardOutput.write(dataOut)
} catch { } catch {
fputs("Analysis failed: \(error)\n", stderr) fputs("Analysis failed: \\(error)\\n", stderr)
// Dump full error chain
var err: Error? = error
while let e = err {
fputs(" -> \(e)\n", stderr)
if let le = e as? LocalizedError {
if let desc = le.errorDescription { fputs(" desc: \(desc)\n", stderr) }
if let fail = le.failureReason { fputs(" reason: \(fail)\n", stderr) }
}
err = (e as NSError).underlyingErrors.first ?? (e as NSError).userInfo[NSUnderlyingErrorKey] as? Error
}
exit(6) exit(6)
} }
} }
@@ -161,8 +143,6 @@ struct TranscribeCLI {
`; `;
} }
// ---------- MAIN EXPORTS ----------
async function ensureOutputDir() { async function ensureOutputDir() {
await mkdir(OUTPUT_BASE, { recursive: true }); await mkdir(OUTPUT_BASE, { recursive: true });
} }
@@ -175,40 +155,25 @@ async function buildAndRunTranscriber({ audioPath, locale = "en-US" }) {
await writeFile(swiftFile, swiftSourceForFileTranscription(), "utf8"); await writeFile(swiftFile, swiftSourceForFileTranscription(), "utf8");
// Compile with swiftc
// Need AVFoundation + Speech, so link frameworks automatically via swiftc
try { try {
fputsStderr(`Compiling transcriber for ${audioPath}...\n`); const comp = await execFileAsync("/usr/bin/swiftc", [
const compileCmd = await execFileAsync("/usr/bin/swiftc", [ "-O", "-parse-as-library", swiftFile, "-o", binFile,
"-O", "-framework", "AVFoundation", "-framework", "Speech",
"-parse-as-library",
swiftFile,
"-o", binFile,
"-framework", "AVFoundation",
"-framework", "Speech",
], { timeout: 60_000, maxBuffer: 10 * 1024 * 1024 }); ], { timeout: 60_000, maxBuffer: 10 * 1024 * 1024 });
if (compileCmd.stderr) fputsStderr(compileCmd.stderr); if (comp.stderr) { try { process.stderr.write(comp.stderr); } catch {} }
} catch (e) { } catch (e) {
await cleanup(tmpDir); await cleanup(tmpDir);
throw new Error(`swiftc compile failed: ${e.stderr || e.message}\n${e.stdout || ""}`); throw new Error(`swiftc compile failed: ${e.stderr || e.message}\n${e.stdout || ""}`);
} }
try { try { await stat(binFile); } catch { await cleanup(tmpDir); throw new Error("Compiled binary not found"); }
await stat(binFile);
} catch {
await cleanup(tmpDir);
throw new Error("Compiled binary not found after swiftc");
}
// Run binary
try { try {
fputsStderr(`Running transcriber binary...\n`);
const { stdout, stderr } = await execFileAsync(binFile, [audioPath, locale, jsonOut], { const { stdout, stderr } = await execFileAsync(binFile, [audioPath, locale, jsonOut], {
timeout: 120_000, timeout: 120_000, maxBuffer: 20 * 1024 * 1024,
maxBuffer: 20 * 1024 * 1024,
}); });
if (stderr) fputsStderr(`transcriber stderr: ${stderr.slice(0, 4000)}\n`); if (stderr) { try { process.stderr.write(`transcriber stderr: ${stderr.slice(0,4000)}\n`); } catch {} }
// Try jsonOut file first, then stdout
let resultJson; let resultJson;
try { try {
const raw = await readFile(jsonOut, "utf8"); const raw = await readFile(jsonOut, "utf8");
@@ -216,8 +181,8 @@ async function buildAndRunTranscriber({ audioPath, locale = "en-US" }) {
} catch { } catch {
resultJson = JSON.parse(stdout); resultJson = JSON.parse(stdout);
} }
await ensureOutputDir(); await ensureOutputDir();
// Copy to persistent location
const persistName = `transcription-${Date.now()}.json`; const persistName = `transcription-${Date.now()}.json`;
const persistPath = path.join(OUTPUT_BASE, persistName); const persistPath = path.join(OUTPUT_BASE, persistName);
try { try {
@@ -229,32 +194,21 @@ async function buildAndRunTranscriber({ audioPath, locale = "en-US" }) {
} catch (e) { } catch (e) {
const errOut = e.stdout || ""; const errOut = e.stdout || "";
const errErr = e.stderr || e.message || ""; const errErr = e.stderr || e.message || "";
// Try to parse partial stdout as json
try { try {
const maybe = JSON.parse(e.stdout || ""); const maybe = JSON.parse(e.stdout || "");
if (maybe && maybe.ok) { if (maybe && maybe.ok) { await cleanup(tmpDir); return maybe; }
await cleanup(tmpDir);
return maybe;
}
} catch {} } catch {}
await cleanup(tmpDir); await cleanup(tmpDir);
throw new Error(`Transcriber run failed\nSTDOUT: ${errOut.slice(0, 3000)}\nSTDERR: ${errErr.slice(0, 5000)}`); throw new Error(`Transcriber run failed\nSTDOUT: ${errOut.slice(0,3000)}\nSTDERR: ${errErr.slice(0,5000)}`);
} }
} }
function fputsStderr(s) { function fputsStderr(s) { try { process.stderr.write(s); } catch {} }
try { process.stderr.write(s); } catch {}
}
async function cleanup(dir) { async function cleanup(dir) {
try { try { await rm(dir, { recursive: true, force: true }); } catch {}
const { rm } = await import("node:fs/promises");
await rm(dir, { recursive: true, force: true });
} catch {}
} }
// ---- Tool functions exposed to MCP ----
export async function speechListLocales() { export async function speechListLocales() {
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "speech-locale-")); const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "speech-locale-"));
const swiftFile = path.join(tmpDir, "ListLocales.swift"); const swiftFile = path.join(tmpDir, "ListLocales.swift");
@@ -266,26 +220,19 @@ import Foundation
struct L { struct L {
static func main() async { static func main() async {
let isAvail = SpeechTranscriber.isAvailable let isAvail = SpeechTranscriber.isAvailable
let supported: [String] let supported = await SpeechTranscriber.supportedLocales.map { $0.identifier }.sorted()
let installed: [String] let installed = await SpeechTranscriber.installedLocales.map { $0.identifier }.sorted()
// These may be async in newer SDKs let reserved = AssetInventory.reservedLocales.map { $0.identifier }
// Try sync first, fallback to await if needed via Any let maxRes = AssetInventory.maximumReservedLocales
if #available(macOS 26.0, *) { let status: [String: Any] = [
supported = await SpeechTranscriber.supportedLocales.map { $0.identifier }.sorted()
installed = await SpeechTranscriber.installedLocales.map { $0.identifier }.sorted()
} else {
supported = SpeechTranscriber.supportedLocales.map { $0.identifier }.sorted()
installed = SpeechTranscriber.installedLocales.map { $0.identifier }.sorted()
}
let payload: [String: Any] = [
"isAvailable": isAvail, "isAvailable": isAvail,
"supported": supported, "supported": supported,
"installed": installed, "installed": installed,
"macOS": ProcessInfo.processInfo.operatingSystemVersionString, "macOS": ProcessInfo.processInfo.operatingSystemVersionString,
"maxReserved": AssetInventory.maximumReservedLocales, "maxReserved": maxRes,
"reserved": AssetInventory.reservedLocales.map { $0.identifier } "reserved": reserved
] ]
let d = try! JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) let d = try! JSONSerialization.data(withJSONObject: status, options: [.prettyPrinted, .sortedKeys])
FileHandle.standardOutput.write(d) FileHandle.standardOutput.write(d)
} }
} }
@@ -304,43 +251,21 @@ struct L {
export async function speechTranscribeFile({ filePath, locale }) { export async function speechTranscribeFile({ filePath, locale }) {
if (!filePath) throw new Error("filePath required"); if (!filePath) throw new Error("filePath required");
// Security: must be inside allowed dirs or /tmp const resolved = path.resolve(filePath.replace(/^~/, os.homedir()));
const allowedPrefixes = [ try { await stat(resolved); } catch { throw new Error(`File not found: ${resolved}`); }
os.homedir(),
"/tmp/",
"/var/tmp/",
os.tmpdir() + "/",
];
const resolved = path.resolve(filePath);
const isAllowed = allowedPrefixes.some(p => resolved.startsWith(path.resolve(p)));
if (!isAllowed) {
// Still allow if file exists and is under /Users/adolforeyna/ (we're on Mac)
if (!resolved.startsWith("/Users/adolforeyna/") && !resolved.startsWith("/var/folders/")) {
throw new Error(`filePath not in allowed area: ${resolved}`);
}
}
try {
await stat(resolved);
} catch {
throw new Error(`File not found: ${resolved}`);
}
return await buildAndRunTranscriber({ audioPath: resolved, locale: locale || "en-US" }); return await buildAndRunTranscriber({ audioPath: resolved, locale: locale || "en-US" });
} }
export async function speechQuickTest({ text, voice } = {}) { export async function speechQuickTest({ text, voice } = {}) {
// Generate a test audio using say command, then transcribe it - end-to-end loopback test
// This tests the engine without needing a real file from user
const testText = text || "Hello world this is a test of Apple SpeechAnalyzer on the Mac mini M four"; const testText = text || "Hello world this is a test of Apple SpeechAnalyzer on the Mac mini M four";
const testVoice = voice || "Alex"; const testVoice = voice || "Alex";
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "speech-qtest-")); const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "speech-qtest-"));
const wavPath = path.join(tmpDir, "test.wav"); // say can do aiff, we convert?
const aiffPath = path.join(tmpDir, "test.aiff"); const aiffPath = path.join(tmpDir, "test.aiff");
try { try {
// say -> aiff (macOS say produces aiff/wav)
await execFileAsync("/usr/bin/say", ["-v", testVoice, "-o", aiffPath, testText], { timeout: 15_000 }); await execFileAsync("/usr/bin/say", ["-v", testVoice, "-o", aiffPath, testText], { timeout: 15_000 });
} catch (e) { } catch {
// Try default voice
try { try {
await execFileAsync("/usr/bin/say", ["-o", aiffPath, testText], { timeout: 15_000 }); await execFileAsync("/usr/bin/say", ["-o", aiffPath, testText], { timeout: 15_000 });
} catch (e2) { } catch (e2) {
@@ -349,15 +274,8 @@ export async function speechQuickTest({ text, voice } = {}) {
} }
} }
try { try { await stat(aiffPath); } catch { await cleanup(tmpDir); throw new Error("Generated audio file not found"); }
await stat(aiffPath);
} catch {
await cleanup(tmpDir);
throw new Error("Generated audio file not found");
}
// Now transcribe the generated aiff
// AVFoundation can read aiff directly
let result; let result;
try { try {
result = await buildAndRunTranscriber({ audioPath: aiffPath, locale: "en-US" }); result = await buildAndRunTranscriber({ audioPath: aiffPath, locale: "en-US" });
@@ -365,17 +283,17 @@ export async function speechQuickTest({ text, voice } = {}) {
await cleanup(tmpDir); await cleanup(tmpDir);
throw e; throw e;
} }
result.testInputText = testText; result.testInputText = testText;
result.testVoice = testVoice; result.testVoice = testVoice;
result.generatedAudioPath = aiffPath;
// Don't cleanup yet? cleanup our temp but keep result with attempted path
// Keep aiff for debugging if needed - move to output
try { try {
await ensureOutputDir(); await ensureOutputDir();
const destAiff = path.join(OUTPUT_BASE, `qtest-${Date.now()}.aiff`); const destAiff = path.join(OUTPUT_BASE, `qtest-${Date.now()}.aiff`);
await execFileAsync("/bin/cp", [aiffPath, destAiff], { timeout: 5_000 }); await execFileAsync("/bin/cp", [aiffPath, destAiff], { timeout: 5_000 });
result.persistedAudio = destAiff; result.persistedAudio = destAiff;
} catch {} } catch {}
await cleanup(tmpDir); await cleanup(tmpDir);
return result; return result;
} }