الواجهة البرمجية والبروتوكولات

تُعرّف وحدة 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() كقطعة واحدة. النماذج التي تدعم البث الحقيقي (مثل Qwen3-TTS) تتجاوز هذا التطبيق.

الأنواع الملتزمة: Qwen3TTSModel, CosyVoiceTTSModel, VoxCPM2TTSModel, KokoroTTSModel, IndexTTS2TTSModel

يضيف IndexTTS2TTSModel overload لـ generate مع صوت مرجعي لاستنساخ zero-shot، ويستخدم 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

بروتوكول لاستخراج تضمينات المتحدث.

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

مقطع مُحلَّل من نص حوار متعدد المتحدثين مع وسوم اختيارية للمتحدث والمشاعر. يُستخدم مع DialogueParser وDialogueSynthesizer لتوليف الحوار في 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/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

بروتوكول لدمج النماذج اللغوية مع خطوط معالجة الصوت. يربط نموذجًا لغويًا بتدفق ASR → LLM → TTS في VoicePipeline.

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

المحوّل المضمَّن: Qwen3PipelineLLM يربط Qwen35MLXChat بهذا البروتوكول مع تنظيف الرموز، والإلغاء، وتجميع العبارات المعلّقة.

AudioIO

مدير قابل لإعادة الاستخدام لمدخلات/مخرجات الصوت يُلغي تكرار شيفرة AVAudioEngine. يتولى التقاط الميكروفون، وإعادة التشكيل، والتشغيل، وقياس مستوى الصوت.

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

تتضمن AudioIO مكوّن StreamingAudioPlayer لإخراج TTS وAudioRingBuffer لنقل الصوت بأمان بين خيوط الالتقاط والاستدلال.

SystemAudioTap

يلتقط مزيج إخراج النظام — ما يشغّله الـ Mac الآن — كصوت أحادي Float32 بمعدل عيّنات يختاره المستدعي (process taps في Core Audio، macOS 14.4+). يستبعد الـ tap الأحادي العام العمليةَ الحالية افتراضيًا، فلا يُعاد التقاط تشغيل التطبيق نفسه أبدًا، ويُغلَّف في جهاز تجميع خاص لا يحوي سوى الـ tap.

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

يستمر الالتقاط بعد تبديل جهاز الإخراج الافتراضي — يُعاد بناء معيد أخذ العينات الداخلي عند تغيّر معدل المزيج. يجب أن يعلن تطبيقُ المضيف NSAudioCaptureUsageDescription. عند رفض إذن التسجيل قد يُنشأ الـ tap لكنه لا يسلّم إلا صمتًا: راقب تزايد framesCaptured بينما يبقى nonSilentFrames عند 0، وافشل بشكل صريح.

مصادر مستقلة بطوابع زمنية

توفر فئتا الالتقاط تحميلات زائدة بطوابع زمنية. يحمل CapturedAudioChunk عينات samples أحادية بمعدل sampleRate ووقت 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 قبل قراءة تنسيق الميكروفون، ويحذف enablePlayback: false المشغّل غير المستخدم. على macOS، يُزال صوت الجهاز المشغّل من عينات الميكروفون، ويُبلّغ عن تعذّر بدء إلغاء الصدى بدل الرجوع إلى إدخال خام.

SentencePieceModel

قارئ protobuf مشترك لملفات .model الخاصة بـ SentencePiece، يقع في AudioCommon. كل وحدة بحاجة إلى فك ترميز قطع SentencePiece (PersonaPlex، OmnilingualASR، نقلات ASR / TTS المستقبلية) تبني فاكَّ الترميز الخاص بها فوق هذا القارئ الوحيد بدلًا من إعادة تنفيذ تنسيق wire الخاص بـ 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.

MLXCommon.SDPA

مساعدات الانتباه scaled dot-product المشتركة بين كل وحدات الانتباه في MLX (Qwen3-ASR / Qwen3-TTS / Qwen3-Chat / CosyVoice / PersonaPlex / OmnilingualASR). كل وحدة تحتفظ بإسقاطاتها الخاصة — وحدة SDPA تعالج فقط تكرار شيفرة reshape → الانتباه → 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 لبُعد الـ batch، بحيث تتركّب المساعدات مع رسومات MLX.compile(shapeless:) التي تختلف فيها قيمة batch وقت التشغيل (مثل فك التشفير التراجعي للـ Talker في Qwen3-TTS).

خادم HTTP API

يكشف الملف التنفيذي speech-server كل نموذج في speech-swift كنقاط نهاية HTTP REST بالإضافة إلى نقطة WebSocket تُنفّذ OpenAI Realtime API. تُحمَّل النماذج بشكل كسول عند أول طلب؛ مرّر --preload لتسخين الجميع عند الإقلاع.

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

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

نقاط نهاية REST

نقطة النهايةالطريقةالطلبالاستجابة
/transcribePOSTجسم audio/wavJSON { text } (Qwen3-ASR)
/v1/audio/transcriptionsPOSTنموذج multipart { file, model, response_format?, language?, prompt?, temperature? }JSON أو نص أو JSON مفصل أو SRT أو VTT
/v1/audio/speechPOSTJSON { model, input, voice, response_format?, speed? }بيانات صوتية audio/wav أو audio/pcm
/speakPOSTJSON { text, engine?, language?, voice? }جسم audio/wav (Qwen3-TTS, CosyVoice, Kokoro)
/respondPOSTجسم audio/wavجسم audio/wav (PersonaPlex)
/enhancePOSTجسم audio/wavجسم audio/wav (DeepFilterNet3)
/vadPOSTجسم audio/wavقائمة مقاطع بصيغة JSON
/diarizePOSTجسم audio/wavقائمة DiarizedSegment بصيغة JSON
/embed-speakerPOSTجسم audio/wavJSON [Float] (256 بُعدًا)

يربط /v1/audio/speech أسماء نماذج وأصوات TTS القياسية بالمحركات المحلية. التنسيق الافتراضي هو WAV؛ ويعيد response_format: "pcm" صوت PCM16 أحادي القناة بتردد 24 كيلوهرتز، من دون ترويسة وبترتيب little-endian. تُرفض التنسيقات المضغوطة غير المدعومة بوضوح.

تستقبل /v1/audio/transcriptions ملف WAV واحدًا كنموذج multipart وتعيد {"text":"..."} افتراضيًا؛ ويمكن لـ response_format طلب نص أو JSON مفصل أو SRT أو VTT. على Apple تختار الأسماء البديلة للنماذج محرك ASR محليًا. يستخدم خادم speech-core المضمّن لنظامي Linux وWindows نموذج Parakeet المحمّل عند الطلب، ويقبل WAV بصيغ PCM16/24/32 أو Float32، ويحد الرفع إلى 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)

نقطة WebSocket عند ws://host:port/v1/realtime تُنفّذ بروتوكول OpenAI Realtime. جميع الرسائل بصيغة JSON مع مميّز type؛ وحمولات الصوت هي PCM16 مُرمَّز بـ base64 عند 24 كيلوهرتز أحادي القناة.

أثناء تحميل نموذج بارد أو توليد طويل، يرسل الخادم أحداث JSON خفيفة realtime.keepalive وإطارات تحكم websocket pong كل 15 ثانية تقريباً حتى يصبح خرج النموذج جاهزاً. يمكن للعملاء تجاهل هذه الأحداث أو استخدامها كمؤشرات نشاط.

أحداث من العميل → إلى الخادم

الحدثالغرض
session.updateإعداد المحرّك واللغة والصوت وصيغة الصوت
input_audio_buffer.appendإضافة قطعة PCM16 بترميز base64 إلى مخزّن الإدخال
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قطعة PCM16 بترميز base64 من الصوت المُولَّد
response.audio.doneلا مزيد من قطع الصوت لهذه الاستجابة
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

الخادم يقع داخل منتج SPM المسمّى AudioServer. يُشحَن مثال لعميل متصفح في Examples/websocket-client.html — افتحه إلى جانب خادم قيد التشغيل لتشغيل دورة ASR + TTS الكاملة.

تنزيل النماذج

تُنزَّل كل النماذج من HuggingFace عند أول استخدام وتُخزَّن مؤقتًا في ~/Library/Caches/qwen3-speech/. توفر وحدة AudioCommon أداة HuggingFaceDownloader مشتركة تتولى التنزيل والتخزين المؤقت والتحقق من السلامة.