-
-
Notifications
You must be signed in to change notification settings - Fork 159
Expand file tree
/
Copy pathAudioServer.swift
More file actions
1947 lines (1780 loc) · 83.2 KB
/
Copy pathAudioServer.swift
File metadata and controls
1947 lines (1780 loc) · 83.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import Foundation
import Hummingbird
import HummingbirdCore
import HummingbirdWebSocket
import NIOCore
import Qwen3ASR
import Qwen3TTS
import Qwen3TTSCoreML
import CosyVoiceTTS
import ParakeetASR
import ParakeetStreamingASR
import NemotronStreamingASR
import OmnilingualASR
import KokoroTTS
import VoxCPM2TTS
import IndicMioTTS
import MagpieTTS
import MagpieTTSCoreML
import VibeVoiceTTS
import PersonaPlex
import HibikiTranslate
import SpeechEnhancement
import SpeechVAD
import AudioCommon
// MARK: - Server
public struct AudioServer {
let state: ModelState
let realtimeState: any RealtimeModelLoading
let host: String
let port: Int
public init(host: String = "127.0.0.1", port: Int = 8080, preload: Bool = false) {
let state = ModelState()
self.state = state
self.realtimeState = state
self.host = host
self.port = port
}
init(
host: String = "127.0.0.1",
port: Int = 8080,
state: ModelState = ModelState(),
realtimeState: any RealtimeModelLoading
) {
self.state = state
self.realtimeState = realtimeState
self.host = host
self.port = port
}
public func run() async throws {
let router = buildRouter()
let realtimeState = self.realtimeState
let wsConfig = realtimeWebSocketServerConfiguration()
let wsServer: HTTPServerBuilder = .http1WebSocketUpgrade(configuration: wsConfig) { head, _, _ in
let path = head.path ?? ""
guard path == "/v1/realtime" else { return .dontUpgrade }
return .upgrade([:]) { inbound, outbound, _ in
try await handleRealtimeWS(inbound: inbound, outbound: outbound, state: realtimeState)
}
}
let app = Application(
router: router,
server: wsServer,
configuration: .init(address: .hostname(host, port: port)))
try await app.run()
}
public func preloadModels() async throws {
_ = try await state.loadASR()
_ = try await state.loadTTS()
_ = try await state.loadPersonaPlex()
_ = try await state.loadEnhancer()
}
// MARK: - HTTP Routes
func buildRouter() -> Router<BasicRequestContext> {
let router = Router()
let state = self.state
router.get("/health") { _, _ in
Response(
status: .ok,
headers: [.contentType: "application/json"],
body: .init(byteBuffer: .init(string: "{\"status\":\"ok\"}")))
}
// Shared row builder so the two model-list endpoints can't drift
// apart in shape.
@Sendable
func modelRow(_ v: ModelVariant) -> [String: Any] {
return [
"id": v.name,
"object": "model",
"engine": v.engine,
"kind": v.kind.rawValue,
"model_id": v.modelId,
"aliases": v.aliases
]
}
router.get("/v1/models") { _, _ in
// The complete model catalog — every model the server can run,
// across every kind (ASR, TTS, S2S, enhance, music, VAD,
// diarize, speaker, separate, SR). Clients can introspect
// what's selectable across both the Realtime WS and the HTTP
// routes without trying names blindly.
return jsonResponse([
"object": "list",
"data": MODEL_REGISTRY.map(modelRow)
] as [String: Any])
}
router.get("/v1/realtime/models") { _, _ in
// The Realtime-protocol subset — only kinds that the WS
// session.update model field actually dispatches to (ASR,
// TTS, S2S). Convenience filter for clients that only care
// about the WS surface; same registry backs both endpoints.
let realtime: Set<ModelVariant.Kind> = [.asr, .tts, .s2s]
let filtered = MODEL_REGISTRY.filter { realtime.contains($0.kind) }
return jsonResponse([
"object": "list",
"data": filtered.map(modelRow)
] as [String: Any])
}
router.post("/v1/audio/transcriptions") { request, _ in
try await handleOpenAITranscriptions(request: request, state: state)
}
router.post("/v1/audio/speech") { request, _ in
try await handleOpenAISpeech(request: request, state: state)
}
router.post("/transcribe") { request, _ in
let body = try await request.body.collect(upTo: 50 * 1024 * 1024)
let params = try RequestParams.parse(body, contentType: request.headers[.contentType])
// Validate the model name BEFORE reading audio so a typo in
// the model field returns the actual problem, not a confusing
// "missing audio" error. Unknown names error rather than
// silently fall back, so clients learn fast.
let variant: ModelVariant
if let modelName = params.string("model"), !modelName.isEmpty {
if let v = resolveModelToASRVariant(modelName) {
variant = v
} else {
return errorResponse(
"Unknown ASR model: \(modelName)",
status: .badRequest)
}
} else {
variant = defaultVariant(forEngine: "parakeet", kind: .asr)
}
guard let audioData = params.audioData else {
return errorResponse("Missing audio data", status: .badRequest)
}
let sampleRate = params.int("sample_rate") ?? 16000
let audio = try decodeWAVData(audioData, targetSampleRate: sampleRate)
let language = params.string("language")
let text = try await dispatchTranscribe(
audio: audio, sampleRate: sampleRate,
variant: variant, language: language, state: state)
return jsonResponse([
"text": text,
"model": variant.name,
"duration": round(Double(audio.count) / Double(sampleRate) * 100) / 100
] as [String: Any])
}
router.post("/speak") { request, _ in
let body = try await request.body.collect(upTo: 1024 * 1024)
let params = try RequestParams.parse(body, contentType: request.headers[.contentType])
guard let text = params.text else {
return errorResponse("Missing 'text' field", status: .badRequest)
}
// Variant precedence: model > legacy engine > default Kokoro.
// Legacy engine field kept so old callers don't break.
let variant: ModelVariant
if let modelName = params.string("model"), !modelName.isEmpty {
if let v = resolveModelToTTSVariant(modelName) {
variant = v
} else {
return errorResponse(
"Unknown TTS model: \(modelName)",
status: .badRequest)
}
} else if let engineName = params.string("engine"), !engineName.isEmpty {
variant = resolveModelToTTSVariant(engineName)
?? defaultVariant(forEngine: "kokoro", kind: .tts)
} else {
variant = defaultVariant(forEngine: "kokoro", kind: .tts)
}
let language = params.string("language") ?? "english"
let samples = try await dispatchSynthesize(
text: text, variant: variant, language: language, state: state)
let wavData = try encodeWAV(samples: samples, sampleRate: 24000)
return Response(
status: .ok,
headers: [.contentType: "audio/wav"],
body: .init(byteBuffer: .init(data: wavData)))
}
router.post("/respond") { request, _ in
let body = try await request.body.collect(upTo: 50 * 1024 * 1024)
let params = try RequestParams.parse(body, contentType: request.headers[.contentType])
// /respond is the PersonaPlex/Hibiki entry point on HTTP — same
// S2S surface the Realtime WS uses, just request-shaped. The
// `model` field picks which S2S engine (and which quantization
// bundle). Default stays PersonaPlex 4-bit for back-compat.
//
// Validate the model name first so a typo doesn't trip the
// audio guard or the voice guard with a misleading error.
let variant: ModelVariant
if let modelName = params.string("model"), !modelName.isEmpty {
if let v = resolveModelToS2SVariant(modelName) {
variant = v
} else {
return errorResponse(
"Unknown speech-to-speech model: \(modelName)",
status: .badRequest)
}
} else {
variant = defaultVariant(forEngine: "personaplex", kind: .s2s)
}
guard let audioData = params.audioData, !audioData.isEmpty else {
return errorResponse("Missing audio data", status: .badRequest)
}
let maxSteps = params.int("max_steps") ?? 200
let audio = try decodeWAVData(audioData, targetSampleRate: 24000)
let responseAudio: [Float]
var responseTranscript: String?
switch variant.engine {
case "personaplex":
// PersonaPlex consumes a `voice` preset; Hibiki ignores it.
// Keep the voice guard inside the personaplex branch so a
// bad `voice` value never blocks a Hibiki translate call.
let voiceName = params.string("voice") ?? "NATM0"
guard let voice = PersonaPlexVoice(rawValue: voiceName) else {
return errorResponse("Unknown voice: \(voiceName)", status: .badRequest)
}
let model = try await state.loadPersonaPlex()
let result = model.respond(
userAudio: audio,
voice: voice,
maxSteps: maxSteps)
responseAudio = result.audio
if let dec = state.spmDecoder, !result.textTokens.isEmpty {
responseTranscript = dec.decode(result.textTokens)
}
case "hibiki":
let model = try await state.loadHibiki(modelId: variant.modelId)
let lang = params.string("language") ?? "french"
let sourceLang = HibikiSourceLanguage(
rawValue: mapToHibikiSourceLanguage(lang)) ?? .fr
let result = model.translate(sourceAudio: audio, sourceLanguage: sourceLang)
responseAudio = result.audio
default:
return errorResponse(
"S2S engine '\(variant.engine)' not enabled in this build",
status: .badRequest)
}
let wavData = try encodeWAV(samples: responseAudio, sampleRate: 24000)
let duration = Double(responseAudio.count) / 24000.0
if params.string("format") == "json" {
var json: [String: Any] = [
"duration": round(duration * 100) / 100,
"model": variant.name
]
if let t = responseTranscript { json["transcript"] = t }
json["audio_base64"] = wavData.base64EncodedString()
return jsonResponse(json)
}
return Response(
status: .ok,
headers: [.contentType: "audio/wav"],
body: .init(byteBuffer: .init(data: wavData)))
}
router.post("/enhance") { request, _ in
let body = try await request.body.collect(upTo: 50 * 1024 * 1024)
let params = try RequestParams.parse(body, contentType: request.headers[.contentType])
// Variant precedence: model > default. Same pattern as the
// other registry-driven routes — typos return 400 with a
// specific message rather than silently using the default.
let variant: ModelVariant
if let modelName = params.string("model"), !modelName.isEmpty {
if let v = resolveModelVariant(modelName), v.kind == .enhance {
variant = v
} else {
return errorResponse(
"Unknown enhance model: \(modelName)",
status: .badRequest)
}
} else {
variant = defaultVariant(forEngine: "deepfilternet3", kind: .enhance)
}
guard let audioData = params.audioData else {
return errorResponse("Missing audio data", status: .badRequest)
}
let enhancer = try await state.loadEnhancer(modelId: variant.modelId)
let audio = try decodeWAVData(audioData, targetSampleRate: 48000)
// Auto-chunk long inputs. The body cap of 50 MB allows roughly 4-5
// min of 48 kHz mono PCM, which can easily exceed the model's 60 s
// single-shot cap. enhanceChunked() does its own short-input
// fast-path so we route everything through it (bit-identical to
// enhance() when duration ≤ 45 s).
let enhanced = try enhancer.enhanceChunked(audio: audio, sampleRate: 48000)
let wavData = try encodeWAV(samples: enhanced, sampleRate: 48000)
return Response(
status: .ok,
headers: [.contentType: "audio/wav"],
body: .init(byteBuffer: .init(data: wavData)))
}
return router
}
}
func realtimeWebSocketServerConfiguration() -> WebSocketServerConfiguration {
// Hummingbird's autoPing watchdog expects a payload-matching control-frame
// pong, which URLSessionWebSocketTask doesn't reliably surface while a
// receive is pending behind synchronous work. The session-scoped
// `realtime.keepalive` text frame in handleRealtimeWS replaces it: it
// probes the transport every 15s for the whole connection lifetime,
// including idle gaps between operations.
WebSocketServerConfiguration(
maxFrameSize: 1 << 24,
autoPing: .disabled)
}
// MARK: - Lazy Model State
protocol RealtimeModelLoading: Sendable {
func loadVAD() async throws -> any StreamingVADProvider
func loadQwen3ASR(modelId: String) async throws -> Qwen3ASRModel
func loadParakeet(modelId: String) async throws -> ParakeetASRModel
func loadParakeetStreaming(modelId: String) async throws -> ParakeetStreamingASRModel
func loadNemotron(modelId: String) async throws -> NemotronStreamingASRModel
func loadOmnilingual(modelId: String) async throws -> OmnilingualASRModel
func loadQwen3TTS(modelId: String) async throws -> Qwen3TTSModel
func loadCosyVoice(modelId: String) async throws -> CosyVoiceTTSModel
func loadKokoro(modelId: String) async throws -> KokoroTTSModel
func loadVoxCPM2(modelId: String) async throws -> VoxCPM2TTSModel
func loadIndicMio(modelId: String) async throws -> IndicMioTTSModel
func loadMagpie() async throws -> MagpieTTS
func loadMagpieCoreML() async throws -> MagpieTTSCoreML
func loadQwen3TTSCoreML(modelId: String) async throws -> Qwen3TTSCoreMLModel
func loadVibeVoice(modelId: String) async throws -> VibeVoiceTTSModel
func loadVibeVoice15B(modelId: String) async throws -> VibeVoice15BTTSModel
func loadHibiki(modelId: String) async throws -> HibikiTranslateModel
func loadPersonaPlex() async throws -> PersonaPlexModel
}
struct RealtimeModelLoadingFailure: Error, CustomStringConvertible {
let description: String
init(_ description: String = "forced realtime model failure") {
self.description = description
}
}
final class FailingRealtimeModelLoading: RealtimeModelLoading, @unchecked Sendable {
let error: Error
let beforeFailure: (@Sendable () -> Void)?
let vadOverride: (any StreamingVADProvider)?
init(
error: Error = RealtimeModelLoadingFailure(),
beforeFailure: (@Sendable () -> Void)? = nil,
vadOverride: (any StreamingVADProvider)? = nil
) {
self.error = error
self.beforeFailure = beforeFailure
self.vadOverride = vadOverride
}
private func fail<T>() async throws -> T {
beforeFailure?()
throw error
}
func loadVAD() async throws -> any StreamingVADProvider {
if let vadOverride { return vadOverride }
beforeFailure?()
throw error
}
func loadQwen3ASR(modelId: String) async throws -> Qwen3ASRModel {
try await fail()
}
func loadParakeet(modelId: String) async throws -> ParakeetASRModel {
try await fail()
}
func loadParakeetStreaming(modelId: String) async throws -> ParakeetStreamingASRModel {
try await fail()
}
func loadNemotron(modelId: String) async throws -> NemotronStreamingASRModel {
try await fail()
}
func loadOmnilingual(modelId: String) async throws -> OmnilingualASRModel {
try await fail()
}
func loadQwen3TTS(modelId: String) async throws -> Qwen3TTSModel {
try await fail()
}
func loadCosyVoice(modelId: String) async throws -> CosyVoiceTTSModel {
try await fail()
}
func loadKokoro(modelId: String) async throws -> KokoroTTSModel {
try await fail()
}
func loadVoxCPM2(modelId: String) async throws -> VoxCPM2TTSModel {
try await fail()
}
func loadIndicMio(modelId: String) async throws -> IndicMioTTSModel {
try await fail()
}
func loadMagpie() async throws -> MagpieTTS {
try await fail()
}
func loadMagpieCoreML() async throws -> MagpieTTSCoreML {
try await fail()
}
func loadQwen3TTSCoreML(modelId: String) async throws -> Qwen3TTSCoreMLModel {
try await fail()
}
func loadVibeVoice(modelId: String) async throws -> VibeVoiceTTSModel {
try await fail()
}
func loadVibeVoice15B(modelId: String) async throws -> VibeVoice15BTTSModel {
try await fail()
}
func loadHibiki(modelId: String) async throws -> HibikiTranslateModel {
try await fail()
}
func loadPersonaPlex() async throws -> PersonaPlexModel {
try await fail()
}
}
final class ModelState: RealtimeModelLoading, @unchecked Sendable {
// Per-modelId caches: switching variants of the same engine (e.g.
// qwen3-asr-0.6b → qwen3-asr-1.7b) keeps both loaded so flipping back
// is instant. The typical session picks one variant and sticks, but
// multi-tenant servers benefit from holding the small set warm.
private var qwen3ASR: [String: Qwen3ASRModel] = [:]
private var parakeet: [String: ParakeetASRModel] = [:]
private var parakeetStreaming: [String: ParakeetStreamingASRModel] = [:]
private var nemotron: [String: NemotronStreamingASRModel] = [:]
private var omnilingual: [String: OmnilingualASRModel] = [:]
private var qwen3TTS: [String: Qwen3TTSModel] = [:]
private var qwen3TTSCoreML: [String: Qwen3TTSCoreMLModel] = [:]
private var cosyvoice: [String: CosyVoiceTTSModel] = [:]
private var kokoro: [String: KokoroTTSModel] = [:]
private var voxcpm2: [String: VoxCPM2TTSModel] = [:]
private var indicMio: [String: IndicMioTTSModel] = [:]
private var magpie: MagpieTTS?
private var magpieCoreML: MagpieTTSCoreML?
private var vibevoice: [String: VibeVoiceTTSModel] = [:]
private var vibevoice15B: [String: VibeVoice15BTTSModel] = [:]
private var personaplex: PersonaPlexModel?
private var hibikiByModelId: [String: HibikiTranslateModel] = [:]
private var enhancer: SpeechEnhancer?
var spmDecoder: SentencePieceDecoder?
func loadVAD() async throws -> any StreamingVADProvider {
// Silero carries recurrent/context state. Give every websocket session
// its own instance so clear/reset and concurrent turns cannot corrupt
// another client's detector state. The downloaded bundle remains in
// the shared on-disk model cache.
print("[server] Loading Silero VAD...")
return try await SileroVADModel.fromPretrained(
engine: .coreml,
progressHandler: logProgress)
}
func loadQwen3ASR(modelId: String) async throws -> Qwen3ASRModel {
if let m = qwen3ASR[modelId] { return m }
print("[server] Loading Qwen3-ASR (\(modelId))...")
let m = try await Qwen3ASRModel.fromPretrained(modelId: modelId, progressHandler: logProgress)
qwen3ASR[modelId] = m
return m
}
/// Back-compat shim for the HTTP routes that still want the default
/// Qwen3-ASR build without naming a variant.
func loadASR() async throws -> Qwen3ASRModel {
try await loadQwen3ASR(modelId: "aufklarer/Qwen3-ASR-0.6B-MLX-4bit")
}
func loadParakeet(modelId: String) async throws -> ParakeetASRModel {
if let m = parakeet[modelId] { return m }
print("[server] Loading Parakeet (\(modelId))...")
let m = try await ParakeetASRModel.fromPretrained(modelId: modelId, progressHandler: logProgress)
parakeet[modelId] = m
return m
}
func loadParakeetStreaming(modelId: String) async throws -> ParakeetStreamingASRModel {
if let m = parakeetStreaming[modelId] { return m }
print("[server] Loading Parakeet EOU Streaming (\(modelId))...")
let m = try await ParakeetStreamingASRModel.fromPretrained(modelId: modelId, progressHandler: logProgress)
parakeetStreaming[modelId] = m
return m
}
func loadNemotron(modelId: String) async throws -> NemotronStreamingASRModel {
if let m = nemotron[modelId] { return m }
print("[server] Loading Nemotron Streaming ASR (\(modelId))...")
let m = try await NemotronStreamingASRModel.fromPretrained(modelId: modelId, progressHandler: logProgress)
nemotron[modelId] = m
return m
}
func loadOmnilingual(modelId: String) async throws -> OmnilingualASRModel {
if let m = omnilingual[modelId] { return m }
print("[server] Loading Omnilingual ASR (\(modelId))...")
let m = try await OmnilingualASRModel.fromPretrained(modelId: modelId, progressHandler: logProgress)
omnilingual[modelId] = m
return m
}
func loadQwen3TTS(modelId: String) async throws -> Qwen3TTSModel {
if let m = qwen3TTS[modelId] { return m }
print("[server] Loading Qwen3-TTS (\(modelId))...")
let m = try await Qwen3TTSModel.fromPretrained(modelId: modelId, progressHandler: logProgress)
qwen3TTS[modelId] = m
return m
}
/// Back-compat shim — default Qwen3-TTS bundle.
func loadTTS() async throws -> Qwen3TTSModel {
try await loadQwen3TTS(modelId: Qwen3TTSModel.defaultModelId)
}
func loadCosyVoice(modelId: String) async throws -> CosyVoiceTTSModel {
if let m = cosyvoice[modelId] { return m }
print("[server] Loading CosyVoice (\(modelId))...")
let m = try await CosyVoiceTTSModel.fromPretrained(modelId: modelId, progressHandler: logProgress)
cosyvoice[modelId] = m
return m
}
/// Back-compat shim — default CosyVoice bundle for HTTP routes.
func loadCosyVoice() async throws -> CosyVoiceTTSModel {
try await loadCosyVoice(modelId: CosyVoiceTTSModel.defaultModelId)
}
func loadKokoro(modelId: String) async throws -> KokoroTTSModel {
if let m = kokoro[modelId] { return m }
print("[server] Loading Kokoro (\(modelId))...")
let m = try await KokoroTTSModel.fromPretrained(modelId: modelId, progressHandler: logProgress)
kokoro[modelId] = m
return m
}
func loadVoxCPM2(modelId: String) async throws -> VoxCPM2TTSModel {
if let m = voxcpm2[modelId] { return m }
print("[server] Loading VoxCPM2 (\(modelId))...")
let m = try await VoxCPM2TTSModel.fromPretrained(modelId: modelId, progressHandler: logProgress)
voxcpm2[modelId] = m
return m
}
func loadIndicMio(modelId: String) async throws -> IndicMioTTSModel {
if let m = indicMio[modelId] { return m }
print("[server] Loading Indic-Mio (\(modelId))...")
let m = try await IndicMioTTSModel.fromPretrained(modelId: modelId, progressHandler: logProgress)
indicMio[modelId] = m
return m
}
/// Magpie ships as a single fixed bundle today — `fromPretrained` takes
/// a `MagpieTTSVariant` enum, not an HF slug. The registry's modelId is
/// informational; the loader uses the variant default.
func loadMagpie() async throws -> MagpieTTS {
if let m = magpie { return m }
print("[server] Loading Magpie-TTS Multilingual...")
let m = try await MagpieTTS.fromPretrained()
magpie = m
return m
}
/// MagpieTTSCoreML uses a fixed CoreML bundle; like MLX-Magpie, no
/// per-modelId caching — there's one variant.
func loadMagpieCoreML() async throws -> MagpieTTSCoreML {
if let m = magpieCoreML { return m }
print("[server] Loading Magpie-TTS CoreML...")
let m = try await MagpieTTSCoreML.fromPretrained()
magpieCoreML = m
return m
}
func loadQwen3TTSCoreML(modelId: String) async throws -> Qwen3TTSCoreMLModel {
if let m = qwen3TTSCoreML[modelId] { return m }
print("[server] Loading Qwen3-TTS CoreML (\(modelId))...")
let m = try await Qwen3TTSCoreMLModel.fromPretrained(modelId: modelId, progressHandler: logProgress)
qwen3TTSCoreML[modelId] = m
return m
}
func loadVibeVoice(modelId: String) async throws -> VibeVoiceTTSModel {
if let m = vibevoice[modelId] { return m }
print("[server] Loading VibeVoice Realtime (\(modelId))...")
var cfg = VibeVoiceTTSModel.Configuration()
cfg.modelId = modelId
let m = try await VibeVoiceTTSModel.fromPretrained(configuration: cfg, progressHandler: logProgress)
vibevoice[modelId] = m
return m
}
func loadVibeVoice15B(modelId: String) async throws -> VibeVoice15BTTSModel {
if let m = vibevoice15B[modelId] { return m }
print("[server] Loading VibeVoice 1.5B (\(modelId))...")
var cfg = VibeVoice15BTTSModel.Configuration()
cfg.modelId = modelId
let m = try await VibeVoice15BTTSModel.fromPretrained(configuration: cfg, progressHandler: logProgress)
vibevoice15B[modelId] = m
return m
}
func loadHibiki(modelId: String) async throws -> HibikiTranslateModel {
if let m = hibikiByModelId[modelId] { return m }
print("[server] Loading Hibiki (\(modelId))...")
let m = try await HibikiTranslateModel.fromPretrained(modelId: modelId, progressHandler: logProgress)
hibikiByModelId[modelId] = m
return m
}
func loadPersonaPlex() async throws -> PersonaPlexModel {
if let m = personaplex { return m }
print("[server] Loading PersonaPlex 7B...")
let m = try await PersonaPlexModel.fromPretrained(progressHandler: logProgress)
personaplex = m
do {
// Resolve the SPM tokenizer cache dir from the LOADED model's
// modelId — not a hardcoded 4-bit repo. Same root cause as #300:
// 8-bit users were silently falling back to no-decoder mode
// because the cache dir lookup pointed at the wrong directory.
let cacheDir = try HuggingFaceDownloader.getCacheDirectory(for: m.modelId)
let spmPath = cacheDir.appendingPathComponent("tokenizer_spm_32k_3.model").path
if FileManager.default.fileExists(atPath: spmPath) {
spmDecoder = try SentencePieceDecoder(modelPath: spmPath)
}
} catch {}
return m
}
private var enhancerByModelId: [String: SpeechEnhancer] = [:]
func loadEnhancer(modelId: String) async throws -> SpeechEnhancer {
if let m = enhancerByModelId[modelId] { return m }
print("[server] Loading DeepFilterNet3 (\(modelId))...")
let m = try await SpeechEnhancer.fromPretrained(modelId: modelId, progressHandler: logProgress)
enhancerByModelId[modelId] = m
// Mirror to the legacy slot too so back-compat callers see the
// last-loaded enhancer.
enhancer = m
return m
}
/// Back-compat shim — default DeepFilterNet3 bundle.
func loadEnhancer() async throws -> SpeechEnhancer {
try await loadEnhancer(modelId: SpeechEnhancer.defaultModelId)
}
}
private func logProgress(_ progress: Double, _ status: String) {
print(" [\(Int(progress * 100))%] \(status)")
}
// MARK: - OpenAI Realtime API Handler
/// Per-connection session state for the OpenAI Realtime protocol.
///
/// Three engine slots tracked independently:
/// - `asrVariant` for the input transcription stage
/// - `ttsVariant` for the output synthesis stage
/// - `s2sVariant` for true speech-to-speech models (PersonaPlex, Hibiki)
///
/// When `s2sVariant` is non-nil it takes precedence — `input_audio_buffer.commit`
/// captures the audio for the S2S model and `response.create` runs the model
/// over that audio in one shot, bypassing the ASR→TTS compose path.
///
/// The `model` field is the canonical name last set by the client; it does
/// not control routing on its own — the resolved variants do.
private final class RealtimeSession {
/// ASR variant used by `input_audio_buffer.commit` when no S2S is active.
var asrVariant: ModelVariant
/// TTS variant used by `response.create` when no S2S is active.
var ttsVariant: ModelVariant
/// Optional speech-to-speech variant. When non-nil, the S2S path is
/// active and the ASR/TTS slots are bypassed for both events.
var s2sVariant: ModelVariant?
/// Canonical model name last set via session.update (or the default).
/// Stored verbatim — may be a registered name, an alias, or a forward-
/// compat name we accept-and-echo without dispatching.
var model: String
/// Legacy `engine` field — last value the client sent. Echoed back as
/// `session.engine` for back-compat with clients that don't read the
/// new `asr_engine` / `tts_engine` / `s2s_engine` fields.
var legacyEngine: String?
var language: String = "english"
/// PersonaPlex voice preset (e.g. "NATM0"). Only consulted when the
/// active S2S engine is PersonaPlex.
var voice: String?
/// Optional reference audio (PCM16 24 kHz) for voice-cloning engines
/// (VoxCPM2). Setting this forces the next response.create to VoxCPM2.
var voiceCloneReferenceAudio: [Float]?
/// Optional reference transcript that pairs with the cloning audio.
var voiceCloneReferenceText: String?
var inputAudioBuffer = Data()
var inputSampleRate: Int = 24000
/// Nil keeps explicit-commit behavior. A value enables automatic turns.
var turnDetection: RealtimeTurnDetectionConfig?
var vadController: RealtimeVADController?
var activeInputItemId: String?
/// A single trailing byte when websocket PCM16 frames split a sample.
var vadPCM16Carry = Data()
/// Audio captured by the last `input_audio_buffer.commit`, kept at the
/// protocol sample rate (24 kHz mono Float32). The S2S path reads from
/// here on the following `response.create`. Cleared after use.
var lastCommittedAudio: [Float]?
init() {
let defaultASR = defaultVariant(forEngine: "parakeet", kind: .asr)
let defaultTTS = defaultVariant(forEngine: "kokoro", kind: .tts)
self.asrVariant = defaultASR
self.ttsVariant = defaultTTS
self.s2sVariant = nil
// Canonical model defaults to the TTS variant name — that's what
// the user hears, and matches the OpenAI convention of `model`
// naming the user-facing output side.
self.model = defaultTTS.name
}
/// Echo value for the legacy `engine` field. If the client set it
/// explicitly we round-trip the raw string; otherwise we derive it from
/// the active TTS variant's engine slot.
var engineEcho: String {
return legacyEngine ?? ttsVariant.engine
}
}
private enum RealtimeTurnDetectionConfigurationError: Error, LocalizedError {
case unsupportedType(String)
case invalidValue(String)
var errorDescription: String? {
switch self {
case .unsupportedType(let type):
return "Unsupported turn_detection type '\(type)'"
case .invalidValue(let field):
return "Invalid turn_detection.\(field) value"
}
}
}
private func parseRealtimeTurnDetection(
_ value: Any
) throws -> RealtimeTurnDetectionConfig? {
if value is NSNull { return nil }
guard let object = value as? [String: Any] else {
throw RealtimeTurnDetectionConfigurationError.invalidValue("type")
}
let type = object["type"] as? String ?? "server_vad"
if type == "none" { return nil }
guard type == "server_vad" else {
throw RealtimeTurnDetectionConfigurationError.unsupportedType(type)
}
var config = RealtimeTurnDetectionConfig()
if let number = object["threshold"] as? NSNumber {
let value = number.floatValue
guard value >= 0, value <= 1 else {
throw RealtimeTurnDetectionConfigurationError.invalidValue("threshold")
}
config.threshold = value
}
if let number = object["prefix_padding_ms"] as? NSNumber {
let value = number.intValue
guard value >= 0, value <= 10_000 else {
throw RealtimeTurnDetectionConfigurationError.invalidValue("prefix_padding_ms")
}
config.prefixPaddingMilliseconds = value
}
if let number = object["silence_duration_ms"] as? NSNumber {
let value = number.intValue
guard value >= 0, value <= 60_000 else {
throw RealtimeTurnDetectionConfigurationError.invalidValue("silence_duration_ms")
}
config.silenceDurationMilliseconds = value
}
if let number = object["max_turn_duration_ms"] as? NSNumber {
let value = number.intValue
guard value >= 1_000, value <= 600_000 else {
throw RealtimeTurnDetectionConfigurationError.invalidValue("max_turn_duration_ms")
}
config.maxTurnDurationMilliseconds = value
}
return config
}
/// Resolve a model name to its ASR variant, if any.
///
/// Convenience over `resolveAllVariants` for callers that only care about
/// one slot (e.g. the OpenAI-standard `input_audio_transcription.model`
/// field, which explicitly targets ASR).
func resolveModelToASRVariant(_ model: String) -> ModelVariant? {
return resolveAllVariants(model).first(where: { $0.kind == .asr })
}
/// Resolve a model name to its TTS variant, if any.
func resolveModelToTTSVariant(_ model: String) -> ModelVariant? {
return resolveAllVariants(model).first(where: { $0.kind == .tts })
}
/// Resolve a model name to its S2S variant, if any.
func resolveModelToS2SVariant(_ model: String) -> ModelVariant? {
return resolveAllVariants(model).first(where: { $0.kind == .s2s })
}
/// Look up a name across every kind. A single name can match more than one
/// kind (e.g. "qwen3" hits both an ASR variant and a TTS variant), in which
/// case all matches are returned — used by the top-level `model` field on
/// session.update so a paired family name updates both slots in one step.
func resolveAllVariants(_ name: String) -> [ModelVariant] {
let lower = name.lowercased()
guard !lower.isEmpty else { return [] }
// Exact canonical-name match short-circuits everything else.
if let exact = MODEL_REGISTRY.first(where: { $0.name == lower }) {
return [exact]
}
// Alias match — collect one per kind so paired names update every
// slot they fit.
var hits: [ModelVariant] = []
for kind in [ModelVariant.Kind.asr, .tts, .s2s] {
if let v = MODEL_REGISTRY.first(where: { $0.kind == kind && $0.aliases.contains(lower) }) {
hits.append(v)
}
}
return hits
}
// MARK: - Legacy resolver shims
//
// These keep the original `resolveModelToASREngine` / `resolveModelToTTSEngine`
// / `resolveModelToEngine` surface for tests and any external callers that
// pinned to it. The body just walks the registry.
func resolveModelToASREngine(_ model: String) -> String? {
return resolveModelToASRVariant(model)?.engine
}
func resolveModelToTTSEngine(_ model: String) -> String? {
return resolveModelToTTSVariant(model)?.engine
}
func resolveModelToEngine(_ model: String) -> String? {
if let asr = resolveModelToASREngine(model) { return asr }
if let tts = resolveModelToTTSEngine(model) { return tts }
return nil
}
/// Handle /v1/realtime: OpenAI Realtime API compatible protocol.
/// All messages are JSON with a "type" field. Audio is base64-encoded PCM16 24kHz.
func handleRealtimeWS(
inbound: WebSocketInboundStream,
outbound: WebSocketOutboundWriter,
state: any RealtimeModelLoading
) async throws {
let session = RealtimeSession()
let sessionId = UUID().uuidString
// session.created reflects the same fields as session.updated so clients
// can rely on a single shape for either event.
try await outbound.write(.text(formatJSON(sessionEnvelope(id: sessionId, session: session, type: "session.created"))))
// Session-scoped keepalive: emits `realtime.keepalive` every 15s for the
// entire connection lifetime, not just while a model operation is in
// flight. Covers idle gaps between operations that the Hummingbird
// autoPing watchdog would otherwise have caught — see
// realtimeWebSocketServerConfiguration for why autoPing is off.
let keepalive = Task.detached { [outbound] in
while !Task.isCancelled {
do {
try await Task.sleep(nanoseconds: realtimeKeepaliveIntervalNanoseconds)
try Task.checkCancellation()
try await outbound.write(.text(formatJSON([
"type": realtimeKeepaliveEvent
])))
} catch {
return
}
}
}
defer { keepalive.cancel() }
for try await message in inbound.messages(maxSize: 50 * 1024 * 1024) {
guard case .text(let string) = message else { continue }
guard let jsonData = string.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let eventType = json["type"] as? String else {
try await sendRealtimeError(outbound: outbound, message: "Invalid message format")
continue
}
do {
switch eventType {
case "session.update":
if let sessionConfig = json["session"] as? [String: Any] {
// Top-level `model`: walk the registry, update whichever
// slots match. Bare "qwen3" updates both ASR and TTS
// (they share the alias); "voxcpm2" updates only TTS;
// "hibiki" updates only S2S. Unknown names are accepted
// and echoed without touching any slot.
if let modelName = sessionConfig["model"] as? String, !modelName.isEmpty {
session.model = modelName
let variants = resolveAllVariants(modelName)
let pickedS2S = variants.first(where: { $0.kind == .s2s })
for v in variants {
switch v.kind {
case .asr: session.asrVariant = v
case .tts: session.ttsVariant = v
case .s2s: session.s2sVariant = v
case .enhance, .music, .vad, .diarize,
.speaker, .separate, .sr, .turn:
// Cataloged in the registry for discovery via
// /v1/models, but the Realtime session protocol
// has no slot for these — they're routed via
// dedicated HTTP endpoints (/enhance, /compose,
// /diarize, …). No-op on session.update.
break
}
}
// S2S is exclusive — picking a recognized ASR/TTS-only
// model turns S2S off so the user gets the compose
// path back. Also drop any pending S2S input audio so
// the next response.create starts clean.
if pickedS2S == nil && !variants.isEmpty {
session.s2sVariant = nil
session.lastCommittedAudio = nil
}
}
// OpenAI-standard: `input_audio_transcription.model` selects
// the ASR backend independently of the top-level model.
if let iat = sessionConfig["input_audio_transcription"] as? [String: Any],
let asrModel = iat["model"] as? String,
let asr = resolveModelToASRVariant(asrModel) {
session.asrVariant = asr
}
// Legacy `engine` field used to control TTS dispatch only.
// Preserve that — store the raw string for the echo and
// update the TTS variant if the name resolves.
if let engine = sessionConfig["engine"] as? String {
session.legacyEngine = engine
if let tts = resolveModelToTTSVariant(engine) {
session.ttsVariant = tts
}
}
if let lang = sessionConfig["language"] as? String {
session.language = lang