API & Protocols

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)   │
│                      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.

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]
}

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.

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

EndpointMethodRequestResponse
/transcribePOSTaudio/wav bodyJSON { text } (Qwen3-ASR)
/v1/audio/transcriptionsPOSTMultipart { file, model, response_format?, language?, prompt?, temperature? }JSON, text, verbose JSON, SRT, or VTT
/v1/audio/speechPOSTJSON { model, input, voice, response_format?, speed? }audio/wav or audio/pcm body
/speakPOSTJSON { text, engine?, language?, voice? }audio/wav body (Qwen3-TTS, CosyVoice, Kokoro)
/respondPOSTaudio/wav bodyaudio/wav body (PersonaPlex)
/enhancePOSTaudio/wav bodyaudio/wav body (DeepFilterNet3)
/vadPOSTaudio/wav bodyJSON segment list
/diarizePOSTaudio/wav bodyJSON DiarizedSegment list
/embed-speakerPOSTaudio/wav bodyJSON [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

EventPurpose
session.updateConfigure engine, language, voice, and audio format
input_audio_buffer.appendAppend a base64 PCM16 chunk to the input buffer
input_audio_buffer.commitCommit the buffered audio for transcription
input_audio_buffer.clearDiscard the current input buffer
response.createRequest TTS synthesis for the supplied text/instructions

Server → Client events

EventMeaning
session.createdHandshake complete, default config emitted
session.updatedMost recent session.update acknowledged
input_audio_buffer.committedAudio accepted and queued for transcription
conversation.item.input_audio_transcription.completedASR result with final transcript text
response.audio.deltaBase64 PCM16 chunk of synthesized audio
response.audio.doneNo more audio chunks for this response
response.doneResponse finalized (metadata + latency stats)
errorError envelope with type and message
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.