API y protocolos
El módulo AudioCommon define protocolos agnósticos al modelo y tipos compartidos. Cualquier modelo que cumpla estos protocolos puede usarse de forma intercambiable a través de estas interfaces.
Resumen de protocolos
┌─────────────────────────────────────────────────────────┐
│ AudioCommon │
│ │
│ AudioChunk SpeechGenerationModel (TTS) │
│ AlignedWord SpeechRecognitionModel (STT) │
│ SpeechSegment ForcedAlignmentModel │
│ SpeechToSpeechModel │
│ VoiceActivityDetectionModel (VAD) │
│ SpeakerEmbeddingModel │
│ SpeakerDiarizationModel │
│ SpeakerExtractionCapable │
└─────────────────────────────────────────────────────────┘SpeechRecognitionModel
Protocolo para modelos de voz a texto.
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
}
Tipos conformes: Qwen3ASRModel, ParakeetASRModel, ParakeetStreamingASRModel, OmnilingualASRModel (CoreML), OmnilingualASRMLXModel (MLX)
SpeechGenerationModel
Protocolo para modelos de texto a voz.
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() tiene una implementación por defecto que envuelve generate() como un único fragmento. Los modelos con streaming real (p. ej. Qwen3-TTS) la sobrescriben.
Tipos conformes: Qwen3TTSModel, CosyVoiceTTSModel, KokoroTTSModel, Qwen35MLXChat
ForcedAlignmentModel
Protocolo para alineación de marcas temporales a nivel de palabra.
public protocol ForcedAlignmentModel: AnyObject {
func align(audio: [Float], text: String, sampleRate: Int, language: String?) -> [AlignedWord]
}
SpeechToSpeechModel
Protocolo para modelos de diálogo de voz a voz.
public protocol SpeechToSpeechModel: AnyObject {
var sampleRate: Int { get }
func respond(userAudio: [Float]) -> [Float]
func respondStream(userAudio: [Float]) -> AsyncThrowingStream<AudioChunk, Error>
}
Tipos conformes: PersonaPlexModel
VoiceActivityDetectionModel
Protocolo para detección de actividad vocal.
public protocol VoiceActivityDetectionModel: AnyObject {
var inputSampleRate: Int { get }
func detectSpeech(audio: [Float], sampleRate: Int) -> [SpeechSegment]
}
SpeakerEmbeddingModel
Protocolo para extracción de embeddings de hablante.
public protocol SpeakerEmbeddingModel: AnyObject {
var inputSampleRate: Int { get }
var embeddingDimension: Int { get }
func embed(audio: [Float], sampleRate: Int) -> [Float]
}
Tipos conformes: WeSpeakerModel
SpeakerDiarizationModel
Protocolo para modelos de diarización de hablantes que asignan etiquetas de hablante a los segmentos de audio.
public protocol SpeakerDiarizationModel: AnyObject {
var inputSampleRate: Int { get }
func diarize(audio: [Float], sampleRate: Int) -> [DiarizedSegment]
}
Tipos conformes: DiarizationPipeline (Pyannote), SortformerDiarizer
SpeakerExtractionCapable
Protocolo de diarización extendido para motores que soportan la extracción de los segmentos de un hablante objetivo usando un embedding de referencia. No todos los motores lo soportan (Sortformer es de extremo a extremo y no produce embeddings de hablante).
public protocol SpeakerExtractionCapable: SpeakerDiarizationModel {
func extractSpeaker(audio: [Float], sampleRate: Int, targetEmbedding: [Float]) -> [SpeechSegment]
}
Tipos conformes: DiarizationPipeline (solo Pyannote)
Tipos compartidos
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
Un segmento parseado de texto de diálogo multi-hablante con etiquetas opcionales de hablante y emoción. Se usa con DialogueParser y DialogueSynthesizer para la síntesis de diálogo con 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
Parsea texto de diálogo multi-hablante con etiquetas inline de hablante ([S1]) y etiquetas de emoción ((happy)).
public enum DialogueParser {
static func parse(_ text: String) -> [DialogueSegment]
static func emotionToInstruction(_ emotion: String) -> String
}
Emociones integradas: happy/excited, sad, angry, whispers/whispering, laughs/laughing, calm, surprised, serious. Las etiquetas desconocidas se pasan como instrucciones en texto libre.
DialogueSynthesizer
Orquesta la síntesis de diálogo multi-segmento con clonación de voz por hablante, huecos de silencio y 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
Protocolo para integrar modelos de lenguaje con pipelines de voz. Hace de puente entre un LLM y el flujo ASR → LLM → TTS de VoicePipeline.
public protocol PipelineLLM: AnyObject {
func chat(messages: [(role: MessageRole, content: String)],
onToken: @escaping (String, Bool) -> Void)
func cancel()
}
Adaptador integrado: Qwen3PipelineLLM conecta Qwen35MLXChat a este protocolo con limpieza de tokens, cancelación y acumulación de frases pendientes.
AudioIO
Gestor reutilizable de E/S de audio que elimina el boilerplate de AVAudioEngine. Se encarga de la captura del micrófono, el remuestreo, la reproducción y la medición del nivel de audio.
let audio = AudioIO()
try audio.startMicrophone(targetSampleRate: 16000) { samples in
pipeline.pushAudio(samples)
}
audio.player.scheduleChunk(ttsOutput)
audio.stopMicrophone()
AudioIO incluye un StreamingAudioPlayer para la salida TTS y un AudioRingBuffer para la transferencia de audio thread-safe entre los hilos de captura e inferencia.
SentencePieceModel
Lector protobuf compartido para archivos .model de SentencePiece, ubicado en AudioCommon. Cada módulo que necesite decodificar piezas SentencePiece (PersonaPlex, OmnilingualASR, futuras portaciones de ASR / TTS) construye su propio decodificador sobre este lector único en lugar de reimplementar el formato wire del 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
}
Usado por: OmnilingualASR.OmnilingualVocabulary, PersonaPlex.SentencePieceDecoder. Cubierto por 7 pruebas unitarias en Tests/AudioCommonTests/SentencePieceModelTests.
MLXCommon.SDPA
Helpers de atención scaled dot-product compartidos entre todos los módulos de atención MLX (Qwen3-ASR / Qwen3-TTS / Qwen3-Chat / CosyVoice / PersonaPlex / OmnilingualASR). Cada módulo mantiene sus propias proyecciones — SDPA solo gestiona el boilerplate de reshape → atención → 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
}
Todas las llamadas de reshape usan -1 para la dimensión de batch, de modo que los helpers componen con grafos MLX.compile(shapeless:) que varían el batch en tiempo de ejecución (p. ej. el decode autoregresivo del Talker de Qwen3-TTS).
Servidor API HTTP
El binario audio-server expone cada modelo de speech-swift como endpoints HTTP REST más un endpoint WebSocket que implementa la OpenAI Realtime API. Los modelos se cargan de forma perezosa en la primera petición; pasa --preload para calentarlos todos al arrancar.
swift build -c release
.build/release/audio-server --port 8080
# Preload every model at startup
.build/release/audio-server --port 8080 --preload
Endpoints REST
| Endpoint | Método | Petición | Respuesta |
|---|---|---|---|
/transcribe | POST | Cuerpo audio/wav | JSON { text } (Qwen3-ASR) |
/speak | POST | JSON { text, engine?, language?, voice? } | Cuerpo audio/wav (Qwen3-TTS, CosyVoice, Kokoro) |
/respond | POST | Cuerpo audio/wav | Cuerpo audio/wav (PersonaPlex) |
/enhance | POST | Cuerpo audio/wav | Cuerpo audio/wav (DeepFilterNet3) |
/vad | POST | Cuerpo audio/wav | Lista de segmentos en JSON |
/diarize | POST | Cuerpo audio/wav | Lista de DiarizedSegment en JSON |
/embed-speaker | POST | Cuerpo audio/wav | JSON [Float] (256 dim) |
# 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
# 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)
El endpoint WebSocket en ws://host:port/v1/realtime implementa el protocolo OpenAI Realtime. Todos los mensajes son JSON con un discriminador type; las cargas de audio son PCM16 codificado en base64 a 24 kHz mono.
Eventos cliente → servidor
| Evento | Propósito |
|---|---|
session.update | Configura motor, idioma, voz y formato de audio |
input_audio_buffer.append | Añade un fragmento PCM16 en base64 al buffer de entrada |
input_audio_buffer.commit | Confirma el audio del buffer para transcripción |
input_audio_buffer.clear | Descarta el buffer de entrada actual |
response.create | Solicita síntesis TTS para el texto/instrucciones proporcionadas |
Eventos servidor → cliente
| Evento | Significado |
|---|---|
session.created | Handshake completado, configuración por defecto emitida |
session.updated | Último session.update confirmado |
input_audio_buffer.committed | Audio aceptado y en cola para transcripción |
conversation.item.input_audio_transcription.completed | Resultado de ASR con el texto transcrito final |
response.audio.delta | Fragmento PCM16 en base64 de audio sintetizado |
response.audio.done | No hay más fragmentos de audio para esta respuesta |
response.done | Respuesta finalizada (metadatos + estadísticas de latencia) |
error | Envoltorio de error con type y 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
El servidor vive en el producto SPM AudioServer. Se incluye un cliente de navegador de ejemplo en Examples/websocket-client.html — ábrelo junto a un servidor en ejecución para probar el ciclo completo de ASR + TTS.
Descargas de modelos
Todos los modelos se descargan desde HuggingFace en el primer uso y se almacenan en caché en ~/Library/Caches/qwen3-speech/. El módulo AudioCommon proporciona un HuggingFaceDownloader compartido que gestiona la descarga, el almacenamiento en caché y la verificación de integridad.