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, WhisperASRModel、ParakeetASRModel、ParakeetStreamingASRModel、OmnilingualASRModel(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 は zero-shot 音声クローン用に参照音声付きの 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サンプル
public let sampleRate: Int // サンプルレート(例: 24000)
}
SpeechSegment
public struct SpeechSegment {
public let startTime: Float // 開始時刻(秒)
public let endTime: Float // 終了時刻(秒)
}
AlignedWord
public struct AlignedWord {
public let text: String // 単語
public let startTime: Float // 開始時刻(秒)
public let endTime: Float // 終了時刻(秒)
}
DiarizedSegment
public struct DiarizedSegment {
public let startTime: Float // 開始時刻(秒)
public let endTime: Float // 終了時刻(秒)
public let speakerId: Int // 話者識別子(0始まり)
}
DialogueSegment
オプションの話者タグと感情タグを含む、マルチ話者対話テキストの解析済みセグメント。CosyVoice3の対話合成でDialogueParserおよびDialogueSynthesizerと共に使用されます。
public struct DialogueSegment: Sendable, Equatable {
public let speaker: String? // 話者識別子("S1"、"S2")、タグなしはnil
public let emotion: String? // 感情タグ("happy"、"whispers")、なければnil
public let text: String // 合成用のクリーニング済みテキスト
}
DialogueParser
インラインの話者タグ([S1])と感情タグ((happy))を含むマルチ話者対話テキストを解析します。
public enum DialogueParser {
static func parse(_ text: String) -> [DialogueSegment]
static func emotionToInstruction(_ emotion: String) -> String
}
組み込みの感情: happy/excited、sad、angry、whispers/whispering、laughs/laughing、calm、surprised、serious。未知のタグはフリーフォームの指示としてそのまま渡されます。
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 // デフォルト: 0.2
public var crossfadeSeconds: Float // デフォルト: 0.0
public var defaultInstruction: String // デフォルト: "You are a helpful assistant."
public var maxTokensPerSegment: Int // デフォルト: 500
}
PipelineLLM
音声パイプラインとの言語モデル統合用プロトコル。VoicePipelineのASR → LLM → TTSフローにLLMをブリッジします。
public protocol PipelineLLM: AnyObject {
func chat(messages: [(role: MessageRole, content: String)],
onToken: @escaping (String, Bool) -> Void)
func cancel()
}
組み込みアダプター: Qwen3PipelineLLMは、トークンクリーンアップ、キャンセル、保留フレーズの蓄積を備えてQwen35MLXChatをこのプロトコルにブリッジします。
AudioIO
AVAudioEngineのボイラープレートを排除する、再利用可能な音声I/Oマネージャ。マイクキャプチャ、リサンプリング、再生、音声レベルメータリングを処理します。
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 のプロセスタップ、macOS 14.4+)。グローバルなモノラルタップは既定で現在のプロセスを除外するため、アプリ自身の再生が再取得されることはなく、タップだけを含むプライベートな集約デバイスにラップされます。
let tap = SystemAudioTap()
try tap.start(targetSampleRate: 16000) { samples in
pipeline.pushAudio(samples)
}
tap.stop()
既定出力デバイスを切り替えてもキャプチャは継続します——ミックスのレートが変わると内部リサンプラーが再構築されます。ホストアプリは NSAudioCaptureUsageDescription を宣言する必要があります。録音許可が拒否されてもタップは作成され得ますが無音のみを出力します: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
SentencePieceの.modelファイル用の共有protobufリーダで、AudioCommonに存在します。SentencePieceピースをデコードする必要があるすべてのモジュール(PersonaPlex、OmnilingualASR、将来のASR / TTSポート)は、protobufワイヤ形式を再実装する代わりに、この単一のリーダの上に独自のデコーダを構築します。
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.OmnilingualVocabulary、PersonaPlex.SentencePieceDecoder。Tests/AudioCommonTests/SentencePieceModelTestsの7つのユニットテストでカバーされています。
MLXCommon.SDPA
すべてのMLXアテンションモジュール(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呼び出しはバッチ次元に-1を使用するため、ヘルパーはランタイムでバッチが変動するMLX.compile(shapeless:)グラフ(例: 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
# 起動時にすべてのモデルをプリロード
.build/release/speech-server --port 8080 --preload
RESTエンドポイント
| エンドポイント | Method | リクエスト | レスポンス |
|---|---|---|---|
/transcribe | POST | audio/wav body | JSON { text }(Qwen3-ASR) |
/v1/audio/transcriptions | POST | multipart { file, model, response_format?, language?, prompt?, temperature? } | JSON、テキスト、verbose JSON、SRT、VTT |
/v1/audio/speech | POST | JSON { model, input, voice, response_format?, speed? } | audio/wav または audio/pcm の音声本文 |
/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セグメントリスト |
/diarize | POST | audio/wav body | JSON DiarizedSegmentリスト |
/embed-speaker | POST | audio/wav body | JSON [Float](256次元) |
/v1/audio/speech は標準の TTS モデル名と音声名をローカルエンジンに割り当てます。既定は WAV です。response_format: "pcm" では、ヘッダーなしの 24 kHz モノラル PCM16 リトルエンディアン音声を返します。非対応の圧縮形式は明示的に拒否されます。
/v1/audio/transcriptions は multipart フォームで 1 つの WAV ファイルを受け取り、既定で {"text":"..."} を返します。response_format ではテキスト、verbose JSON、SRT、VTT も指定できます。Apple ではモデル別名がローカル ASR エンジンを選択します。Linux と Windows の speech-core パッケージ版サーバーは遅延読み込みの Parakeet を使用し、PCM16/24/32 または Float32 WAV を受け付け、アップロードを 25 MiB、10 分までに制限します。
# ファイルを文字起こし
curl -X POST http://localhost:8080/transcribe \
--data-binary @recording.wav \
-H "Content-Type: audio/wav"
# 音声を合成
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
# 完全な音声間ラウンドトリップ
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です。音声ペイロードは24 kHzモノラルのbase64エンコードされた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.completed | 最終のtranscriptテキスト付きASR結果 |
response.audio.delta | 合成された音声のbase64 PCM16 chunk |
response.audio.done | このレスポンスの音声chunksはこれ以上なし |
response.done | レスポンス確定(メタデータ + レイテンシ統計) |
error | typeと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
サーバーはAudioServerのSPMプロダクトに存在します。ブラウザクライアントの例はExamples/websocket-client.htmlに同梱されています — 実行中のサーバーと共に開くことで、完全なASR + TTSのラウンドトリップを駆動できます。
モデルダウンロード
すべてのモデルは初回使用時にHuggingFaceからダウンロードされ、~/Library/Caches/qwen3-speech/にキャッシュされます。AudioCommonモジュールは、ダウンロード、キャッシング、整合性検証を処理する共有のHuggingFaceDownloaderを提供します。