Apps ask the hub for a recognizer or a voice and get one; where it runs is the hub's decision. AiHub::start_stt / start_tts return poll-driven sessions shaped like the chat session. The Auto ladder is Whisper/Kokoro in this process (weights present, machine election), on the machine node over loopback, on a LAN node, else the OS engine; SpeechReach::Local is the "don't reach out" knob. Audio always comes back as PCM: the app owns the device. Three layers: - makepad-ai-speech is the whole speech model family, engines only. libs/voice (Whisper + Silero VAD) folds in as the `whisper` and `vad` modules next to kokoro and indextts, each a cargo feature; the Apple bridges and the Speaker/VoiceTranscriber selection leave it. - makepad-system-speech (new) is the OS speech services as blocking fns: Apple SpeechAnalyzer/AVSpeechSynthesizer via Swift, Windows.Media.Speech* on the vendored bindings, Android SpeechRecognizer/TextToSpeech through MakepadSpeech.java (API 26 floor), espeak-ng on Linux. It models the two STT shapes honestly: PCM in (Whisper, Apple) versus an engine that owns the microphone (Android, Windows), with capabilities the caller reads. - the hub grows speech sessions, in-process Whisper/Kokoro workers with the residency election, a `whisper` wire backend (stt domain, registry entry pinned to ggerganov/whisper.cpp) so a Mac can serve a Quest, and a `language` field on the generate request. Consumers: the Window voice input runs on an STT session and switches to engine-mic mode when the recognizer owns the microphone; converse's SpeechOutput is a lazily started TTS session plus a pump thread; route drops its private speech copy for converse; vj's lyrics fallback and the alignment bakes call the engines directly. Verified here: speech-roundtrip through the real sessions (Apple voice in, in-process Whisper on Metal out, 4.3% WER); system-speech-test TTS->STT verbatim; hub/converse/system-speech unit tests; msvc, aarch64-android and linux-gnu cross-checks; Java against android-34. Windows, Android and Linux bridges are compile-checked only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
168 lines
7 KiB
Swift
168 lines
7 KiB
Swift
import Foundation
|
|
import Speech
|
|
import AVFoundation
|
|
import CoreMedia
|
|
|
|
// The STT half of makepad-system-speech's Apple bridge: SpeechAnalyzer (macOS 26 /
|
|
// iOS 26) over caller-recorded 16 kHz mono PCM. Symbols are prefixed `mss_` so
|
|
// this static library can coexist with any other Swift bridge in the binary.
|
|
|
|
struct MssSegment {
|
|
var text: UnsafeMutablePointer<CChar>?
|
|
var start_ms: Int64
|
|
var end_ms: Int64
|
|
}
|
|
|
|
func mssResolveLocale(_ tag: String) -> Locale {
|
|
let defaults: [String: String] = [
|
|
"en": "en-US", "fr": "fr-FR", "de": "de-DE", "es": "es-ES",
|
|
"it": "it-IT", "pt": "pt-BR", "nl": "nl-NL", "zh": "zh-CN",
|
|
"ja": "ja-JP", "ko": "ko-KR", "ru": "ru-RU", "yue": "yue-CN",
|
|
]
|
|
return Locale(identifier: defaults[tag] ?? tag.replacingOccurrences(of: "_", with: "-"))
|
|
}
|
|
|
|
func mssRunAsyncSync<T: Sendable>(_ body: @escaping @Sendable () async throws -> T) throws -> T {
|
|
let sem = DispatchSemaphore(value: 0)
|
|
let box = UnsafeMutablePointer<Result<T, Error>>.allocate(capacity: 1)
|
|
box.initialize(to: .failure(NSError(domain: "uninit", code: 0)))
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
Task {
|
|
do { box.pointee = .success(try await body()) }
|
|
catch { box.pointee = .failure(error) }
|
|
sem.signal()
|
|
}
|
|
}
|
|
sem.wait()
|
|
let r = box.pointee
|
|
box.deallocate()
|
|
switch r {
|
|
case .success(let v): return v
|
|
case .failure(let e): throw e
|
|
}
|
|
}
|
|
|
|
/// Recognize `sampleCount` floats of 16 kHz mono PCM. Returns 0 and an owned
|
|
/// array of `MssSegment` (release with `mss_stt_free_segments`), or a
|
|
/// negative code: -1 recognition failed, -2 locale unsupported.
|
|
@_cdecl("mss_stt_transcribe")
|
|
func mss_stt_transcribe(
|
|
_ samples: UnsafePointer<Float>,
|
|
_ sampleCount: Int64,
|
|
_ lang: UnsafePointer<CChar>,
|
|
_ wantTimestamps: Int32,
|
|
_ outCount: UnsafeMutablePointer<Int32>,
|
|
_ outSegments: UnsafeMutablePointer<OpaquePointer?>
|
|
) -> Int32 {
|
|
let requestedLocale = mssResolveLocale(String(cString: lang))
|
|
let count = Int(sampleCount)
|
|
outCount.pointee = 0
|
|
outSegments.pointee = nil
|
|
if count == 0 { return 0 }
|
|
|
|
let samplesCopy = Array(UnsafeBufferPointer(start: samples, count: count))
|
|
|
|
do {
|
|
let segments: [(String, Int64, Int64)] = try mssRunAsyncSync {
|
|
let locale = await SpeechTranscriber.supportedLocale(equivalentTo: requestedLocale)
|
|
?? requestedLocale
|
|
let preset = SpeechTranscriber.Preset(
|
|
transcriptionOptions: [],
|
|
reportingOptions: [],
|
|
attributeOptions: wantTimestamps != 0 ? [.audioTimeRange] : []
|
|
)
|
|
let transcriber = SpeechTranscriber(locale: locale, preset: preset)
|
|
let analyzer = SpeechAnalyzer(modules: [transcriber])
|
|
|
|
let inputFormat = AVAudioFormat(
|
|
commonFormat: .pcmFormatFloat32,
|
|
sampleRate: 16000, channels: 1, interleaved: false
|
|
)!
|
|
|
|
// SpeechAnalyzer's file entry point is the one that finishes
|
|
// deterministically after the last sample; write the PCM to a
|
|
// temporary CAF and hand it that.
|
|
let tempURL = URL(fileURLWithPath: NSTemporaryDirectory())
|
|
.appendingPathComponent("makepad_mss_\(UUID().uuidString).caf")
|
|
defer { try? FileManager.default.removeItem(at: tempURL) }
|
|
|
|
let outFile = try AVAudioFile(forWriting: tempURL, settings: inputFormat.settings)
|
|
let allFrames = AVAudioFrameCount(count)
|
|
let outBuf = AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: allFrames)!
|
|
outBuf.frameLength = allFrames
|
|
samplesCopy.withUnsafeBufferPointer { buf in
|
|
memcpy(outBuf.floatChannelData![0], buf.baseAddress!, count * MemoryLayout<Float>.size)
|
|
}
|
|
try outFile.write(from: outBuf)
|
|
|
|
let inFile = try AVAudioFile(forReading: tempURL)
|
|
try await analyzer.start(inputAudioFile: inFile, finishAfterFile: true)
|
|
var segs: [(String, Int64, Int64)] = []
|
|
for try await response in transcriber.results {
|
|
let text = String(response.text.characters)
|
|
if response.isFinal {
|
|
let range = response.range
|
|
let startMs = Int64(CMTimeGetSeconds(range.start) * 1000)
|
|
let endMs = Int64(CMTimeGetSeconds(CMTimeAdd(range.start, range.duration)) * 1000)
|
|
if !text.isEmpty { segs.append((text, startMs, endMs)) }
|
|
}
|
|
}
|
|
return segs
|
|
}
|
|
|
|
outCount.pointee = Int32(segments.count)
|
|
if segments.isEmpty { return 0 }
|
|
let ptr = UnsafeMutablePointer<MssSegment>.allocate(capacity: segments.count)
|
|
for (i, (text, startMs, endMs)) in segments.enumerated() {
|
|
ptr[i] = MssSegment(text: strdup(text), start_ms: startMs, end_ms: endMs)
|
|
}
|
|
outSegments.pointee = OpaquePointer(ptr)
|
|
return 0
|
|
} catch {
|
|
NSLog("[makepad-system-speech] stt transcribe: %@", error.localizedDescription)
|
|
return -1
|
|
}
|
|
}
|
|
|
|
@_cdecl("mss_stt_free_segments")
|
|
func mss_stt_free_segments(_ ptr: OpaquePointer?, _ count: Int32) {
|
|
guard let rawPtr = ptr else { return }
|
|
let typed = UnsafeMutablePointer<MssSegment>(rawPtr)
|
|
for i in 0..<Int(count) {
|
|
if let text = typed[i].text { free(text) }
|
|
}
|
|
typed.deallocate()
|
|
}
|
|
|
|
/// Make sure the on-device model for `lang` is installed (downloading it if
|
|
/// needed). 0 ready, -2 locale unsupported, -1 other failure.
|
|
@_cdecl("mss_stt_prepare")
|
|
func mss_stt_prepare(_ lang: UnsafePointer<CChar>) -> Int32 {
|
|
let requestedLocale = mssResolveLocale(String(cString: lang))
|
|
do {
|
|
try mssRunAsyncSync {
|
|
let locale = await SpeechTranscriber.supportedLocale(equivalentTo: requestedLocale)
|
|
?? requestedLocale
|
|
let supported = await SpeechTranscriber.supportedLocales
|
|
let bcp47 = locale.identifier(.bcp47)
|
|
guard supported.contains(where: { $0.identifier(.bcp47) == bcp47 }) else {
|
|
throw NSError(domain: "mss", code: -2,
|
|
userInfo: [NSLocalizedDescriptionKey: "Unsupported: \(bcp47)"])
|
|
}
|
|
let installed = await SpeechTranscriber.installedLocales
|
|
if !installed.contains(where: { $0.identifier(.bcp47) == bcp47 }) {
|
|
let preset = SpeechTranscriber.Preset(
|
|
transcriptionOptions: [], reportingOptions: [], attributeOptions: [])
|
|
let t = SpeechTranscriber(locale: locale, preset: preset)
|
|
if let dl = try await AssetInventory.assetInstallationRequest(supporting: [t]) {
|
|
try await dl.downloadAndInstall()
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
} catch let e as NSError where e.code == -2 { return -2 }
|
|
catch {
|
|
NSLog("[makepad-system-speech] stt prepare: %@", error.localizedDescription)
|
|
return -1
|
|
}
|
|
}
|