API 与协议

AudioCommon 模块定义了与模型无关的协议和共享类型。任何符合这些协议的模型都可以通过这些接口互换使用。

协议概览

┌─────────────────────────────────────────────────────────┐
│                    AudioCommon                          │
│                                                         │
│  AudioChunk          SpeechGenerationModel (TTS)        │
│  AlignedWord         SpeechRecognitionModel (STT)       │
│  SpeechSegment       ForcedAlignmentModel               │
│                      SpeechToSpeechModel                │
│                      VoiceActivityDetectionModel (VAD)   │
│                      SpeakerEmbeddingModel              │
│                      SpeakerDiarizationModel            │
│                      SpeakerExtractionCapable           │
└─────────────────────────────────────────────────────────┘

SpeechRecognitionModel

语音转文字模型的协议。

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
}

符合该协议的类型: Qwen3ASRModel, WhisperASRModelParakeetASRModelParakeetStreamingASRModelOmnilingualASRModel(CoreML)、OmnilingualASRMLXModel(MLX)

SpeechGenerationModel

文本转语音模型的协议。

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() 提供默认实现,将 generate() 包装为单个 chunk。具有真正 streaming 能力的模型(例如 Qwen3-TTS)会覆盖该实现。

符合该协议的类型: Qwen3TTSModel, CosyVoiceTTSModel, VoxCPM2TTSModel, KokoroTTSModel, IndexTTS2TTSModel

IndexTTS2TTSModel 增加了带参考音频的 generate 重载,用于零样本语音克隆,并使用 IndexTTS2SynthesisOptions 控制语速和停顿。

ForcedAlignmentModel

词级时间戳对齐的协议。

public protocol ForcedAlignmentModel: AnyObject {
    func align(audio: [Float], text: String, sampleRate: Int, language: String?) -> [AlignedWord]
}

SpeechToSpeechModel

语音到语音对话模型的协议。

public protocol SpeechToSpeechModel: AnyObject {
    var sampleRate: Int { get }
    func respond(userAudio: [Float]) -> [Float]
    func respondStream(userAudio: [Float]) -> AsyncThrowingStream<AudioChunk, Error>
}

符合该协议的类型: PersonaPlexModel

VoiceActivityDetectionModel

语音活动检测的协议。

public protocol VoiceActivityDetectionModel: AnyObject {
    var inputSampleRate: Int { get }
    func detectSpeech(audio: [Float], sampleRate: Int) -> [SpeechSegment]
}

SpeakerEmbeddingModel

说话人 embedding 提取的协议。

public protocol SpeakerEmbeddingModel: AnyObject {
    var inputSampleRate: Int { get }
    var embeddingDimension: Int { get }
    func embed(audio: [Float], sampleRate: Int) -> [Float]
}

符合该协议的类型: WeSpeakerModel

SpeakerDiarizationModel

将说话人标签分配给音频片段的说话人分离模型协议。

public protocol SpeakerDiarizationModel: AnyObject {
    var inputSampleRate: Int { get }
    func diarize(audio: [Float], sampleRate: Int) -> [DiarizedSegment]
}

符合该协议的类型: DiarizationPipeline(Pyannote)、SortformerDiarizer

SpeakerExtractionCapable

扩展的说话人分离协议,适用于支持通过参考 embedding 提取目标说话人片段的引擎。并非所有引擎都支持(Sortformer 是端到端的,不产生说话人 embedding)。

public protocol SpeakerExtractionCapable: SpeakerDiarizationModel {
    func extractSpeaker(audio: [Float], sampleRate: Int, targetEmbedding: [Float]) -> [SpeechSegment]
}

符合该协议的类型: DiarizationPipeline(仅 Pyannote)

共享类型

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

多说话人对话文本的解析片段,带有可选的说话人和情感标签。与 DialogueParserDialogueSynthesizer 一起用于 CosyVoice3 对话合成。

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

解析带有内联说话人标签([S1])和情感标签((happy))的多说话人对话文本。

public enum DialogueParser {
    static func parse(_ text: String) -> [DialogueSegment]
    static func emotionToInstruction(_ emotion: String) -> String
}

内置情感:happy/excitedsadangrywhispers/whisperinglaughs/laughingcalmsurprisedserious。未知标签会作为自由形式指令透传。

DialogueSynthesizer

编排多片段对话合成,支持每位说话人的声音克隆、静默间隔和交叉淡化。

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

用于将语言模型集成到语音流水线的协议。将 LLM 桥接到 VoicePipeline 的 ASR → LLM → TTS 流程。

public protocol PipelineLLM: AnyObject {
    func chat(messages: [(role: MessageRole, content: String)],
              onToken: @escaping (String, Bool) -> Void)
    func cancel()
}

内置适配器: Qwen3PipelineLLMQwen35MLXChat 桥接到该协议,提供 token 清理、取消和待处理短语累积。

AudioIO

可复用的音频 I/O 管理器,免去 AVAudioEngine 的样板代码。处理麦克风采集、重采样、播放以及音频电平测量。

let audio = AudioIO()
try audio.startMicrophone(targetSampleRate: 16000) { samples in
    pipeline.pushAudio(samples)
}
audio.player.scheduleChunk(ttsOutput)
audio.stopMicrophone()

AudioIO 包含用于 TTS 输出的 StreamingAudioPlayer 以及用于在采集与推理线程之间进行线程安全音频传输的 AudioRingBuffer

SystemAudioTap

捕获系统输出混音——Mac 当前正在播放的内容——以调用方选择的采样率输出单声道 Float32(Core Audio process taps,macOS 14.4+)。全局单声道 tap 默认排除当前进程,因此应用自身的播放永远不会被重新捕获;tap 被包装在仅包含该 tap 的私有聚合设备中。

let tap = SystemAudioTap()
try tap.start(targetSampleRate: 16000) { samples in
    pipeline.pushAudio(samples)
}
tap.stop()

默认输出设备切换后捕获仍然继续——混音采样率变化时内部重采样器会自动重建。宿主应用必须声明 NSAudioCaptureUsageDescription。若录音权限被拒绝,tap 仍可能创建成功却只输出静音:当 framesCaptured 持续增长而 nonSilentFrames 保持为 0 时应显式失败。

带时间戳的独立音源

两个采集类都提供带时间戳的重载。CapturedAudioChunk 包含 sampleRate 采样率的单声道 samples,以及首个输入帧可选的 Mach hostTime。两个音源使用同一个主机时钟,因此应用可以保持 PCM 分离,并在不混合音频的情况下排列派生的转录事件。

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

对于仅监听采集,enableAEC: true 会在读取麦克风格式前启用 Apple Voice Processing,enablePlayback: false 则省略未使用的播放器。在 macOS 上,设备播放声会从麦克风采样中移除;如果 AEC 无法启动,系统会报告错误,而不会退回原始输入。

SentencePieceModel

位于 AudioCommon 中的共享 protobuf 读取器,用于 SentencePiece .model 文件。每个需要解码 SentencePiece pieces 的模块(PersonaPlex、OmnilingualASR、未来的 ASR / TTS 移植)都在这个唯一读取器之上构建自己的解码器,而不是各自重新实现 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
}

使用方: OmnilingualASR.OmnilingualVocabularyPersonaPlex.SentencePieceDecoder。在 Tests/AudioCommonTests/SentencePieceModelTests 中由 7 个单元测试覆盖。

MLXCommon.SDPA

在每个 MLX attention 模块(Qwen3-ASR / Qwen3-TTS / Qwen3-Chat / CosyVoice / PersonaPlex / OmnilingualASR)之间共享的 scaled dot-product attention 辅助工具。每个模块保留自己的投影——SDPA 只负责 reshape → attention → merge 的样板。

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
}

所有 reshape 调用都为 batch 维度使用 -1,这样辅助函数就能与 MLX.compile(shapeless:) 图组合——这些图在运行时 batch 会变化(例如 Qwen3-TTS Talker 的自回归解码)。

HTTP API 服务器

speech-server 二进制将 speech-swift 中的每个模型都暴露为 HTTP REST 端点,外加一个实现 OpenAI Realtime API 的 WebSocket 端点。模型在首次请求时才惰性加载;传入 --preload 可在启动时预热所有模型。

swift build -c release
.build/release/speech-server --port 8080

# Preload every model at startup
.build/release/speech-server --port 8080 --preload

REST 端点

端点方法请求响应
/transcribePOSTaudio/wav bodyJSON { text }(Qwen3-ASR)
/v1/audio/transcriptionsPOST多部分表单 { file, model, response_format?, language?, prompt?, temperature? }JSON、文本、详细 JSON、SRT 或 VTT
/v1/audio/speechPOSTJSON { model, input, voice, response_format?, speed? }audio/wavaudio/pcm 音频正文
/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 段列表
/diarizePOSTaudio/wav bodyJSON DiarizedSegment 列表
/embed-speakerPOSTaudio/wav bodyJSON [Float](256 维)

/v1/audio/speech 将标准 TTS 模型和语音名称映射到本地引擎。默认返回 WAV;设置 response_format: "pcm" 会返回无文件头的 24 kHz 单声道 PCM16 小端音频。不支持的压缩格式会被明确拒绝。

/v1/audio/transcriptions 通过多部分表单接收一个 WAV 文件,默认返回 {"text":"..."}response_format 还可请求文本、详细 JSON、SRT 或 VTT。在 Apple 平台上,模型别名会选择本地 ASR 引擎。Linux 和 Windows 上打包的 speech-core 服务器使用延迟加载的 Parakeet,接受 PCM16/24/32 或 Float32 WAV,并将上传限制为 25 MiB 和 10 分钟。

# 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

ws://host:port/v1/realtime 的 WebSocket 端点实现了 OpenAI Realtime 协议。所有消息都是带 type 鉴别字段的 JSON;音频负载是 base64 编码的 24 kHz 单声道 PCM16。

在冷启动模型加载或长时间生成期间,服务器会大约每 15 秒发送轻量级 realtime.keepalive JSON 事件和 websocket pong 控制帧,直到模型输出准备好。客户端可以忽略这些事件,也可以将其视为活动指示。

客户端 → 服务器事件

事件用途
session.update配置引擎、语言、音色和音频格式
input_audio_buffer.append向输入缓冲区追加一个 base64 PCM16 chunk
input_audio_buffer.commit提交已缓冲的音频用于转写
input_audio_buffer.clear丢弃当前输入缓冲区
response.create为提供的文本/指令请求 TTS 合成

服务器 → 客户端事件

事件含义
session.created握手完成,输出默认配置
session.updated最近一次 session.update 被确认
input_audio_buffer.committed音频已接受并排队等待转写
conversation.item.input_audio_transcription.completedASR 结果及最终转录文本
response.audio.delta合成音频的 base64 PCM16 chunk
response.audio.done本次响应没有更多音频 chunks
response.done响应最终化(metadata + 延迟统计)
errortypemessage 的错误信封
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

该服务器位于 AudioServer SPM product 中。仓库提供了一个示例浏览器客户端 Examples/websocket-client.html——在服务器运行时打开它,就可以驱动完整的 ASR + TTS 往返。

模型下载

所有模型在首次使用时从 HuggingFace 下载,并缓存在 ~/Library/Caches/qwen3-speech/AudioCommon 模块提供了共享的 HuggingFaceDownloader,负责下载、缓存和完整性校验。