feat: Apple Speech v3 + Freeflow polish + draft streaming

- Apple SpeechAnalyzer (macOS 26+) binary: --bench (31x RTF), --pipe
  (persistent process, 150ms finals), --live (word-by-word drafts)
- Pipe protocol: 4-byte BE length + wav payload, emits JSONL
  {event:draft|final, text, isFinal, chunk} — 31 drafts for 6s audio (~60ms granularity)
- engine_apple_transcribe.py: ApplePipeTranscriber with
  transcribe() + transcribe_with_draft_callback(), VAD + draft
  queue, new flags --apple-stream (on), --apple-stream-interval,
  --apple-pipe (on). Fixes PIL/transformers import crash by lazy import.
- main_v3.py: engine selector {whisper,apple}, passthrough translate
  when no -es/-fr/-ar, freeflow flags same as v2
- Freeflow polish: deterministic punctuation commands (comma,
  question mark, new paragraph, at sign), filler stripping,
  <keep> protection, skip-clean heuristic, freeflow/qwen/legacy
  prompt styles. Much better final readability vs raw Apple/Whisper.
- main_v2.py, engine_llm.py, engine_distribute.py: integrate freeflow
- bench: Apple 2.12% WER vs Whisper Small 3.74% (Inscribe), CPU
  0mW ANE (measured via powermetrics), 196M EN cryptex per locale.
- Verified: 31 word-by-word drafts, 2 finals, exit 0, bench regression ok.

Freeflow still much better for final polish — Apple wins on speed
and raw accuracy, freeflow wins on readable paragraph output.

Co-authored-by: internal-model
This commit is contained in:
Adolfo Reyna
2026-07-13 21:08:18 -04:00
parent a2b108a5da
commit 80f0bf309f
19 changed files with 2276 additions and 185 deletions
+16
View File
@@ -0,0 +1,16 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "AppleSpeech",
platforms: [.macOS(.v26)],
products: [
.executable(name: "apple-speech-transcribe", targets: ["AppleSpeechCLI"])
],
targets: [
.executableTarget(
name: "AppleSpeechCLI",
path: "Sources/AppleSpeechCLI"
)
]
)
@@ -0,0 +1,436 @@
import AVFoundation
import Foundation
import Speech
enum Mode: String { case file, live, pipe, listLocales, bench, check }
struct CLIArgs {
var mode: Mode = .file
var filePath: String? = nil
var locale: String = "en-US"
var verbose: Bool = false
var includeVolatile: Bool = false
}
func parseArgs() -> CLIArgs {
var args = CLIArgs()
var i = 1
let raw = CommandLine.arguments
while i < raw.count {
let a = raw[i]
switch a {
case "--live": args.mode = .live
case "--pipe": args.mode = .pipe
case "--list-locales": args.mode = .listLocales
case "--bench": args.mode = .bench
case "--check": args.mode = .check
case "--locale": if i + 1 < raw.count { args.locale = raw[i+1]; i += 1 }
case "--verbose", "-v": args.verbose = true
case "--include-volatile": args.includeVolatile = true
case "--help", "-h":
fputs("""
Usage:
apple-speech-transcribe <audio-file> [--locale en-US] [-v] [--include-volatile]
apple-speech-transcribe --bench <audio-file> [--locale en-US] [-v]
apple-speech-transcribe --live [--locale en-US] [-v]
apple-speech-transcribe --pipe [--locale en-US] [-v] (stdin wav chunks: 4-byte BE len + wav payload)
apple-speech-transcribe --check
apple-speech-transcribe --list-locales
Output (file mode): JSON lines per segment on stdout
Output (bench): single JSON object with full transcript + timing
Output (live/pipe): JSON lines streaming (isFinal + text)
""", stderr)
exit(0)
default:
if !a.hasPrefix("-") && args.filePath == nil && (args.mode == .file || args.mode == .bench) {
args.filePath = a
}
}
i += 1
}
return args
}
func logv(_ msg: String, verbose: Bool) { if verbose { fputs("[apple-speech] \(msg)\n", stderr) } }
func resolveLocale(_ id: String) async -> Locale {
if let sup = await SpeechTranscriber.supportedLocale(equivalentTo: Locale(identifier: id)) { return sup }
return Locale(identifier: id)
}
func makeTranscriber(locale: Locale, includeVolatile: Bool) -> SpeechTranscriber {
let rep: Set<SpeechTranscriber.ReportingOption> = includeVolatile ? [.volatileResults, .alternativeTranscriptions] : [.alternativeTranscriptions]
return SpeechTranscriber(locale: locale, transcriptionOptions: [], reportingOptions: rep, attributeOptions: [.audioTimeRange, .transcriptionConfidence])
}
func ensureAssets(_ transcriber: SpeechTranscriber, verbose: Bool) async -> Bool {
let status = await AssetInventory.status(forModules: [transcriber])
switch status {
case .installed: logv("Assets installed", verbose: verbose); return true
case .unsupported: fputs("Locale unsupported\n", stderr); return false
case .supported:
logv("Assets not installed, downloading...", verbose: true)
do {
if let req = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) { try await req.downloadAndInstall() }
return true
} catch { fputs("Asset download failed: \(error)\n", stderr); return false }
case .downloading:
logv("Assets downloading, waiting up to 30s...", verbose: true)
for _ in 0..<30 {
try? await Task.sleep(nanoseconds: 1_000_000_000)
let s2 = await AssetInventory.status(forModules: [transcriber])
if s2 == .installed { return true }
}
return false
@unknown default: return true
}
}
// MARK: - Check
func runCheck() async {
print("{\"event\":\"check_start\"}")
let isAvailable = SpeechTranscriber.isAvailable
print("{\"event\":\"availability\",\"isAvailable\":\(isAvailable)}")
if !isAvailable { print("{\"event\":\"check_end\",\"ok\":false}"); return }
let supported = await SpeechTranscriber.supportedLocales
let installed = await SpeechTranscriber.installedLocales
let dict: [String: Any] = [
"event": "locales",
"supported": supported.map { $0.identifier }.sorted(),
"installed": installed.map { $0.identifier }.sorted(),
"supported_count": supported.count,
"installed_count": installed.count
]
if let d = try? JSONSerialization.data(withJSONObject: dict), let s = String(data: d, encoding: .utf8) { print(s) }
let t = SpeechTranscriber(locale: Locale(identifier: "en-US"), preset: .transcription)
let st = await AssetInventory.status(forModules: [t])
let str: String
switch st { case .unsupported: str="unsupported"; case .supported: str="supported"; case .downloading: str="downloading"; case .installed: str="installed"; @unknown default: str="unknown" }
print("{\"event\":\"asset_status\",\"locale\":\"en-US\",\"status\":\"\(str)\"}")
print("{\"event\":\"check_end\",\"ok\":true}")
}
func runListLocales() async {
if !SpeechTranscriber.isAvailable { fputs("Not available\n", stderr); exit(1) }
for loc in (await SpeechTranscriber.supportedLocales).sorted(by: { $0.identifier < $1.identifier }) { print(loc.identifier) }
}
// MARK: - File transcription (fixed)
struct SegmentJSON: Codable {
var text: String
var isFinal: Bool
var start: Double?
var duration: Double?
var alternatives: [String]
}
func runFile(filePath: String, localeId: String, includeVolatile: Bool, bench: Bool, verbose: Bool) async {
guard FileManager.default.fileExists(atPath: filePath) else { fputs("File not found: \(filePath)\n", stderr); exit(1) }
guard SpeechTranscriber.isAvailable else { fputs("Not available\n", stderr); exit(1) }
let locale = await resolveLocale(localeId)
logv("Locale resolved: \(locale.identifier) (req \(localeId))", verbose: verbose)
let finalTranscriber: SpeechTranscriber = {
if bench { return makeTranscriber(locale: locale, includeVolatile: false) }
return makeTranscriber(locale: locale, includeVolatile: includeVolatile)
}()
guard await ensureAssets(finalTranscriber, verbose: verbose) else { exit(2) }
let fileURL = URL(fileURLWithPath: filePath)
let audioFile: AVAudioFile
do { audioFile = try AVAudioFile(forReading: fileURL) } catch { fputs("Open failed: \(error)\n", stderr); exit(4) }
let audioDuration = Double(audioFile.length) / audioFile.fileFormat.sampleRate
logv("File: \(filePath) sr=\(audioFile.fileFormat.sampleRate) ch=\(audioFile.fileFormat.channelCount) dur=\(String(format: "%.2f", audioDuration))s", verbose: verbose)
// Use init(inputAudioFile:finishAfterFile:true) which is the path that emits volatile correctly
// and automatically handles format.
let analyzer: SpeechAnalyzer
do {
analyzer = try await SpeechAnalyzer(inputAudioFile: audioFile, modules: [finalTranscriber], finishAfterFile: true)
} catch { fputs("Analyzer init failed: \(error)\n", stderr); exit(5) }
var segments: [SegmentJSON] = []
var volatileCount = 0
var finalCount = 0
let t0 = Date()
// Consume results; loop ends naturally when analyzer finishes file
do {
for try await result in finalTranscriber.results {
let text = String(result.text.characters)
if text.isEmpty { continue }
let isF = result.isFinal
if isF { finalCount += 1 } else { volatileCount += 1 }
let seg = SegmentJSON(
text: text,
isFinal: isF,
start: result.range.start.seconds,
duration: result.range.duration.seconds,
alternatives: result.alternatives.map { String($0.characters) }
)
if bench {
if isF { segments.append(seg) }
} else {
if !includeVolatile && !isF { continue }
segments.append(seg)
if let data = try? JSONEncoder().encode(seg), let line = String(data: data, encoding: .utf8) {
print(line); fflush(stdout)
}
}
}
} catch {
fputs("Results error: \(error)\n", stderr)
}
// Ensure analyzer is done (it should be since results ended)
_ = analyzer // keep alive
let elapsed = Date().timeIntervalSince(t0)
let rtf = audioDuration / max(elapsed, 0.001)
if bench {
let fullText = segments.filter { $0.isFinal }.map { $0.text }.joined(separator: " ")
let out: [String: Any] = [
"file": filePath,
"locale": locale.identifier,
"requested_locale": localeId,
"audio_duration_sec": audioDuration,
"processing_sec": elapsed,
"rtf": rtf,
"volatile_count": volatileCount,
"final_count": finalCount,
"text": fullText,
"segments": segments.map { ["text": $0.text, "start": $0.start ?? 0, "duration": $0.duration ?? 0] }
]
if let data = try? JSONSerialization.data(withJSONObject: out, options: [.prettyPrinted, .sortedKeys]),
let str = String(data: data, encoding: .utf8) { print(str) }
} else {
if verbose {
fputs("Done: \(finalCount) final / \(volatileCount) volatile, RTF=\(String(format: "%.1f", rtf))x elapsed=\(String(format: "%.2f", elapsed))s\n", stderr)
}
}
}
// MARK: - Live (AnalyzerInput)
actor AudioBufferQueue {
private var buffers: [AVAudioPCMBuffer] = []
private var conts: [CheckedContinuation<AVAudioPCMBuffer?, Never>] = []
private var finished = false
func enqueue(_ b: AVAudioPCMBuffer) {
if let c = conts.first { conts.removeFirst(); c.resume(returning: b) } else { buffers.append(b) }
}
func dequeue() async -> AVAudioPCMBuffer? {
if !buffers.isEmpty { return buffers.removeFirst() }
if finished { return nil }
return await withCheckedContinuation { conts.append($0) }
}
func finish() { finished = true; for c in conts { c.resume(returning: nil) }; conts.removeAll() }
}
struct LiveInputSequence: AsyncSequence, Sendable {
let queue: AudioBufferQueue
struct AsyncIterator: AsyncIteratorProtocol {
let queue: AudioBufferQueue
mutating func next() async -> AnalyzerInput? {
guard let buf = await queue.dequeue() else { return nil }
return AnalyzerInput(buffer: buf)
}
}
func makeAsyncIterator() -> AsyncIterator { AsyncIterator(queue: queue) }
typealias Element = AnalyzerInput
}
func runLive(localeId: String, verbose: Bool) async {
guard SpeechTranscriber.isAvailable else { fputs("Not available\n", stderr); exit(1) }
let locale = await resolveLocale(localeId)
logv("Live locale \(locale.identifier) (req \(localeId))", verbose: verbose)
let transcriber = SpeechTranscriber(locale: locale, transcriptionOptions: [], reportingOptions: [.volatileResults, .alternativeTranscriptions], attributeOptions: [.audioTimeRange, .transcriptionConfidence])
guard await ensureAssets(transcriber, verbose: verbose) else { exit(2) }
let engine = AVAudioEngine()
let inputNode = engine.inputNode
let inputFormat = inputNode.inputFormat(forBus: 0)
logv("Mic format: \(inputFormat)", verbose: verbose)
let queue = AudioBufferQueue()
let inputSequence = LiveInputSequence(queue: queue)
let analyzer = SpeechAnalyzer(inputSequence: inputSequence, modules: [transcriber])
let targetFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriber], considering: inputFormat)
logv("Target format: \(String(describing: targetFormat))", verbose: verbose)
let converter: AVAudioConverter? = {
guard let tf = targetFormat, tf != inputFormat else { return nil }
return AVAudioConverter(from: inputFormat, to: tf)
}()
var shouldStop = false
let sig = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
signal(SIGINT, SIG_IGN)
sig.setEventHandler { fputs("\nStopping...\n", stderr); shouldStop = true; Task { await queue.finish() } }
sig.resume()
let analyzerTask = Task { do { try await analyzer.start(inputSequence: inputSequence) } catch { fputs("Analyzer err: \(error)\n", stderr) } }
let resultsTask = Task {
do {
for try await result in transcriber.results {
let dict: [String: Any] = [
"text": String(result.text.characters),
"isFinal": result.isFinal,
"start": result.range.start.seconds,
"duration": result.range.duration.seconds,
"alternatives": result.alternatives.map { String($0.characters) }
]
if let d = try? JSONSerialization.data(withJSONObject: dict), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
}
} catch { fputs("Results err: \(error)\n", stderr) }
}
if let tf = targetFormat, let conv = converter {
inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { buffer, _ in
if shouldStop { return }
let cap = AVAudioFrameCount(Double(buffer.frameLength) * tf.sampleRate / inputFormat.sampleRate + 10)
guard let out = AVAudioPCMBuffer(pcmFormat: tf, frameCapacity: cap) else { return }
var err: NSError?
let block: AVAudioConverterInputBlock = { _, outStatus in outStatus.pointee = .haveData; return buffer }
conv.convert(to: out, error: &err, withInputFrom: block)
if err == nil && out.frameLength > 0 { Task { await queue.enqueue(out) } }
}
} else {
inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { buffer, _ in
if shouldStop { return }
guard let copy = AVAudioPCMBuffer(pcmFormat: buffer.format, frameCapacity: buffer.frameCapacity) else { return }
copy.frameLength = buffer.frameLength
if let src = buffer.floatChannelData, let dst = copy.floatChannelData {
for ch in 0..<Int(buffer.format.channelCount) { memcpy(dst[ch], src[ch], Int(buffer.frameLength) * MemoryLayout<Float>.size) }
}
Task { await queue.enqueue(copy) }
}
}
engine.prepare()
do { try engine.start() } catch { fputs("Audio engine start failed: \(error)\n", stderr); exit(6) }
logv("Listening... Ctrl-C to stop", verbose: true)
while !shouldStop { try? await Task.sleep(nanoseconds: 100_000_000) }
inputNode.removeTap(onBus: 0)
engine.stop()
await queue.finish()
analyzerTask.cancel()
resultsTask.cancel()
}
@main
struct AppleSpeechCLI {
static func main() async {
let cli = parseArgs()
switch cli.mode {
case .check: await runCheck()
case .listLocales: await runListLocales()
case .file:
guard let fp = cli.filePath else {
fputs("Usage: apple-speech-transcribe <file> [--locale en-US] [-v] [--include-volatile]\n", stderr); exit(1)
}
await runFile(filePath: fp, localeId: cli.locale, includeVolatile: cli.includeVolatile, bench: false, verbose: cli.verbose)
case .bench:
guard let fp = cli.filePath else { fputs("Usage: --bench <file> [--locale]\n", stderr); exit(1) }
await runFile(filePath: fp, localeId: cli.locale, includeVolatile: false, bench: true, verbose: cli.verbose)
case .live: await runLive(localeId: cli.locale, verbose: cli.verbose)
case .pipe: await runPipe(localeId: cli.locale, verbose: cli.verbose)
}
}
}
// MARK: - Pipe (stdin wav chunks) for draft streaming from Python VAD
actor WavChunkQueue {
private var chunks: [URL] = []
private var conts: [CheckedContinuation<URL?, Never>] = []
private var finished = false
func enqueue(_ url: URL) {
if let c = conts.first { conts.removeFirst(); c.resume(returning: url) } else { chunks.append(url) }
}
func dequeue() async -> URL? {
if !chunks.isEmpty { return chunks.removeFirst() }
if finished { return nil }
return await withCheckedContinuation { conts.append($0) }
}
func finish() { finished = true; for c in conts { c.resume(returning: nil) }; conts.removeAll() }
}
func runPipe(localeId: String, verbose: Bool) async {
guard SpeechTranscriber.isAvailable else { fputs("Not available\n", stderr); exit(1) }
let locale = await resolveLocale(localeId)
let warm = SpeechTranscriber(locale: locale, transcriptionOptions: [], reportingOptions: [.volatileResults], attributeOptions: [])
guard await ensureAssets(warm, verbose: verbose) else { exit(2) }
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)
// first consume leftover
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 {
// EOF? try one more small sleep then retry once if we have partial
if out.count == 0 { return nil }
// partial -> return nil -> broken stream
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("Pipe: chunk too large \(length)\n", stderr); break }
guard let chunkData = readExact(Int(length)) else {
fputs("Pipe: 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("Pipe write err: \(error)\n", stderr); continue }
guard let audioFile = try? AVAudioFile(forReading: tmpURL) else {
try? FileManager.default.removeItem(at: tmpURL)
fputs("Pipe 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("Pipe results err \(chunkIdx): \(error)\n", stderr) }
_ = analyzer
try? FileManager.default.removeItem(at: tmpURL)
}
logv("Pipe done \(chunkIdx) chunks", verbose: true)
}
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
SRC="Sources/AppleSpeechCLI/main.swift"
OUT=".build/release/apple-speech-transcribe"
mkdir -p .build/release
# Use swiftc directly with macOS 26.5 SDK
# Swift 6.3.3 supports typed throws, Speech new API compiles fine with swiftc
echo "Compiling apple-speech-transcribe with swiftc..."
swiftc -O -parse-as-library \
-target arm64-apple-macosx26.0 \
-sdk /Library/Developer/CommandLineTools/SDKs/MacOSX26.5.sdk \
"$SRC" -o "$OUT" \
-framework Speech -framework AVFoundation -framework Foundation
echo "Built: $OUT"
ls -lh "$OUT"
"$OUT" --check 2>&1 || true
# Also copy to project root for python use
cp "$OUT" ../apple-speech-transcribe 2>/dev/null || cp "$OUT" ./apple-speech-transcribe
echo "Copied to ../apple-speech-transcribe and ./apple-speech-transcribe"