API & Protocols
Native Apple runtime · PocketTTS
PocketTTS adds native CoreML speech synthesis to speech-swift. All stages default to CPU-only execution. The English Alba preset streams 1,920-sample frames at 24 kHz (80 ms per frame).
import PocketTTS
let model = try PocketTTSModel.fromBundle(URL(fileURLWithPath: "/path/to/bundle"))
try await model.generateStreaming(text: "Hello world.") { chunk in
await consume(chunk.samples)
}The AudioCommon module defines model-agnostic protocols and shared types. Any conforming model can be used interchangeably through these interfaces.
Protocol Overview
┌─────────────────────────────────────────────────────────┐
│ AudioCommon │
│ │
│ AudioChunk SpeechGenerationModel (TTS) │
│ AlignedWord SpeechRecognitionModel (STT) │
│ SpeechSegment ForcedAlignmentModel │
│ SpeechToSpeechModel │
│ VoiceActivityDetectionModel (VAD) │
│ TurnCompletionProvider (EOT) │
│ SpeakerEmbeddingModel │
│ SpeakerDiarizationModel │
│ SpeakerExtractionCapable │
└─────────────────────────────────────────────────────────┘SpeechRecognitionModel
Protocol for speech-to-text models.
public protocol SpeechRecognitionModel: AnyObject {
var inputSampleRate: Int { get }
func transcribe(audio: [Float], sampleRate: Int, language: String?) -> String
func transcribeWithLanguage(audio: [Float], sampleRate: Int, language: String?) -> TranscriptionResult
}
Conforming types: Qwen3ASRModel, WhisperASRModel, ParakeetASRModel, ParakeetStreamingASRModel, OmnilingualASRModel (CoreML), OmnilingualASRMLXModel (MLX)
SpeechGenerationModel
Protocol for text-to-speech models.
public protocol SpeechGenerationModel: AnyObject {
var sampleRate: Int { get }
func generate(text: String, language: String?) async throws -> [Float]
func generateStream(text: String, language: String?) -> AsyncThrowingStream<AudioChunk, Error> // has default impl
}
generateStream() has a default implementation that wraps generate() as a single chunk. Models with true streaming (e.g. Qwen3-TTS) override it.
Conforming types: Qwen3TTSModel, CosyVoiceTTSModel, VoxCPM2TTSModel, KokoroTTSModel, IndexTTS2TTSModel
IndexTTS2TTSModel adds a reference-audio generate overload for zero-shot voice cloning and uses IndexTTS2SynthesisOptions for speaking-rate and pause controls.
Qwen3-TTS local loading
fromLocal validates required files and checkpoint shard completeness, then derives model size, quantization, and architecture from config.json. The optional configuration value is only a fallback for omitted metadata; malformed or contradictory metadata throws Qwen3TTSLoadingError.invalidConfiguration before model allocation.
public static func fromLocal(
modelDirectory: URL,
tokenizerDirectory: URL,
configuration: Qwen3TTSConfig? = nil,
wiredMemoryPolicy: Qwen3TTSWiredMemoryPolicy = .none,
progressHandler: ((Double, String) -> Void)? = nil
) throws -> Qwen3TTSModel
public static func fromPretrained(
modelId: String = Qwen3TTSModel.defaultModelId,
tokenizerModelId: String = "Qwen/Qwen3-TTS-Tokenizer-12Hz",
cacheDir: URL? = nil,
tokenizerCacheDir: URL? = nil,
offlineMode: Bool = false,
wiredMemoryPolicy: Qwen3TTSWiredMemoryPolicy = .pin(fraction: 0.9),
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> Qwen3TTSModel
fromLocal defaults to .none, so it does not change the process-wide Metal wired-memory limit. fromPretrained keeps its .pin(fraction: 0.9) default. Both pretrained loaders accept a separate tokenizerCacheDir.
ForcedAlignmentModel
Protocol for word-level timestamp alignment.
public protocol ForcedAlignmentModel: AnyObject {
func align(audio: [Float], text: String, sampleRate: Int, language: String?) -> [AlignedWord]
}
SpeechToSpeechModel
Protocol for speech-to-speech dialogue models.
public protocol SpeechToSpeechModel: AnyObject {
var sampleRate: Int { get }
func respond(userAudio: [Float]) -> [Float]
func respondStream(userAudio: [Float]) -> AsyncThrowingStream<AudioChunk, Error>
}
Conforming types: PersonaPlexModel
HibikiTranslateModel
Speech-to-speech translation driver. Doesn't conform to SpeechToSpeechModel because the contract is different: input and output are different languages, output length is variable (EOS-driven, capped at max(tSrc * 5/2, tSrc + 20) steps — roughly 1.0–1.6× source duration on FLEURS-style clips), and there's a parallel text-token stream alongside the audio. Full guide →
public final class HibikiTranslateModel {
public let cfg: HibikiConfig
public static var defaultModelId: String { "aufklarer/Hibiki-Zero-3B-MLX-4bit" }
public static var modelId8bit: String { "aufklarer/Hibiki-Zero-3B-MLX-8bit" }
public static func fromPretrained(
modelId: String = defaultModelId,
cacheDir: URL? = nil,
offlineMode: Bool = false,
progressHandler: ((Double, String) -> Void)? = nil
) async throws -> HibikiTranslateModel
public func warmUp()
public func translate(
sourceAudio: [Float],
sourceLanguage: HibikiSourceLanguage = .fr,
verbose: Bool = false
) -> (audio: [Float], textTokens: [Int32])
public func translateStream(
sourceAudio: [Float],
sourceLanguage: HibikiSourceLanguage = .fr,
streaming: HibikiStreamingConfig = .default,
verbose: Bool = false
) -> AsyncThrowingStream<AudioChunk, Error>
}
public enum HibikiSourceLanguage: String, CaseIterable, Sendable, Codable {
case fr, es, pt, de
}
Sample rate: 24 kHz mono, both input and output. Frame rate: 12.5 Hz (Mimi codec). Source languages: FR / ES / PT / DE. Target language: EN. Set HIBIKI_GREEDY=1 at process launch for deterministic argmax decoding (matches the CI canaries).
VoiceActivityDetectionModel
Protocol for voice activity detection.
public protocol VoiceActivityDetectionModel: AnyObject {
var inputSampleRate: Int { get }
func detectSpeech(audio: [Float], sampleRate: Int) -> [SpeechSegment]
}
TurnCompletionProvider
Protocol for end-of-turn classifiers consulted by StreamingVADProcessor on every confirmed VAD pause. A VAD only hears silence; a turn-completion model listens to the prosody of the whole utterance, so a mid-sentence pause keeps the agent waiting while a finished sentence gets an immediate reply. Maps to speech-core's sc_turn_completion_vtable_t. See the Smart Turn guide.
public protocol TurnCompletionProvider: AnyObject {
/// Probability in [0, 1] that the turn is complete, given the audio of the
/// turn so far (Smart Turn looks at the last 8 s).
func turnCompleteProbability(audio: [Float], sampleRate: Int) throws -> Float
}
Conforming types: SmartTurnModel (Smart Turn v3.2)
SpeakerEmbeddingModel
Protocol for speaker embedding extraction.
public protocol SpeakerEmbeddingModel: AnyObject {
var inputSampleRate: Int { get }
var embeddingDimension: Int { get }
func embed(audio: [Float], sampleRate: Int) -> [Float]
}
Conforming types: WeSpeakerModel
SpeakerDiarizationModel
Protocol for speaker diarization models that assign speaker labels to audio segments.
public protocol SpeakerDiarizationModel: AnyObject {
var inputSampleRate: Int { get }
func diarize(audio: [Float], sampleRate: Int) -> [DiarizedSegment]
}
Conforming types: DiarizationPipeline (Pyannote), SortformerDiarizer
SpeakerExtractionCapable
Extended diarization protocol for engines that support extracting a target speaker's segments using a reference embedding. Not all engines support this (Sortformer is end-to-end and does not produce speaker embeddings).
public protocol SpeakerExtractionCapable: SpeakerDiarizationModel {
func extractSpeaker(audio: [Float], sampleRate: Int, targetEmbedding: [Float]) -> [SpeechSegment]
}
Conforming types: DiarizationPipeline (Pyannote only)
Shared Types
AudioChunk
public struct AudioChunk {
public let samples: [Float] // PCM samples
public let sampleRate: Int // Sample rate (e.g. 24000)
}
SpeechSegment
public struct SpeechSegment {
public let startTime: Float // Start time in seconds
public let endTime: Float // End time in seconds
}
AlignedWord
public struct AlignedWord {
public let text: String // The word
public let startTime: Float // Start time in seconds
public let endTime: Float // End time in seconds
}
DiarizedSegment
public struct DiarizedSegment {
public let startTime: Float // Start time in seconds
public let endTime: Float // End time in seconds
public let speakerId: Int // Speaker identifier (0-based)
}
DialogueSegment
A parsed segment of multi-speaker dialogue text with optional speaker and emotion tags. Used with DialogueParser and DialogueSynthesizer for CosyVoice3 dialogue synthesis.
public struct DialogueSegment: Sendable, Equatable {
public let speaker: String? // Speaker identifier ("S1", "S2"), nil for untagged
public let emotion: String? // Emotion tag ("happy", "whispers"), nil if none
public let text: String // Cleaned text to synthesize
}
DialogueParser
Parses multi-speaker dialogue text with inline speaker tags ([S1]) and emotion tags ((happy)).
public enum DialogueParser {
static func parse(_ text: String) -> [DialogueSegment]
static func emotionToInstruction(_ emotion: String) -> String
}
Built-in emotions: happy/excited, sad, angry, whispers/whispering, laughs/laughing, calm, surprised, serious. Unknown tags pass through as freeform instructions.
DialogueSynthesizer
Orchestrates multi-segment dialogue synthesis with per-speaker voice cloning, silence gaps, and crossfade.
public enum DialogueSynthesizer {
static func synthesize(
segments: [DialogueSegment],
speakerEmbeddings: [String: [Float]],
model: CosyVoiceTTSModel,
language: String,
config: DialogueSynthesisConfig,
verbose: Bool
) -> [Float]
}
DialogueSynthesisConfig
public struct DialogueSynthesisConfig: Sendable {
public var turnGapSeconds: Float // Default: 0.2
public var crossfadeSeconds: Float // Default: 0.0
public var defaultInstruction: String // Default: "You are a helpful assistant."
public var maxTokensPerSegment: Int // Default: 500
}
PipelineLLM
Protocol for language model integration with voice pipelines. Bridges an LLM to the VoicePipeline's ASR → LLM → TTS flow.
public protocol PipelineLLM: AnyObject {
func chat(messages: [(role: MessageRole, content: String)],
onToken: @escaping (String, Bool) -> Void)
func cancel()
}
Built-in adapter: Qwen3PipelineLLM bridges Qwen35MLXChat to this protocol with token cleanup, cancellation, and pending phrase accumulation.
AudioIO
Reusable audio I/O manager that eliminates AVAudioEngine boilerplate. Handles mic capture, resampling, playback, and audio level metering.
let audio = AudioIO()
try audio.startMicrophone(targetSampleRate: 16000) { samples in
pipeline.pushAudio(samples)
}
audio.player.scheduleChunk(ttsOutput)
audio.stopMicrophone()
AudioIO includes a StreamingAudioPlayer for TTS output and an AudioRingBuffer for thread-safe audio transfer between capture and inference threads.
SystemAudioTap
Captures the system output mix — what the Mac is currently playing — as mono Float32 at a caller-chosen sample rate (Core Audio process taps, macOS 14.4+). The mono global tap excludes the current process by default, so an app’s own playback is never re-captured, and it is wrapped in a private aggregate device containing only the tap.
let tap = SystemAudioTap()
try tap.start(targetSampleRate: 16000) { samples in
pipeline.pushAudio(samples)
}
tap.stop()
Capture survives default-output-device switches — the internal resampler is rebuilt when the mix rate changes. The host app must declare NSAudioCaptureUsageDescription. If the recording permission is denied, a tap can still be created yet deliver only silence: watch framesCaptured grow while nonSilentFrames stays 0 and fail closed.
Timestamped independent sources
Both capture classes expose timestamped overloads. CapturedAudioChunk carries mono samples at sampleRate plus an optional Mach hostTime for the first input frame. Because both sources use the same host clock, an app can keep their PCM separate and order derived transcript events without mixing the audio.
try tap.startTimestamped(targetSampleRate: 16000) { systemChunk in
systemPipeline.pushAudio(systemChunk.samples)
recordSystemTime(systemChunk.hostTime)
}
let microphone = AudioIO(enableAEC: true, enablePlayback: false)
try microphone.startMicrophoneTimestamped(targetSampleRate: 16000) { micChunk in
microphonePipeline.pushAudio(micChunk.samples)
recordMicrophoneTime(micChunk.hostTime)
}
For listen-only capture, enableAEC: true activates Apple Voice Processing before the microphone format is read, and enablePlayback: false omits the unused player. On macOS, device playback is removed from microphone samples and failure to start AEC is reported instead of falling back to raw input.
Incremental file input and recognition
CapturedAudioChunk also carries a monotonic frameIndex and an isFinal marker. AudioFileLoader.stream produces these chunks on demand with bounded memory, resamples once with persistent converter state, and averages all input channels by default. Use .first or .select([indices]) when channel routing is known.
StreamingRecognitionModel and StreamingRecognitionSession provide one cache-preserving incremental API across Parakeet Streaming and Nemotron Core ML/MLX:
let source = AudioFileLoader.stream(
url: inputURL,
options: AudioFileStreamOptions(targetSampleRate: 16_000,
chunkDuration: 0.32,
channelSelection: .mixAll))
let session = try model.makeStreamingSession(language: "en-US")
for try await chunk in source {
for update in try session.push(chunk) {
render(update.text, final: update.isFinal)
}
}
for update in try session.finish() {
render(update.text, final: true)
}
SentencePieceModel
Shared protobuf reader for SentencePiece .model files, lives in AudioCommon. Every module that needs to decode SentencePiece pieces (PersonaPlex, OmnilingualASR, future ASR / TTS ports) builds its own decoder on top of this single reader instead of re-implementing the protobuf wire format.
public struct SentencePieceModel: Sendable {
public struct Piece: Sendable, Equatable {
public let text: String
public let score: Float
public let type: Int32
public var pieceType: PieceType? { get }
public var isControlOrUnknown: Bool { get }
}
public enum PieceType: Int32 {
case normal = 1, unknown = 2, control = 3,
userDefined = 4, unused = 5, byte = 6
}
public let pieces: [Piece]
public var count: Int { get }
public subscript(_ id: Int) -> Piece? { get }
public init(contentsOf url: URL) throws
public init(modelPath: String) throws
public init(data: Data) throws
}
Used by: OmnilingualASR.OmnilingualVocabulary, PersonaPlex.SentencePieceDecoder. Covered by 7 unit tests in Tests/AudioCommonTests/SentencePieceModelTests.
MLXCommon.SDPA
Scaled dot-product attention helpers shared across every MLX attention module (Qwen3-ASR / Qwen3-TTS / Qwen3-Chat / CosyVoice / PersonaPlex / OmnilingualASR). Each module keeps its own projections — SDPA only handles the reshape → attention → merge boilerplate.
public enum SDPA {
// Flat [B, T, H*D] input: project/reshape happens inside
public static func multiHead(
q: MLXArray, k: MLXArray, v: MLXArray,
numHeads: Int, headDim: Int, scale: Float,
mask: MLXArray? = nil
) -> MLXArray
// GQA / MQA variant with separate query and KV head counts
public static func multiHead(
q: MLXArray, k: MLXArray, v: MLXArray,
numQueryHeads: Int, numKVHeads: Int, headDim: Int, scale: Float,
mask: MLXArray? = nil
) -> MLXArray
// Already-shaped [B, H, T, D] (RoPE / KV cache paths)
public static func attendAndMerge(
qHeads: MLXArray, kHeads: MLXArray, vHeads: MLXArray,
scale: Float,
mask: MLXArray? = nil
) -> MLXArray
// Same, with ScaledDotProductAttentionMaskMode enum (newer API)
public static func attendAndMerge(
qHeads: MLXArray, kHeads: MLXArray, vHeads: MLXArray,
scale: Float,
mask: MLXFast.ScaledDotProductAttentionMaskMode
) -> MLXArray
// Low-level head merge: [B, H, T, D] → [B, T, H*D]
public static func mergeHeads(_ attn: MLXArray) -> MLXArray
}
All reshape calls use -1 for the batch dimension so the helpers compose with MLX.compile(shapeless:) graphs that vary batch at runtime (e.g. Qwen3-TTS Talker autoregressive decode).
HTTP API Server
The speech-server binary exposes every model in speech-swift as HTTP REST endpoints plus a WebSocket endpoint that implements the OpenAI Realtime API. Models are loaded lazily on first request; pass --preload to warm them all at startup.
swift build -c release
.build/release/speech-server --port 8080
# Preload every model at startup
.build/release/speech-server --port 8080 --preload
REST Endpoints
| Endpoint | Method | Request | Response |
|---|---|---|---|
/transcribe | POST | audio/wav body | JSON { text } (Qwen3-ASR) |
/v1/audio/transcriptions | POST | Multipart { file, model, response_format?, language?, prompt?, temperature? } | JSON, text, verbose JSON, SRT, or VTT |
/v1/audio/speech | POST | JSON { model, input, voice, response_format?, speed? } | audio/wav or audio/pcm body |
/speak | POST | JSON { text, engine?, language?, voice? } | audio/wav body (Qwen3-TTS, CosyVoice, Kokoro) |
/respond | POST | audio/wav body | audio/wav body (PersonaPlex) |
/enhance | POST | audio/wav body | audio/wav body (DeepFilterNet3) |
/vad | POST | audio/wav body | JSON segment list |
/diarize | POST | audio/wav body | JSON DiarizedSegment list |
/embed-speaker | POST | audio/wav body | JSON [Float] (256-dim) |
/v1/audio/speech maps standard TTS model and voice names to local engines. It defaults to WAV; response_format: "pcm" returns headerless 24 kHz mono PCM16 little-endian audio. Unsupported compressed formats are rejected explicitly.
/v1/audio/transcriptions accepts one WAV file as multipart form data and defaults to {"text":"..."}; response_format can also request text, verbose JSON, SRT, or VTT. On Apple, model aliases select a local ASR engine. The packaged speech-core server on Linux and Windows uses lazily loaded Parakeet, accepts PCM16/24/32 or Float32 WAV, and caps uploads at 25 MiB and 10 minutes.
# Transcribe a file
curl -X POST http://localhost:8080/transcribe \
--data-binary @recording.wav \
-H "Content-Type: audio/wav"
# Synthesize speech
curl -X POST http://localhost:8080/speak \
-H "Content-Type: application/json" \
-d '{"text": "Hello world", "engine": "cosyvoice"}' \
-o output.wav
# OpenAI-compatible transcription
curl http://localhost:8080/v1/audio/transcriptions \
-F "[email protected];type=audio/wav" \
-F "model=whisper-1"
# OpenAI-compatible speech synthesis
curl http://localhost:8080/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"model":"tts-1","voice":"alloy","input":"Hello world","response_format":"wav"}' \
-o openai-output.wav
# Full speech-to-speech round trip
curl -X POST http://localhost:8080/respond \
--data-binary @question.wav \
-o response.wav
OpenAI Realtime API (/v1/realtime)
The WebSocket endpoint at ws://host:port/v1/realtime implements the OpenAI Realtime protocol. All messages are JSON with a type discriminator; audio payloads are base64-encoded PCM16 at 24 kHz mono.
During cold model loads or long generations, the server emits lightweight realtime.keepalive JSON events and websocket pong control frames roughly every 15 seconds until model output is ready. Clients can ignore these events or treat them as activity indicators.
Client → Server events
| Event | Purpose |
|---|---|
session.update | Configure engine, language, voice, audio format, and optional server VAD |
input_audio_buffer.append | Append a base64 PCM16 chunk to the input buffer |
input_audio_buffer.commit | Commit the buffered audio for transcription |
input_audio_buffer.clear | Discard the current input buffer |
response.create | Request TTS synthesis for the supplied text/instructions |
Server → Client events
| Event | Meaning |
|---|---|
session.created | Handshake complete, default config emitted |
session.updated | Most recent session.update acknowledged |
input_audio_buffer.speech_started | Server VAD detected the beginning of a speech turn |
input_audio_buffer.speech_stopped | Server VAD ended the turn after silence or the duration limit |
input_audio_buffer.committed | Audio accepted and queued for transcription |
conversation.item.input_audio_transcription.completed | ASR result with final transcript text |
response.audio.delta | Base64 PCM16 chunk of synthesized audio |
response.audio.done | No more audio chunks for this response |
response.done | Response finalized (metadata + latency stats) |
error | Error envelope with type and message |
Automatic turns with server VAD
Explicit input_audio_buffer.commit remains the default. To let the server close and transcribe turns automatically with Silero VAD, configure turn_detection. The server keeps only the requested pre-roll while idle, emits speech start/stop and commit events in order, and force-closes a continuously active turn at max_turn_duration_ms.
ws.send(JSON.stringify({
type: 'session.update',
session: {
turn_detection: {
type: 'server_vad',
threshold: 0.5,
prefix_padding_ms: 300,
silence_duration_ms: 500,
max_turn_duration_ms: 120000
}
}
}));
// Disable automatic turns and return to explicit commits:
// { type: 'session.update', session: { turn_detection: null } }
const ws = new WebSocket('ws://localhost:8080/v1/realtime');
// ASR: push audio, request transcription
ws.send(JSON.stringify({ type: 'input_audio_buffer.append', audio: base64PCM16 }));
ws.send(JSON.stringify({ type: 'input_audio_buffer.commit' }));
// → conversation.item.input_audio_transcription.completed
// TTS: request synthesis and stream audio deltas
ws.send(JSON.stringify({
type: 'response.create',
response: { modalities: ['audio', 'text'], instructions: 'Hello world' }
}));
// → response.audio.delta (repeated), response.audio.done, response.done
The server lives in the AudioServer SPM product. An example browser client is shipped at Examples/websocket-client.html — open it alongside a running server to drive the full ASR + TTS round trip.
Model Downloads
All models are downloaded from HuggingFace on first use and cached in ~/Library/Caches/qwen3-speech/. The AudioCommon module provides a shared HuggingFaceDownloader that handles download, caching, and integrity verification.
Download retries
Model loaders and download APIs accept downloadOptions: DownloadOptions. onRetry receives a DownloadRetryEvent before each backoff, with modelId, attempt, maxAttempts, the original error, and retryInSeconds. nil means no retries remain; complete-cache fallback can still succeed.
Callbacks are synchronous and @Sendable, with no main-actor guarantee. Cancellation produces no retry event. Set retryDelaysSeconds to customize the policy ([] means one attempt); the explicit download API argument takes precedence. Default attempt counts and byte progress are preserved. Failed attempts are also logged at notice level through AudioLog.download.
Cooperative cancellation
For MLX transcription, Qwen3ASRModel.transcribeCheckingCancellation(audio:sampleRate:options:) throws CancellationError when task cancellation is observed before feature extraction, encoding, decoder prefill, or a decoding step. GPU work already submitted can finish. The synchronous transcribe and transcribeBatch APIs retain their non-throwing behavior and ignore task cancellation. speech-server uses the cancellation-aware entry point for Qwen3-ASR.
MOSS-TTS-Nano
MossTTSNanoModel serializes requests and creates fresh CoreML state for each utterance. synthesize and synthesizeStream preserve stereo; SpeechGenerationModel returns a mono downmix. Pass language: nil. Streams end with an empty final marker.
import AudioCommon
import MossTTSNano
let model = try await MossTTSNanoModel.fromPretrained()
let reference = try await model.encodeReference(url: referenceURL)
let result = try await model.synthesize(
text: "The weather is nice today.", reference: reference,
options: MossTTSNanoOptions(maxFrames: 300, seed: 1))
try WAVWriter.writeStereo(left: result.left, right: result.right,
sampleRate: 48000, to: outputURL)Local native inference
For 12B, repeated adjacent tool-call closing markers immediately after a complete, validated call are consumed with that call. They do not create another call or change its arguments. Standalone markers and malformed arguments remain errors; E4B retains strict handling.
To retain failed native chat requests, set failureCaptureDirectory in the inference manifest. Capture is off by default. The directory must deny group and other access; new directories use 0700 and failure files use 0600. Files include the exact request body, request ID and error. Completed 12B generations also retain the rendered prompt, token IDs, raw output and stop reason before parsing. E4B failures have no raw generation record. These local files contain conversation and tool content; HTTP errors and successful requests do not include them.
Gemma4UnifiedChat.generateResponse accepts an optional onGeneration callback with a Gemma4GenerationTrace. The callback runs before tool parsing, including when that completion is rejected. Recording does not change sampling or accepted tool syntax.
Optional agentDirectory loads a staged Gemma 4 12B Unified INT4 text model alongside E4B. Use model: "gemma4-12b-it" for chat with native tools, result history, and cancellation. E4B remains available for other requests. Gemma4UnifiedChat uses the pinned MLX text implementation and releases each request’s KV cache. Response parsing removes channel delimiters from returned text. Orphaned tool-call terminators are rejected as malformed responses. For Gemma 4 12B, tool-message content preserves JSON as text, matching the checkpoint chat template.
Source builds accept --inference-manifest models.json to load staged Gemma 4 E4B INT4, VoxCPM2, full Whisper Large-v3, and Silero VAD. This service starts offline and rejects incompatible model identities.
VoxCPM2 supports weightPrecision: .float16 for an unquantized checkpoint. Its main parameters use FP16; AudioVAE and some computation remain FP32. INT8 checkpoints are rejected for this mode.
Gemma4ConversationMessage, Gemma4Tool, and generateResponse preserve native tool declarations, arguments, and result history. InferenceCancellationToken cancels generation cooperatively. Silero makeIndependentStream isolates state, and processChunkThrowing propagates inference errors.
WhisperASRModel.fromDirectory loads staged full Large-v3 CoreML bundles. The 32-layer decoder can build its prefix without TextDecoderContextPrefill.mlmodelc and releases per-step temporaries. The default Whisper CLI model remains Turbo.
The native service serializes chat, synthesis, and transcription; lightweight VAD has separate stream state. Gemma prefill uses 256-token batches, request KV state is released, and the MLX cache is capped at 128 MiB with a 20 GiB allocator limit.
Speech with stream: true returns NDJSON audio events at 48 kHz, followed by done or error. Cancel by requestID at /v1/inference/cancel. GET /v1/inference/capabilities and /v1/inference/provenance report model identities, parameter dtypes, asset pins, and memory counters.
speech-server --inference-manifest models.json --port 8080POST /v1/inference
{"operation":"chat","model":"gemma4-e4b-it","requestID":"chat-1","messages":[{"role":"user","content":"Hello"}],"maxTokens":128,"temperature":0}
chat: gemma4-e4b-it
speech: voxcpm2
transcribe: whisper-large-v3
vad: silero-vad
Audio: audioBase64 (mono PCM16), sampleRate
VAD: streamID, reset, endStream; 512 samples/frame at 16000 Hz
Tools: name, description, parameters
Tool call: id, name, arguments
Tool result: role="tool", toolCallID, contentOptional model ownership
SpeechRuntime provides shared loading, cancellable FIFO admission and serialized execution. Qwen3ASRRuntime selects MLX or full CoreML through explicit descriptors; native APIs and CLI flags remain available.
The audio server holds one execution permit through each complete request, including streamed output, with up to 32 requests waiting. ASR failures produce HTTP errors or WebSocket error events; valid empty transcripts remain successful. Playback preserves overflow samples. Cached models and pending playback audio still occupy memory.
import Qwen3ASRRuntime
let runtime = Qwen3ASRRuntime(descriptor: Qwen3ASRDescriptors.small)
let text = try await runtime.transcribe(audio: samples, sampleRate: 16000)
try await runtime.unload()Voxtral TTS
Voxtral 4B TTS 2603 runs locally on Apple Silicon through the VoxtralTTS module: 20 preset voices, nine languages, and 24 kHz mono speech.
import VoxtralTTS
let model = try await VoxtralTTSModel.fromPretrained()
for try await chunk in model.generateStream(text: "Hello, welcome to Voxtral.", voice: "casual_male") {
print(chunk.frameIndex, chunk.samples.count, chunk.isFinal)
}Preset voices only; reference-audio cloning is unavailable. Audio output streams through the SDK, CLI, WebSocket and HTTP PCM. Supply the complete input text up front. Model weights use CC BY-NC 4.0.
import AudioCommon
let player = StreamingAudioPlayer()
player.skipInitialSilence = false