API 및 프로토콜
AudioCommon 모듈은 모델에 독립적인 프로토콜과 공유 타입을 정의합니다. 이를 준수하는 모든 모델은 이러한 인터페이스를 통해 상호 교환 가능하게 사용할 수 있습니다.
프로토콜 개요
┌─────────────────────────────────────────────────────────┐
│ AudioCommon │
│ │
│ AudioChunk SpeechGenerationModel (TTS) │
│ AlignedWord SpeechRecognitionModel (STT) │
│ SpeechSegment ForcedAlignmentModel │
│ SpeechToSpeechModel │
│ VoiceActivityDetectionModel (VAD) │
│ TurnCompletionProvider (EOT) │
│ 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로 래핑하는 기본 구현을 제공합니다. 진정한 스트리밍을 지원하는 모델(예: Qwen3-TTS)은 이를 재정의합니다.
준수 타입: Qwen3TTSModel, CosyVoiceTTSModel, VoxCPM2TTSModel, KokoroTTSModel, IndexTTS2TTSModel
IndexTTS2TTSModel은 zero-shot 음성 복제를 위해 참조 오디오가 있는 generate 오버로드를 제공하며, 말하기 속도와 일시정지 제어에 IndexTTS2SynthesisOptions를 사용합니다.
Qwen3-TTS 로컬 로딩
fromLocal은 필수 파일과 체크포인트 샤드 완전성을 검증한 뒤 config.json에서 모델 크기, 양자화, 아키텍처를 결정합니다. 선택적 configuration은 누락된 메타데이터의 폴백으로만 사용되며, 잘못되거나 모순된 메타데이터는 모델 할당 전에 Qwen3TTSLoadingError.invalidConfiguration을 발생시킵니다.
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의 기본값은 .none이므로 프로세스 전체 Metal wired-memory 한도를 바꾸지 않습니다. fromPretrained는 .pin(fraction: 0.9) 기본값을 유지합니다. 두 사전 학습 로더 모두 별도의 tokenizerCacheDir을 받습니다.
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]
}
TurnCompletionProvider
확정된 VAD 멈춤마다 StreamingVADProcessor가 문의하는 발화 종료 분류기용 프로토콜입니다. VAD는 침묵만 듣지만, 발화 완료 모델은 발화 전체의 운율을 듣기 때문에 문장 중간의 멈춤에서는 에이전트가 기다리고 끝난 문장에는 즉시 답합니다. speech-core의 sc_turn_completion_vtable_t에 대응합니다. Smart Turn 가이드를 참고하세요.
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
}
준수 타입: SmartTurnModel (Smart Turn v3.2)
SpeakerEmbeddingModel
화자 임베딩 추출을 위한 프로토콜입니다.
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
레퍼런스 임베딩을 사용해 대상 화자의 세그먼트를 추출하는 엔진을 위한 확장 화자 분리 프로토콜입니다. 모든 엔진이 이를 지원하지는 않습니다(Sortformer는 엔드투엔드이며 화자 임베딩을 생성하지 않습니다).
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
선택적 화자 및 감정 태그를 갖는 다화자 대화 텍스트의 파싱된 세그먼트입니다. CosyVoice3 대화 합성을 위해 DialogueParser 및 DialogueSynthesizer와 함께 사용됩니다.
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/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 // 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
음성 파이프라인과 언어 모델 통합을 위한 프로토콜입니다. 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를 시작할 수 없으면 원시 입력으로 대체하지 않고 오류를 알립니다.
점진적 파일 입력 및 음성 인식
CapturedAudioChunk에는 단조 증가하는 frameIndex와 isFinal 표시도 포함됩니다. AudioFileLoader.stream은 제한된 메모리로 요청 시 청크를 만들고, 변환기 상태를 유지하며 리샘플링하고, 기본적으로 모든 입력 채널의 평균을 냅니다. 채널 라우팅을 알고 있다면 .first 또는 .select([indices])를 사용하세요.
StreamingRecognitionModel과 StreamingRecognitionSession은 Parakeet Streaming 및 Nemotron Core ML/MLX에서 캐시를 유지하는 하나의 점진적 API를 제공합니다.
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
SentencePiece .model 파일을 위한 공유 protobuf 리더이며, AudioCommon에 위치합니다. SentencePiece 조각을 디코딩해야 하는 모든 모듈(PersonaPlex, OmnilingualASR, 향후 ASR / TTS 포트)은 protobuf wire 형식을 재구현하는 대신 이 단일 리더 위에 자체 디코더를 빌드합니다.
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 attention 모듈(Qwen3-ASR / Qwen3-TTS / Qwen3-Chat / CosyVoice / PersonaPlex / OmnilingualASR)에서 공유하는 scaled dot-product attention 헬퍼입니다. 각 모듈은 자체 projection을 유지하며 — 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 autoregressive decode)와 함께 구성할 수 있습니다.
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 엔드포인트
| 엔드포인트 | 메서드 | 요청 | 응답 |
|---|---|---|---|
/transcribe | POST | audio/wav 본문 | 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 본문 (Qwen3-TTS, CosyVoice, Kokoro) |
/respond | POST | audio/wav 본문 | audio/wav 본문 (PersonaPlex) |
/enhance | POST | audio/wav 본문 | audio/wav 본문 (DeepFilterNet3) |
/vad | POST | audio/wav 본문 | JSON 세그먼트 리스트 |
/diarize | POST | audio/wav 본문 | JSON DiarizedSegment 리스트 |
/embed-speaker | POST | audio/wav 본문 | JSON [Float] (256차원) |
/v1/audio/speech는 표준 TTS 모델 및 음성 이름을 로컬 엔진에 매핑합니다. 기본값은 WAV이며, response_format: "pcm"은 헤더 없는 24 kHz 모노 PCM16 리틀엔디언 오디오를 반환합니다. 지원하지 않는 압축 형식은 명시적으로 거부됩니다.
/v1/audio/transcriptions는 multipart 폼으로 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 | 최종 전사 텍스트가 포함된 ASR 결과 |
response.audio.delta | 합성된 오디오의 Base64 PCM16 chunk |
response.audio.done | 이 응답의 오디오 chunk 종료 |
response.done | 응답 확정 (메타데이터 + 지연 통계) |
error | type과 message를 포함하는 오류 엔벨로프 |
서버 VAD를 사용한 자동 발화 구간
수동 input_audio_buffer.commit이 기본값으로 유지됩니다. session.turn_detection을 server_vad로 설정하면 서버에서 Silero VAD를 실행하여 완료된 각 발화 구간을 자동으로 전사합니다. 접두 오디오가 보존되고 유휴 버퍼는 제한되며 speech_started, speech_stopped, committed 이벤트가 순서대로 전송됩니다. 계속 활성 상태인 구간은 설정된 최대 시간에 강제로 종료됩니다.
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
}
}
}));
const ws = new WebSocket('ws://localhost:8080/v1/realtime');
// ASR: 오디오 전송, 전사 요청
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: 합성 요청 및 오디오 delta 스트리밍
ws.send(JSON.stringify({
type: 'response.create',
response: { modalities: ['audio', 'text'], instructions: 'Hello world' }
}));
// → response.audio.delta (반복), response.audio.done, response.done
서버는 AudioServer SPM 프로덕트에 포함되어 있습니다. 예제 브라우저 클라이언트는 Examples/websocket-client.html에 제공됩니다 — 실행 중인 서버 옆에서 열어 전체 ASR + TTS 왕복을 구동하세요.
모델 다운로드
모든 모델은 최초 사용 시 HuggingFace에서 다운로드되어 ~/Library/Caches/qwen3-speech/에 캐시됩니다. AudioCommon 모듈은 다운로드, 캐싱, 무결성 검증을 처리하는 공유 HuggingFaceDownloader를 제공합니다.
협력적 취소
MLX 전사에서 Qwen3ASRModel.transcribeCheckingCancellation(audio:sampleRate:options:)는 특징 추출, 인코딩, 디코더 프리필 또는 디코딩 단계 전에 작업 취소를 감지하면 CancellationError를 던집니다. 이미 제출된 GPU 작업은 완료될 수 있습니다. 동기 transcribe 및 transcribeBatch API는 오류를 던지지 않는 동작을 유지하며 작업 취소를 무시합니다. speech-server는 Qwen3-ASR에 취소를 지원하는 진입점을 사용합니다.