API และโปรโตคอล

รันไทม์แบบเนทีฟสำหรับ Apple · PocketTTS

PocketTTS เพิ่มการสังเคราะห์เสียง CoreML แบบเนทีฟให้ speech-swift ทุกขั้นตอนใช้ CPU เท่านั้นโดยค่าเริ่มต้น เสียง Alba ภาษาอังกฤษส่งเฟรมขนาด 1,920 ตัวอย่างที่ 24 kHz หรือ 80 มิลลิวินาทีต่อเฟรม

การใช้ Swift และ CLI

import PocketTTS
let model = try PocketTTSModel.fromBundle(URL(fileURLWithPath: "/path/to/bundle"))
try await model.generateStreaming(text: "Hello world.") { chunk in
    await consume(chunk.samples)
}

โมดูล AudioCommon นิยามโปรโตคอลที่ไม่ผูกกับโมเดลและประเภทข้อมูลที่ใช้ร่วม โมเดลใดก็ตามที่ทำตามโปรโตคอลเหล่านี้สามารถใช้แทนกันได้ผ่าน interface ดังกล่าว

ภาพรวมโปรโตคอล

┌─────────────────────────────────────────────────────────┐
│                    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() มีการ implement เริ่มต้นที่ห่อ generate() ให้เป็น chunk เดียว โมเดลที่มี streaming จริง (เช่น Qwen3-TTS) จะ override มัน

ประเภทที่ทำตาม: Qwen3TTSModel, CosyVoiceTTSModel, VoxCPM2TTSModel, KokoroTTSModel, IndexTTS2TTSModel

IndexTTS2TTSModel เพิ่ม overload ของ generate ที่รับเสียงอ้างอิงสำหรับการโคลน zero-shot และใช้ IndexTTS2SynthesisOptions เพื่อควบคุม speaking rate และ pause

การโหลด Qwen3-TTS แบบโลคัล

fromLocal ตรวจสอบไฟล์ที่จำเป็นและความครบถ้วนของ checkpoint shard จากนั้นหา model size, quantization และสถาปัตยกรรมจาก config.json ค่า configuration ที่เป็นตัวเลือกใช้เป็น fallback เฉพาะ metadata ที่ขาดไป ส่วน metadata ที่ไม่ถูกต้องหรือขัดแย้งกันจะทำให้เกิด 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) และ pretrained loader ทั้งสองแบบรับ tokenizerCacheDir แยกได้

ForcedAlignmentModel

โปรโตคอลสำหรับการจัดเรียง timestamp ระดับคำ

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

โปรโตคอลสำหรับตัวจำแนกจุดจบเทิร์น ซึ่ง StreamingVADProcessor จะสอบถามทุกครั้งที่ VAD ยืนยันช่วงหยุด VAD ได้ยินแค่ความเงียบ แต่โมเดลจบเทิร์นฟังทำนองเสียงของทั้งประโยค ดังนั้นการหยุดกลางประโยคจะทำให้เอเจนต์รอ ขณะที่ประโยคที่พูดจบจะได้รับคำตอบทันที สอดคล้องกับ sc_turn_completion_vtable_t ของ speech-core ดูคู่มือ 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

โปรโตคอลสำหรับการสกัด embedding ของผู้พูด

public protocol SpeakerEmbeddingModel: AnyObject {
    var inputSampleRate: Int { get }
    var embeddingDimension: Int { get }
    func embed(audio: [Float], sampleRate: Int) -> [Float]
}

ประเภทที่ทำตาม: WeSpeakerModel

SpeakerDiarizationModel

โปรโตคอลสำหรับโมเดลแยกผู้พูดที่กำกับ label ผู้พูดให้กับช่วงเสียง

public protocol SpeakerDiarizationModel: AnyObject {
    var inputSampleRate: Int { get }
    func diarize(audio: [Float], sampleRate: Int) -> [DiarizedSegment]
}

ประเภทที่ทำตาม: DiarizationPipeline (Pyannote), SortformerDiarizer

SpeakerExtractionCapable

โปรโตคอลแยกผู้พูดแบบขยายสำหรับเอนจินที่รองรับการสกัดช่วงของผู้พูดเป้าหมายโดยใช้ embedding อ้างอิง ไม่ใช่ทุกเอนจินที่รองรับ (Sortformer เป็น end-to-end และไม่สร้าง 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 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

ส่วนหนึ่งของข้อความบทสนทนาหลายผู้พูดที่ถูก parse แล้ว มีแท็กผู้พูดและอารมณ์เป็นตัวเลือก ใช้กับ 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

Parse ข้อความบทสนทนาหลายผู้พูดที่มีแท็กผู้พูดแบบ inline ([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

ประสานการสังเคราะห์บทสนทนาหลายส่วนพร้อมการโคลนเสียงต่อผู้พูด, ช่องว่างเงียบ และ 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

โปรโตคอลสำหรับการรวมโมเดลภาษาเข้ากับไปป์ไลน์เสียง เชื่อม LLM เข้ากับสายการทำงาน ASR → LLM → TTS ของ VoicePipeline

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

Adapter ในตัว: Qwen3PipelineLLM เชื่อม Qwen35MLXChat เข้ากับโปรโตคอลนี้พร้อมการทำความสะอาด token, การยกเลิก และการสะสมวลีที่รอ

AudioIO

ตัวจัดการ I/O เสียงที่ใช้ซ้ำได้ ขจัด boilerplate ของ AVAudioEngine จัดการการจับเสียงจากไมค์, การ resample, การเล่นกลับ และการวัดระดับเสียง

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

AudioIO รวม StreamingAudioPlayer สำหรับเอาต์พุต TTS และ AudioRingBuffer สำหรับโอนเสียงระหว่างเธรดการจับและเธรด inference อย่างปลอดภัย

SystemAudioTap

จับมิกซ์เอาต์พุตของระบบ — สิ่งที่ Mac กำลังเล่นอยู่ — เป็น Float32 โมโนที่อัตราสุ่มตามที่ผู้เรียกเลือก (process taps ของ Core Audio, macOS 14.4+) แท็ปโมโนแบบโกลบอลจะยกเว้นโปรเซสปัจจุบันโดยค่าเริ่มต้น เสียงที่แอปเล่นเองจึงไม่ถูกจับซ้ำ และแท็ปถูกห่อไว้ในอุปกรณ์รวมแบบส่วนตัวที่มีเพียงแท็ปเท่านั้น

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

การจับเสียงยังทำงานต่อเมื่อสลับอุปกรณ์เอาต์พุตเริ่มต้น — ตัวรีแซมเพลอร์ภายในถูกสร้างใหม่เมื่ออัตราของมิกซ์เปลี่ยน แอปโฮสต์ต้องประกาศ NSAudioCaptureUsageDescription หากสิทธิ์บันทึกเสียงถูกปฏิเสธ แท็ปอาจถูกสร้างได้แต่ส่งเพียงความเงียบ: จับตาดู framesCaptured ที่เพิ่มขึ้นขณะ nonSilentFrames ยังเป็น 0 แล้วล้มเหลวอย่างชัดเจน

แหล่งเสียงอิสระที่มีการประทับเวลา

คลาสการจับเสียงทั้งสองมี overload แบบประทับเวลา CapturedAudioChunk มี samples โมโนที่ sampleRate และ Mach hostTime แบบไม่บังคับสำหรับเฟรมอินพุตแรก เนื่องจากทั้งสองแหล่งใช้ clock ของโฮสต์เดียวกัน แอปจึงแยก PCM ไว้และเรียงเหตุการณ์ transcript ที่ได้โดยไม่ต้องมิกซ์เสียงได้

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 สร้าง chunk เหล่านี้ตามที่ร้องขอโดยจำกัดหน่วยความจำ ทำ resample ด้วยสถานะ converter ที่ต่อเนื่อง และเฉลี่ยทุกช่องอินพุตเป็นค่าเริ่มต้น ใช้ .first หรือ .select([indices]) เมื่อทราบการกำหนดช่องสัญญาณ

StreamingRecognitionModel และ StreamingRecognitionSession เป็น API แบบเพิ่มทีละส่วนเดียวที่รักษา cache สำหรับ Parakeet Streaming และ Nemotron Core ML/MLX:

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

ตัวอ่าน protobuf ที่ใช้ร่วมสำหรับไฟล์ .model ของ SentencePiece อยู่ใน AudioCommon ทุกโมดูลที่ต้องการ decode SentencePiece pieces (PersonaPlex, OmnilingualASR, การ port ASR / TTS ในอนาคต) จะสร้าง decoder ของตัวเองบนตัวอ่านเดียวนี้แทนการ implement รูปแบบ 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 ครอบคลุมด้วย 7 unit test ใน Tests/AudioCommonTests/SentencePieceModelTests

MLXCommon.SDPA

ตัวช่วย scaled dot-product attention ที่ใช้ร่วมในทุกโมดูล attention ของ MLX (Qwen3-ASR / Qwen3-TTS / Qwen3-Chat / CosyVoice / PersonaPlex / OmnilingualASR) แต่ละโมดูลเก็บ projection ของตัวเอง — SDPA จัดการเฉพาะ boilerplate ของ 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 สำหรับมิติ batch ดังนั้นตัวช่วยเหล่านี้จึงประกอบกับกราฟ MLX.compile(shapeless:) ที่ batch เปลี่ยนได้ที่ runtime (เช่น Qwen3-TTS Talker ที่ decode แบบ autoregressive)

เซิร์ฟเวอร์ HTTP API

ไฟล์ปฏิบัติการ speech-server เปิดทุกโมเดลใน speech-swift เป็น endpoint HTTP REST บวกกับ endpoint WebSocket ที่ implement OpenAI Realtime API โมเดลถูกโหลดแบบ lazy เมื่อมีคำขอครั้งแรก; ใส่ --preload เพื่อ warm ทั้งหมดตอนเริ่มต้น

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

# โหลดทุกโมเดลล่วงหน้าตอนเริ่มต้น
.build/release/speech-server --port 8080 --preload

REST Endpoints

EndpointMethodคำขอการตอบกลับ
/transcribePOSTbody 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? }body audio/wav (Qwen3-TTS, CosyVoice, Kokoro)
/respondPOSTbody audio/wavbody audio/wav (PersonaPlex)
/enhancePOSTbody audio/wavbody audio/wav (DeepFilterNet3)
/vadPOSTbody audio/wavรายการช่วงเป็น JSON
/diarizePOSTbody audio/wavรายการ JSON DiarizedSegment
/embed-speakerPOSTbody audio/wavJSON [Float] (256 มิติ)

/v1/audio/speech จับคู่ชื่อโมเดลและเสียง TTS มาตรฐานกับเอนจินในเครื่อง ค่าเริ่มต้นคือ WAV ส่วน response_format: "pcm" จะส่งคืนเสียงโมโน PCM16 แบบ little-endian ที่ 24 kHz โดยไม่มีส่วนหัว ระบบจะปฏิเสธรูปแบบบีบอัดที่ไม่รองรับอย่างชัดเจน

/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 นาที

# ถอดเสียงไฟล์เป็นข้อความ
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)

Endpoint WebSocket ที่ ws://host:port/v1/realtime implement โปรโตคอล OpenAI Realtime ทุกข้อความเป็น JSON โดยมี type เป็นตัวบ่งชี้; payload เสียงเป็น PCM16 เข้ารหัส base64 ที่ 24 kHz mono

ระหว่างการโหลดโมเดลแบบ cold start หรือการสร้างผลลัพธ์ที่ใช้เวลานาน เซิร์ฟเวอร์จะส่งอีเวนต์ JSON ขนาดเบา realtime.keepalive และเฟรมควบคุม websocket pong ทุกประมาณ 15 วินาทีจนกว่าเอาต์พุตของโมเดลจะพร้อม ไคลเอนต์สามารถละเว้นอีเวนต์เหล่านี้หรือใช้เป็นตัวบ่งชี้กิจกรรมได้

เหตุการณ์ Client → Server

เหตุการณ์วัตถุประสงค์
session.updateกำหนดค่าเอนจิน, ภาษา, เสียง และรูปแบบเสียง
input_audio_buffer.appendต่อท้าย chunk PCM16 base64 ลงในบัฟเฟอร์อินพุต
input_audio_buffer.commitcommit เสียงที่บัฟเฟอร์ไว้สำหรับการถอดเป็นข้อความ
input_audio_buffer.clearทิ้งบัฟเฟอร์อินพุตปัจจุบัน
response.createขอการสังเคราะห์ TTS สำหรับข้อความ/คำสั่งที่ให้

เหตุการณ์ Server → Client

เหตุการณ์ความหมาย
session.createdhandshake เสร็จสมบูรณ์ การตั้งค่าเริ่มต้นถูกปล่อย
session.updatedยืนยัน session.update ล่าสุดแล้ว
input_audio_buffer.committedเสียงถูกยอมรับและจัดคิวเพื่อถอดเป็นข้อความ
conversation.item.input_audio_transcription.completedผลลัพธ์ ASR พร้อมข้อความสุดท้าย
response.audio.deltachunk PCM16 base64 ของเสียงที่สังเคราะห์
response.audio.doneไม่มี chunk เสียงอีกสำหรับ response นี้
response.doneresponse เสร็จสิ้น (metadata + สถิติความหน่วง)
errorกรอบ error พร้อม type และ message

แบ่งช่วงสนทนาอัตโนมัติด้วย VAD ฝั่งเซิร์ฟเวอร์

ค่าเริ่มต้นยังคงเป็น input_audio_buffer.commit แบบกำหนดเอง ตั้ง session.turn_detection เป็น server_vad เพื่อเรียกใช้ Silero VAD บนเซิร์ฟเวอร์และถอดเสียงแต่ละช่วงคำพูดที่จบแล้วโดยอัตโนมัติ ระบบเก็บเสียงก่อนเริ่มพูด จำกัด buffer ขณะว่าง ส่ง event 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: 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 product ชื่อ AudioServer ตัวอย่าง client บนเบราว์เซอร์มาด้วยที่ Examples/websocket-client.html — เปิดควบคู่กับเซิร์ฟเวอร์ที่กำลังรันเพื่อใช้งานรอบ ASR + TTS แบบเต็ม

การดาวน์โหลดโมเดล

โมเดลทั้งหมดดาวน์โหลดจาก HuggingFace ในการใช้งานครั้งแรกและเก็บแคชใน ~/Library/Caches/qwen3-speech/ โมดูล AudioCommon มี HuggingFaceDownloader ที่ใช้ร่วมกันสำหรับจัดการการดาวน์โหลด การแคช และการตรวจสอบความสมบูรณ์

การลองดาวน์โหลดใหม่

ตัวโหลดโมเดลและ API ดาวน์โหลดรับ downloadOptions: DownloadOptions ก่อนรอแต่ละครั้ง onRetry จะได้รับ DownloadRetryEvent ที่มี modelId, attempt, maxAttempts, error เดิม และ retryInSeconds ค่า nil หมายถึงไม่มีการลองใหม่เหลืออยู่ แต่การโหลดอาจยังสำเร็จได้ด้วยแคชที่ครบถ้วน

คอลแบ็กทำงานแบบซิงโครนัสและเป็น @Sendable โดยไม่รับประกันว่าจะทำงานบน main actor การยกเลิกไม่สร้างเหตุการณ์ลองใหม่ ตั้งค่า retryDelaysSeconds เพื่อปรับนโยบาย ([] หมายถึงลองครั้งเดียว) โดยอาร์กิวเมนต์ที่ระบุโดยตรงใน API ดาวน์โหลดมีลำดับความสำคัญสูงกว่า จำนวนครั้งเริ่มต้นและความคืบหน้าตามไบต์ยังคงเดิม การลองที่ล้มเหลวยังถูกบันทึกที่ระดับ notice ผ่าน AudioLog.download

การยกเลิกแบบร่วมมือ

สำหรับการถอดเสียงด้วย MLX เมธอด Qwen3ASRModel.transcribeCheckingCancellation(audio:sampleRate:options:) จะโยน CancellationError เมื่อตรวจพบการยกเลิกงานก่อนการสกัดคุณลักษณะ การเข้ารหัส การเติมข้อมูลล่วงหน้าให้ตัวถอดรหัส หรือขั้นตอนการถอดรหัส งานที่ส่งไปยัง GPU แล้วอาจทำงานจนเสร็จ API แบบซิงโครนัส transcribe และ transcribeBatch ยังคงไม่โยนข้อผิดพลาดและไม่สนใจการยกเลิกงาน speech-server ใช้จุดเรียกเข้าที่รองรับการยกเลิกสำหรับ Qwen3-ASR

MOSS-TTS-Nano

MossTTSNanoModel ประมวลผลคำขอตามลำดับและสร้างสถานะ CoreML ใหม่ทุกประโยค synthesize และ synthesizeStream เก็บสเตอริโอ ส่วน SpeechGenerationModel ให้เสียงผสมโมโน ใช้ language: nil สตรีมจบด้วยตัวทำเครื่องหมายสุดท้ายที่ว่าง

import AudioCommon
import MossTTSNano

let model = try await MossTTSNanoModel.fromPretrained()
let reference = try await model.encodeReference(url: referenceURL)
let result = try await model.synthesize(
    text: "The weather is nice today.", reference: reference,
    options: MossTTSNanoOptions(maxFrames: 300, seed: 1))
try WAVWriter.writeStereo(left: result.left, right: result.right,
    sampleRate: 48000, to: outputURL)

MOSS-TTS-Nano

การจัดการโมเดลแบบเลือกใช้

SpeechRuntime รองรับการโหลดร่วมกัน การรับงาน FIFO ที่ยกเลิกได้ และการรันตามลำดับ Qwen3ASRRuntime เลือก MLX หรือ CoreML แบบเต็มผ่านตัวอธิบายที่ชัดเจน โดยยังใช้ API ดั้งเดิมและตัวเลือก CLI ได้

เซิร์ฟเวอร์ถือสิทธิ์รันตลอดคำขอ รวมถึงเอาต์พุตสตรีม และให้รอได้สูงสุด 32 คำขอ ความล้มเหลว ASR ส่งเป็นข้อผิดพลาด HTTP หรือเหตุการณ์ผิดพลาด WebSocket ส่วนผลถอดเสียงว่างที่ถูกต้องยังสำเร็จ การเล่นเสียงเก็บตัวอย่างที่ล้นไว้ โมเดลในแคชและเสียงรอเล่นยังใช้หน่วยความจำ

import Qwen3ASRRuntime

let runtime = Qwen3ASRRuntime(descriptor: Qwen3ASRDescriptors.small)
let text = try await runtime.transcribe(audio: samples, sampleRate: 16000)
try await runtime.unload()

Voxtral TTS

Voxtral 4B TTS 2603 ทำงานในเครื่องบน Apple Silicon ผ่านโมดูล VoxtralTTS รองรับเสียงที่ตั้งไว้ล่วงหน้า 20 เสียง 9 ภาษา และเสียงโมโน 24 kHz

import VoxtralTTS

let model = try await VoxtralTTSModel.fromPretrained()
for try await chunk in model.generateStream(text: "Hello, welcome to Voxtral.", voice: "casual_male") {
    print(chunk.frameIndex, chunk.samples.count, chunk.isFinal)
}

รองรับเฉพาะเสียงที่กำหนดไว้ล่วงหน้า ยังไม่รองรับการโคลนจากเสียงอ้างอิง SDK, CLI, WebSocket และ HTTP PCM ส่งเสียงได้ระหว่างการสร้าง ต้องส่งข้อความอินพุตทั้งหมดตั้งแต่เริ่มต้น น้ำหนักโมเดลใช้สัญญาอนุญาต CC BY-NC 4.0

import AudioCommon

let player = StreamingAudioPlayer()
player.skipInitialSilence = false

อ่านคู่มือ Voxtral TTS