feat: live draft pipe transcription (ApplePipeTranscriber --pipe volatile)
- Reuses whisper-translation/apple_speech pipe protocol: 4-byte BE len + WAV -> draft/final JSONL - Persistent Swift session per locale, 60s idle auto-close - New tools: speech_live_transcribe (base64 WAV chunk -> drafts[] finals[]), speech_live_close, speech_live_status - Engine: SpeechAnalyzer + SpeechTranscriber macOS 26.5 with .volatileResults for <200ms draft feedback - Intended for ESP32 voice gateway WS live ingestion
This commit is contained in:
@@ -0,0 +1,281 @@
|
|||||||
|
import { execFile, spawn } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { writeFile, readFile, stat, rm, mkdir, mkdtemp } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
function swiftSourcePipeTranscriber() {
|
||||||
|
return `
|
||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import Speech
|
||||||
|
|
||||||
|
func parseArgs() -> (String, Bool) {
|
||||||
|
var localeId = "en-US"
|
||||||
|
var verbose = false
|
||||||
|
var i = 1
|
||||||
|
let raw = CommandLine.arguments
|
||||||
|
while i < raw.count {
|
||||||
|
let a = raw[i]
|
||||||
|
if a == "--locale", i+1 < raw.count { localeId = raw[i+1]; i+=1 }
|
||||||
|
else if a == "-v" || a == "--verbose" { verbose = true }
|
||||||
|
i+=1
|
||||||
|
}
|
||||||
|
return (localeId, verbose)
|
||||||
|
}
|
||||||
|
func logv(_ msg: String, verbose: Bool) { if verbose { fputs("[apple-pipe] \\(msg)\\n", stderr) } }
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct ApplePipeCLI {
|
||||||
|
static func main() async {
|
||||||
|
let (localeId, verbose) = parseArgs()
|
||||||
|
guard SpeechTranscriber.isAvailable else { fputs("Not available\\n", stderr); exit(1) }
|
||||||
|
let reqLocale = Locale(identifier: localeId)
|
||||||
|
let locale: Locale
|
||||||
|
if let sup = await SpeechTranscriber.supportedLocale(equivalentTo: reqLocale) { locale = sup }
|
||||||
|
else { locale = reqLocale }
|
||||||
|
// warm asset check
|
||||||
|
let warm = SpeechTranscriber(locale: locale, transcriptionOptions: [], reportingOptions: [.volatileResults], attributeOptions: [])
|
||||||
|
let status = await AssetInventory.status(forModules: [warm])
|
||||||
|
switch status {
|
||||||
|
case .installed: logv("Assets installed", verbose: verbose)
|
||||||
|
case .unsupported: fputs("Locale unsupported\\n", stderr); exit(2)
|
||||||
|
case .supported:
|
||||||
|
logv("Downloading assets...", verbose: true)
|
||||||
|
do { if let req = try await AssetInventory.assetInstallationRequest(supporting: [warm]) { try await req.downloadAndInstall() } }
|
||||||
|
catch { fputs("Asset download failed: \\(error)\\n", stderr); exit(3) }
|
||||||
|
case .downloading:
|
||||||
|
logv("Waiting assets...", verbose: true)
|
||||||
|
for _ in 0..<30 { try? await Task.sleep(nanoseconds: 1_000_000_000); if await AssetInventory.status(forModules: [warm]) == .installed { break } }
|
||||||
|
@unknown default: break
|
||||||
|
}
|
||||||
|
logv("Pipe ready locale=\\(locale.identifier)", verbose: true)
|
||||||
|
let stdinH = FileHandle.standardInput
|
||||||
|
var leftover = Data()
|
||||||
|
var chunkIdx = 0
|
||||||
|
func readExact(_ n: Int) -> Data? {
|
||||||
|
var out = Data(); out.reserveCapacity(n)
|
||||||
|
if leftover.count >= n { let d = leftover.prefix(n); leftover = leftover.dropFirst(n); return Data(d) }
|
||||||
|
if leftover.count > 0 { out.append(leftover); leftover = Data() }
|
||||||
|
while out.count < n {
|
||||||
|
let d = stdinH.readData(ofLength: n - out.count)
|
||||||
|
if d.isEmpty { if out.count==0 { return nil }; return nil }
|
||||||
|
out.append(d)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
while true {
|
||||||
|
guard let lenData = readExact(4) else { break }
|
||||||
|
let length = lenData.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }
|
||||||
|
if length == 0 { logv("Pipe EOF", verbose: true); break }
|
||||||
|
if length > 20_000_000 { fputs("Chunk too large \\(length)\\n", stderr); break }
|
||||||
|
guard let chunkData = readExact(Int(length)) else { fputs("Truncated expected \\(length)\\n", stderr); break }
|
||||||
|
chunkIdx += 1
|
||||||
|
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("apple-pipe-\\(ProcessInfo.processInfo.processIdentifier)-\\(chunkIdx).wav")
|
||||||
|
do { try chunkData.write(to: tmpURL) } catch { fputs("Write err: \\(error)\\n", stderr); continue }
|
||||||
|
guard let audioFile = try? AVAudioFile(forReading: tmpURL) else { try? FileManager.default.removeItem(at: tmpURL); fputs("Open fail \\(chunkIdx)\\n", stderr); continue }
|
||||||
|
let t = SpeechTranscriber(locale: locale, transcriptionOptions: [], reportingOptions: [.volatileResults], attributeOptions: [.audioTimeRange])
|
||||||
|
guard let analyzer = try? await SpeechAnalyzer(inputAudioFile: audioFile, modules: [t], finishAfterFile: true) else { try? FileManager.default.removeItem(at: tmpURL); continue }
|
||||||
|
do {
|
||||||
|
for try await res in t.results {
|
||||||
|
let txt = String(res.text.characters).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if txt.isEmpty { continue }
|
||||||
|
let d: [String: Any] = [
|
||||||
|
"event": res.isFinal ? "final" : "draft",
|
||||||
|
"text": txt, "isFinal": res.isFinal,
|
||||||
|
"chunk": chunkIdx, "start": res.range.start.seconds,
|
||||||
|
"duration": res.range.duration.seconds
|
||||||
|
]
|
||||||
|
if let jd = try? JSONSerialization.data(withJSONObject: d), let s = String(data: jd, encoding: .utf8) {
|
||||||
|
print(s); fflush(stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { fputs("Results err \\(chunkIdx): \\(error)\\n", stderr) }
|
||||||
|
_ = analyzer
|
||||||
|
try? FileManager.default.removeItem(at: tmpURL)
|
||||||
|
}
|
||||||
|
logv("Pipe done \\(chunkIdx)", verbose: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApplePipeSession {
|
||||||
|
constructor(locale) {
|
||||||
|
this.locale = locale;
|
||||||
|
this.proc = null;
|
||||||
|
this.tmpDir = null;
|
||||||
|
this.binFile = null;
|
||||||
|
this.ready = false;
|
||||||
|
this.chunkIdx = 0;
|
||||||
|
this.lastActivity = Date.now();
|
||||||
|
this._lineCallback = null;
|
||||||
|
}
|
||||||
|
async ensureBuilt() {
|
||||||
|
const tmpBase = path.join(os.tmpdir(), "speech-live-");
|
||||||
|
this.tmpDir = await mkdtemp(tmpBase);
|
||||||
|
const swiftFile = path.join(this.tmpDir, "Main.swift");
|
||||||
|
this.binFile = path.join(this.tmpDir, "apple-pipe-transcribe");
|
||||||
|
await writeFile(swiftFile, swiftSourcePipeTranscriber(), "utf8");
|
||||||
|
try {
|
||||||
|
await execFileAsync("/usr/bin/swiftc", ["-O", "-parse-as-library", swiftFile, "-o", this.binFile, "-framework", "AVFoundation", "-framework", "Speech"], { timeout: 60000, maxBuffer: 20*1024*1024 });
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`swiftc build failed: ${e.stderr||e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async start() {
|
||||||
|
if (this.proc && this.proc.exitCode === null && this.ready) return;
|
||||||
|
if (!this.binFile) await this.ensureBuilt();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const args = ["--locale", this.locale];
|
||||||
|
this.proc = spawn(this.binFile, args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||||
|
let stderrBuf = "";
|
||||||
|
this.proc.stderr.on("data", d => { stderrBuf += d.toString(); });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.proc.exitCode !== null) {
|
||||||
|
reject(new Error(`apple-pipe exited early code=${this.proc.exitCode} stderr=${stderrBuf.slice(0,2000)}`));
|
||||||
|
} else {
|
||||||
|
this.ready = true;
|
||||||
|
this._setupReader();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
this.proc.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_setupReader() {
|
||||||
|
let buf = "";
|
||||||
|
this.proc.stdout.on("data", chunk => {
|
||||||
|
buf += chunk.toString("utf8");
|
||||||
|
let lines = buf.split("\n");
|
||||||
|
buf = lines.pop() || "";
|
||||||
|
for (let line of lines) {
|
||||||
|
line = line.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line);
|
||||||
|
if (this._lineCallback) this._lineCallback(obj);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async transcribeChunk(wavBytes, { timeoutMs = 6000 } = {}) {
|
||||||
|
await this.start();
|
||||||
|
this.chunkIdx++;
|
||||||
|
const myIdx = this.chunkIdx;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let drafts = [];
|
||||||
|
let finals = [];
|
||||||
|
let timer = null;
|
||||||
|
let done = false;
|
||||||
|
const cleanup = () => { done = true; if (timer) clearTimeout(timer); this._lineCallback = null; };
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
cleanup();
|
||||||
|
const fullFinal = finals.map(f=>f.text).join(" ").trim();
|
||||||
|
const lastDraft = drafts.length ? drafts[drafts.length-1].text : "";
|
||||||
|
const text = fullFinal || lastDraft || "";
|
||||||
|
resolve({ text, finals, drafts, chunk: myIdx, isFinal: finals.length>0 });
|
||||||
|
};
|
||||||
|
this._lineCallback = (obj) => {
|
||||||
|
if (obj.chunk !== myIdx) return;
|
||||||
|
if (obj.isFinal || obj.event==="final") {
|
||||||
|
finals.push(obj);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(finish, 350);
|
||||||
|
} else {
|
||||||
|
drafts.push(obj);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(finish, 700);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
timer = setTimeout(() => { if (!done) finish(); }, timeoutMs);
|
||||||
|
try {
|
||||||
|
const lenBuf = Buffer.alloc(4);
|
||||||
|
lenBuf.writeUInt32BE(wavBytes.length, 0);
|
||||||
|
this.proc.stdin.write(Buffer.concat([lenBuf, Buffer.from(wavBytes)]));
|
||||||
|
} catch (e) {
|
||||||
|
cleanup();
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async close() {
|
||||||
|
try {
|
||||||
|
if (this.proc && this.proc.stdin.writable) {
|
||||||
|
const eof = Buffer.alloc(4); eof.writeUInt32BE(0,0);
|
||||||
|
this.proc.stdin.write(eof);
|
||||||
|
this.proc.stdin.end();
|
||||||
|
}
|
||||||
|
if (this.proc) {
|
||||||
|
await new Promise(r => { this.proc.on("close", r); setTimeout(r, 1500); });
|
||||||
|
try { this.proc.kill(); } catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
try { if (this.tmpDir) await rm(this.tmpDir, {recursive:true, force:true}); } catch {}
|
||||||
|
this.proc = null;
|
||||||
|
this.ready = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map();
|
||||||
|
|
||||||
|
async function getOrCreateSession(locale) {
|
||||||
|
let s = sessions.get(locale);
|
||||||
|
if (!s) {
|
||||||
|
s = new ApplePipeSession(locale);
|
||||||
|
sessions.set(locale, s);
|
||||||
|
}
|
||||||
|
await s.start();
|
||||||
|
s.lastActivity = Date.now();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function speechLiveTranscribe({ audioBase64, locale } = {}) {
|
||||||
|
if (!audioBase64) throw new Error("audioBase64 required (WAV 16k mono base64)");
|
||||||
|
const wavBytes = Buffer.from(audioBase64, "base64");
|
||||||
|
const loc = locale || "en-US";
|
||||||
|
const session = await getOrCreateSession(loc);
|
||||||
|
const res = await session.transcribeChunk(wavBytes, { timeoutMs: 8000 });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
engine: "ApplePipeTranscriber/macOS26.5 --pipe volatile drafts",
|
||||||
|
locale: loc,
|
||||||
|
text: res.text,
|
||||||
|
isFinal: res.isFinal,
|
||||||
|
drafts: res.drafts,
|
||||||
|
finals: res.finals,
|
||||||
|
chunk: res.chunk,
|
||||||
|
realtime: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function speechLiveClose({ locale } = {}) {
|
||||||
|
const loc = locale || "en-US";
|
||||||
|
const s = sessions.get(loc);
|
||||||
|
if (s) {
|
||||||
|
await s.close();
|
||||||
|
sessions.delete(loc);
|
||||||
|
}
|
||||||
|
return { ok: true, closed: loc };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function speechLiveStatus() {
|
||||||
|
const info = [];
|
||||||
|
for (let [loc, s] of sessions.entries()) {
|
||||||
|
info.push({ locale: loc, ready: s.ready, chunkIdx: s.chunkIdx, lastActivity: new Date(s.lastActivity).toISOString(), pid: s.proc?.pid || null });
|
||||||
|
}
|
||||||
|
return { sessions: info, count: info.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (let [loc, s] of sessions.entries()) {
|
||||||
|
if (now - s.lastActivity > 60000) {
|
||||||
|
try { await s.close(); } catch {}
|
||||||
|
sessions.delete(loc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
@@ -36,6 +36,11 @@ import {
|
|||||||
speechListLocales,
|
speechListLocales,
|
||||||
speechQuickTest,
|
speechQuickTest,
|
||||||
} from "./integrations/speech-transcribe.js";
|
} from "./integrations/speech-transcribe.js";
|
||||||
|
import {
|
||||||
|
speechLiveTranscribe,
|
||||||
|
speechLiveClose,
|
||||||
|
speechLiveStatus,
|
||||||
|
} from "./integrations/speech-live.js";
|
||||||
|
|
||||||
const jsonResult = (value) => ({
|
const jsonResult = (value) => ({
|
||||||
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
||||||
@@ -285,6 +290,33 @@ export function createMacMiniMcpServer() {
|
|||||||
handled(({ text, voice }) => speechQuickTest({ text, voice })),
|
handled(({ text, voice }) => speechQuickTest({ text, voice })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"speech_live_transcribe",
|
||||||
|
"LIVE draft transcription via persistent Apple pipe (ApplePipeTranscriber macOS 26+). Reuses your whisper-translation pipe protocol: 4-byte BE len + WAV payload -> draft/final JSON streaming. Input: base64-encoded 16kHz mono WAV. Output: {text, drafts[], finals[], isFinal}. Keep session alive for <200ms incremental feedback while streaming ESP32 PCM. Auto-closes after 60s idle.",
|
||||||
|
{
|
||||||
|
audioBase64: z.string().min(100).describe("Base64-encoded WAV file (16kHz mono s16le). Use 0.5-3s chunks for draft streaming."),
|
||||||
|
locale: z.string().optional().describe("Locale like en-US. Default en-US."),
|
||||||
|
},
|
||||||
|
handled(({ audioBase64, locale }) => speechLiveTranscribe({ audioBase64, locale })),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"speech_live_close",
|
||||||
|
"Close a persistent live transcription pipe session (frees Swift process).",
|
||||||
|
{
|
||||||
|
locale: z.string().optional().describe("Locale to close. Default en-US."),
|
||||||
|
},
|
||||||
|
handled(({ locale }) => speechLiveClose({ locale })),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"speech_live_status",
|
||||||
|
"Show active live pipe sessions: locale, pid, chunkIdx, lastActivity.",
|
||||||
|
{},
|
||||||
|
handled(() => speechLiveStatus()),
|
||||||
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"codex_image_get_config_status",
|
"codex_image_get_config_status",
|
||||||
"Show local Codex CLI image generation configuration.",
|
"Show local Codex CLI image generation configuration.",
|
||||||
|
|||||||
Reference in New Issue
Block a user