makepad/libs/tts/swift/tts_bridge.swift
Admin 449f51be7a gamemaker prerequisites: makepad-tts library, TTS/whisper model downloader, aigame design docs
libs/tts was never tracked despite being a build dependency of the gamemaker
example. tools/download_tts.sh fetches the public upstream weights (HuggingFace
Kokoro-82M + whisper.cpp) and converts them locally with the in-repo stdlib-only
converter; model artifacts are gitignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:29:22 +02:00

98 lines
3.4 KiB
Swift

import AVFoundation
import Foundation
/// Accumulates the PCM the synthesizer hands back, buffer by buffer.
private final class Rendered {
var samples: [Float] = []
var sampleRate: Double = 0
}
/// Render `text` to mono float PCM and return an owned buffer.
///
/// Returns null on failure. The caller must release the result with
/// `apple_tts_free`. Unlike `AVSpeechSynthesizer.speak`, this never touches an
/// output device the samples go back to Rust so Makepad's audio output owns
/// playback.
@_cdecl("apple_tts_synthesize")
public func apple_tts_synthesize(
_ text: UnsafePointer<CChar>,
_ voice: UnsafePointer<CChar>?,
_ rate: Float,
_ outLen: UnsafeMutablePointer<Int32>,
_ outRate: UnsafeMutablePointer<Float>
) -> UnsafeMutablePointer<Float>? {
outLen.pointee = 0
outRate.pointee = 0
let string = String(cString: text)
if string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return nil
}
let utterance = AVSpeechUtterance(string: string)
if let voice, let selected = AVSpeechSynthesisVoice(identifier: String(cString: voice)) {
utterance.voice = selected
} else {
utterance.voice = AVSpeechSynthesisVoice(language: "en-US")
}
if rate > 0 {
utterance.rate = rate
}
let synthesizer = AVSpeechSynthesizer()
let rendered = Rendered()
let finished = DispatchSemaphore(value: 0)
var signalled = false
// Buffers arrive on an internal queue; a zero-length buffer terminates the run.
synthesizer.write(utterance) { buffer in
guard let pcm = buffer as? AVAudioPCMBuffer else { return }
let frames = Int(pcm.frameLength)
if frames == 0 {
if !signalled {
signalled = true
finished.signal()
}
return
}
rendered.sampleRate = pcm.format.sampleRate
if let channels = pcm.floatChannelData {
rendered.samples.append(contentsOf: UnsafeBufferPointer(start: channels[0], count: frames))
} else if let channels = pcm.int16ChannelData {
let source = UnsafeBufferPointer(start: channels[0], count: frames)
rendered.samples.append(contentsOf: source.map { Float($0) / 32768.0 })
}
}
// `write` delivers its buffers through the main run loop. Blocking the main
// thread on the semaphore therefore deadlocks and yields zero buffers so
// pump the run loop when we are on it, and only block when we are not.
if Thread.isMainThread {
let deadline = Date().addingTimeInterval(30)
while !signalled, Date() < deadline {
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.02))
}
} else {
_ = finished.wait(timeout: .now() + 30)
}
// The synthesizer must outlive its callbacks.
withExtendedLifetime(synthesizer) {}
if rendered.samples.isEmpty || rendered.sampleRate <= 0 {
return nil
}
let count = rendered.samples.count
let out = UnsafeMutablePointer<Float>.allocate(capacity: count)
rendered.samples.withUnsafeBufferPointer { source in
out.initialize(from: source.baseAddress!, count: count)
}
outLen.pointee = Int32(count)
outRate.pointee = Float(rendered.sampleRate)
return out
}
@_cdecl("apple_tts_free")
public func apple_tts_free(_ ptr: UnsafeMutablePointer<Float>?) {
ptr?.deallocate()
}