API และโปรโตคอล
รันไทม์แบบเนทีฟสำหรับ Apple · PocketTTS
PocketTTS เพิ่มการสังเคราะห์เสียง CoreML แบบเนทีฟให้ speech-swift ทุกขั้นตอนใช้ CPU เท่านั้นโดยค่าเริ่มต้น เสียง Alba ภาษาอังกฤษส่งเฟรมขนาด 1,920 ตัวอย่างที่ 24 kHz หรือ 80 มิลลิวินาทีต่อเฟรม
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
| Endpoint | Method | คำขอ | การตอบกลับ |
|---|---|---|---|
/transcribe | POST | body audio/wav | JSON { text } (Qwen3-ASR) |
/v1/audio/transcriptions | POST | ฟอร์ม multipart { file, model, response_format?, language?, prompt?, temperature? } | JSON, ข้อความ, 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? } | body audio/wav (Qwen3-TTS, CosyVoice, Kokoro) |
/respond | POST | body audio/wav | body audio/wav (PersonaPlex) |
/enhance | POST | body audio/wav | body audio/wav (DeepFilterNet3) |
/vad | POST | body audio/wav | รายการช่วงเป็น JSON |
/diarize | POST | body audio/wav | รายการ JSON DiarizedSegment |
/embed-speaker | POST | body audio/wav | JSON [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.commit | commit เสียงที่บัฟเฟอร์ไว้สำหรับการถอดเป็นข้อความ |
input_audio_buffer.clear | ทิ้งบัฟเฟอร์อินพุตปัจจุบัน |
response.create | ขอการสังเคราะห์ TTS สำหรับข้อความ/คำสั่งที่ให้ |
เหตุการณ์ Server → Client
| เหตุการณ์ | ความหมาย |
|---|---|
session.created | handshake เสร็จสมบูรณ์ การตั้งค่าเริ่มต้นถูกปล่อย |
session.updated | ยืนยัน session.update ล่าสุดแล้ว |
input_audio_buffer.committed | เสียงถูกยอมรับและจัดคิวเพื่อถอดเป็นข้อความ |
conversation.item.input_audio_transcription.completed | ผลลัพธ์ ASR พร้อมข้อความสุดท้าย |
response.audio.delta | chunk PCM16 base64 ของเสียงที่สังเคราะห์ |
response.audio.done | ไม่มี chunk เสียงอีกสำหรับ response นี้ |
response.done | response เสร็จสิ้น (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)การจัดการโมเดลแบบเลือกใช้
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