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:
@@ -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)
|
||||
}
|
||||
Executable
+23
@@ -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"
|
||||
Reference in New Issue
Block a user