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

EndpointMétodoPeticiónRespuesta
/transcribePOSTCuerpo audio/wavJSON { text } (Qwen3-ASR)
/speakPOSTJSON { text, engine?, language?, voice? }Cuerpo audio/wav (Qwen3-TTS, CosyVoice, Kokoro)
/respondPOSTCuerpo audio/wavCuerpo audio/wav (PersonaPlex)
/enhancePOSTCuerpo audio/wavCuerpo audio/wav (DeepFilterNet3)
/vadPOSTCuerpo audio/wavLista de segmentos en JSON
/diarizePOSTCuerpo audio/wavLista de DiarizedSegment en JSON
/embed-speakerPOSTCuerpo audio/wavJSON [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

EventoPropósito
session.updateConfigura motor, idioma, voz y formato de audio
input_audio_buffer.appendAñade un fragmento PCM16 en base64 al buffer de entrada
input_audio_buffer.commitConfirma el audio del buffer para transcripción
input_audio_buffer.clearDescarta el buffer de entrada actual
response.createSolicita síntesis TTS para el texto/instrucciones proporcionadas

Eventos servidor → cliente

EventoSignificado
session.createdHandshake completado, configuración por defecto emitida
session.updatedÚltimo session.update confirmado
input_audio_buffer.committedAudio aceptado y en cola para transcripción
conversation.item.input_audio_transcription.completedResultado de ASR con el texto transcrito final
response.audio.deltaFragmento PCM16 en base64 de audio sintetizado
response.audio.doneNo hay más fragmentos de audio para esta respuesta
response.doneRespuesta finalizada (metadatos + estadísticas de latencia)
errorEnvoltorio 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.