Repository navigation
Expand file tree
/
Copy pathLocalEngine.swift
More file actions
1513 lines (1392 loc) · 69.7 KB
/
Copy pathLocalEngine.swift
File metadata and controls
1513 lines (1392 loc) · 69.7 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
// The Local provider, with nothing behind it but this file and two C libraries
// on one ggml.
//
// Six model families: whisper.cpp carries a Parakeet implementation alongside
// Whisper, and Cohere Transcribe, Voxtral Realtime, Nemotron 3.5 and Qwen3-ASR
// come from CrispASR, a whisper.cpp fork with runtimes for shapes whisper.cpp has no
// graph for — built against whisper.cpp's ggml (scripts/engines), so all of
// them run everywhere the app does, Apple Silicon and Intel alike, with no
// second engine to ship.
//
// "Local" used to mean an OpenAI-shaped Python server on 127.0.0.1:8756 that
// you had to clone and start by hand — no reasonable thing to ask of someone
// who downloaded a DMG. whisper.cpp is compiled into the binary instead, so the
// only thing a user ever fetches is one file of weights. No port, no second
// process, no runtime to install.
//
// Weights are NOT in the bundle: the app ships tiny, the user picks what to
// download, and it lands in Application Support because the .app is signed and
// must stay read-only.
import AVFoundation
import CryptoKit
import Foundation
/// One downloadable model. Every engine reads a single ggml/GGUF file, which is
/// the whole reason this is so much simpler than it was: download is one URL,
/// delete is one `rm`, and "is it installed" is one `fileExists`.
enum LocalFamily {
case whisper
case parakeet
/// Cohere Transcribe: the most accurate thing on the Open ASR leaderboard,
/// and a 74-second clip takes it under a second on Apple Silicon. An
/// encoder-decoder like Whisper, so it has no streaming mode of its own —
/// but it is fast enough that the live path's re-decode-everything loop
/// works on it (see `streams`). It has no language detection either: the
/// language is chosen up front from the Mac's preferred languages.
case cohere
/// Voxtral Mini 4B Realtime: Mistral's streaming model, a causal
/// Whisper-style encoder feeding a 3.4B Mistral decoder that is walked
/// through the audio one frame at a time. Detects the language itself.
case voxtral
/// Nemotron 3.5 ASR: NVIDIA's cache-aware FastConformer transducer, the
/// same size as Parakeet. Like Cohere it is told the language up front.
case nemotron
/// Qwen3-ASR 1.7B: an audio tower feeding a Qwen3 LLM. Offered in English
/// only, with the user's names as context — the open model that came
/// closest to Soniox on the user's own dictations (2026-10-01).
case qwen3
/// Whether this family can run live at all (LocalLive.swift). Which
/// models actually do on a given Mac is measured there — LocalSpeed —
/// since a pass that keeps up on an M5 Pro may not on a base M1.
///
/// Two ways in. Cohere, Parakeet and Qwen3 re-run over the whole
/// utterance every 0.6 s, which needs a pass to finish well inside the
/// tick and what it already showed to stay put as more arrives. Parakeet
/// qualifies by architecture — a transducer's prefix is stable. Cohere
/// by measurement: 100–240 ms a pass for ten seconds of speech, and the
/// prefix only ever grew. Qwen3-ASR on the owner's call (2026-10-01), at
/// a slower cadence: 0.27 s a pass at 2 s of speech, 0.82 s at 11 s, so
/// past about 8 s the loop skips ticks and the preview updates every
/// second or so.
///
/// Voxtral and Nemotron are streaming models by design and are fed the
/// audio as it arrives instead (`streamsNatively`): re-running them over
/// ten seconds took about two seconds and one, past the tick, but their
/// own incremental paths do each piece of audio once.
///
/// Whisper stays out: it decodes a fixed 30-second window five times
/// slower than Cohere and visibly rewrites itself between passes.
var streams: Bool { self != .whisper }
/// Fed the audio piece by piece (scripts/engines/nemotron_stream.cpp,
/// CrispASR's voxtral4b_stream) rather than re-run over all of it.
var streamsNatively: Bool { self == .voxtral || self == .nemotron }
}
struct LocalCatalogEntry {
let id: String
let name: String
/// How many languages the model was trained on — the one number that
/// actually decides whether a model is usable for you.
let languages: Int
/// Which of the ggml runtimes reads this file.
let family: LocalFamily
/// Where the weights come from. Each family is published in its own
/// repository, so each row carries its own address.
let url: String
let bytes: Int64
/// Straight off HuggingFace's LFS metadata. A download that ends early
/// otherwise fails later as an unreadable model, which tells the user
/// nothing.
let sha256: String
/// The name on disk, taken from the URL so the two can never disagree.
var file: String { String(url.split(separator: "/").last ?? "model.bin") }
/// The whole of a row's subtitle: what it understands, what it costs.
var detail: String { "\(languages) languages · \(LocalEngine.humanSize(bytes))" }
}
/// Deliberately short. Every extra row is a decision the user has to make, and
/// each family gets the same three stops — small, standard, full — with Cohere
/// on two, because its smallest is already 1.5 GB. Listed best first, which is
/// also the order the pane shows them in. Sizes and checksums are read from
/// the model repositories.
let LOCAL_CATALOG: [LocalCatalogEntry] = [
// Cohere first: the most accurate of the three and it streams. The 2B
// model, quantised — Q4_K is the one to have, the card measures it fastest
// of the six and nothing above it is known to be more accurate; Q8_0 is
// there for whoever wants the weights as close to the original as a
// 2.4 GB download gets.
LocalCatalogEntry(
id: "cohere-transcribe",
name: "Cohere Transcribe",
languages: 14,
family: .cohere,
url: COHERE_REPO + "cohere-transcribe-q4_k.gguf",
bytes: 1_510_363_008,
sha256: "237261c543dc9124a3f08f95b48c9c672896ef0d79dc8cadce3fb4ddc09a2ef8"),
LocalCatalogEntry(
id: "cohere-transcribe-full",
name: "Cohere Transcribe Full",
languages: 14,
family: .cohere,
url: COHERE_REPO + "cohere-transcribe-q8_0.gguf",
bytes: 2_423_803_776,
sha256: "e725a5ec9b5b9c0b7aba07b071753cae9b39ec3fbaf9f6a622c6810d5a11e3f4"),
// Qwen3-ASR, English only, the user's names as context; live at a
// slower cadence (see `LocalFamily.streams`). Q8_0, the build that was
// measured: on 279 of the user's own dictations it tied Soniox without
// terms, ahead of every other local model
// (bench-experiments/SUMMARY-own-dictations.txt).
LocalCatalogEntry(
id: "qwen3-asr-english",
name: "Qwen3-ASR English",
languages: 1,
family: .qwen3,
url: QWEN3_REPO + "qwen3-asr-1.7b-q8_0.gguf",
bytes: 2_506_723_200,
sha256: "9851ab996591a2d0cb0efb216002764b509c86bd40c95e613d7b65b8e69c8a6e"),
// Voxtral Realtime and Nemotron 3.5, on the finished clip only (see
// `LocalFamily.streams`). Voxtral is the heavy one: Q4_K is 2.5 GB and
// the most it asks of memory; Q8_0 is the near-original at 4.7 GB.
LocalCatalogEntry(
id: "voxtral-realtime",
name: "Voxtral Realtime",
languages: 13,
family: .voxtral,
url: VOXTRAL_REPO + "voxtral-mini-4b-realtime-q4_k.gguf",
bytes: 2_524_137_216,
sha256: "7dda1dba692f18c9d30a6064943b92c562853b399e96320929d2e1399c9d41cc"),
LocalCatalogEntry(
id: "voxtral-realtime-full",
name: "Voxtral Realtime Full",
languages: 13,
family: .voxtral,
url: VOXTRAL_REPO + "voxtral-mini-4b-realtime-q8_0.gguf",
bytes: 4_733_486_848,
sha256: "0289e58b62c1da39e4f6d1ed962c6d1cab8d261ca193d25a528a71a9b1b36792"),
LocalCatalogEntry(
id: "nemotron-3.5",
name: "Nemotron 3.5",
languages: 34,
family: .nemotron,
url: NEMOTRON_REPO + "nemotron-3.5-asr-streaming-0.6b-q8_0.gguf",
bytes: 686_164_384,
sha256: "96e4e60b9ecea9ef97d9eb5677af7fd77b1d43b2784c6a6ea98e1858bda4f05e"),
LocalCatalogEntry(
id: "nemotron-3.5-full",
name: "Nemotron 3.5 Full",
languages: 34,
family: .nemotron,
url: NEMOTRON_REPO + "nemotron-3.5-asr-streaming-0.6b-f16.gguf",
bytes: 1_286_368_480,
sha256: "14cbc98df495d8ab452c6a4ac367136d94ab5da55e73afaa96ba4f749dd95d09"),
// Parakeet: the light option, a quarter of the size, and it streams.
LocalCatalogEntry(
id: "parakeet-v3-small",
name: "Parakeet v3 Small",
languages: 25,
family: .parakeet,
url: PARAKEET_REPO + "ggml-parakeet-tdt-0.6b-v3-q4_0.bin",
bytes: 355_615_679,
sha256: "aa7fe2f5fb47d863ca23e8b1d490632d63a2599f515268b6d6bd656158dad45e"),
LocalCatalogEntry(
id: "parakeet-v3",
name: "Parakeet v3",
languages: 25,
family: .parakeet,
url: PARAKEET_REPO + "ggml-parakeet-tdt-0.6b-v3-q8_0.bin",
bytes: 668_757_119,
sha256: "4d64e9e96c2792186d072fde0034df0ad670cf680a2f53069052ead827fd600e"),
LocalCatalogEntry(
id: "parakeet-v3-full",
name: "Parakeet v3 Full",
languages: 25,
family: .parakeet,
url: PARAKEET_REPO + "ggml-parakeet-tdt-0.6b-v3-f16.bin",
bytes: 1_255_897_319,
sha256: "833bffc9513b2cae867ee9e51633cfd11e4d51aaa5597c8ac02159385a2b426f"),
// Whisper: 99 languages, no streaming.
LocalCatalogEntry(
id: "whisper-small",
name: "Whisper Small",
languages: 99,
family: .whisper,
url: WHISPER_REPO + "ggml-small.bin",
bytes: 487_601_967,
sha256: "1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b"),
LocalCatalogEntry(
id: "whisper-turbo",
name: "Whisper Turbo",
languages: 99,
family: .whisper,
url: WHISPER_REPO + "ggml-large-v3-turbo-q5_0.bin",
bytes: 574_041_195,
sha256: "394221709cd5ad1f40c46e6031ca61bce88931e6e088c188294c6d5a55ffa7e2"),
LocalCatalogEntry(
id: "whisper-large",
name: "Whisper Large",
languages: 99,
family: .whisper,
url: WHISPER_REPO + "ggml-large-v3-turbo.bin",
bytes: 1_624_555_275,
sha256: "1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69"),
]
/// The upstream model repositories. The first two are the ones whisper.cpp's
/// own download scripts use; the third is CrispASR's GGUF export of Cohere's
/// Apache-2.0 weights, the only published GGUF of them.
private let WHISPER_REPO = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/"
private let PARAKEET_REPO = "https://huggingface.co/ggml-org/parakeet-GGUF/resolve/main/"
private let COHERE_REPO = "https://huggingface.co/cstr/cohere-transcribe-03-2026-GGUF/resolve/main/"
/// CrispASR's GGUF exports of Mistral's and NVIDIA's weights, in the layout
/// its runtimes read — llama.cpp's GGUFs of the same models are not.
private let VOXTRAL_REPO = "https://huggingface.co/cstr/voxtral-mini-4b-realtime-GGUF/resolve/main/"
private let NEMOTRON_REPO = "https://huggingface.co/cstr/nemotron-3.5-asr-streaming-0.6b-GGUF/resolve/main/"
private let QWEN3_REPO = "https://huggingface.co/cstr/qwen3-asr-1.7b-GGUF/resolve/main/"
// MARK: - The loaded model
/// One loaded model, and the serial queue that owns it. None of `whisper_full`,
/// `parakeet_full` or `cohere_transcribe` is reentrant and all run for seconds,
/// so the context never touches the main thread and never runs twice at once.
private final class LocalRunner {
let modelID: String
let family: LocalFamily
private var ctx: OpaquePointer?
private let queue = DispatchQueue(label: "com.sayctrl.localengine", qos: .userInitiated)
/// Cohere only: the language every clip is decoded in, settled at load.
private var language = "en"
init(path: String, modelID: String, family: LocalFamily) throws {
self.modelID = modelID
self.family = family
switch family {
case .whisper:
var params = whisper_context_default_params()
// Metal on Apple Silicon; the Intel slice is built without it and
// ignores this.
params.use_gpu = true
ctx = whisper_init_from_file_with_params(path, params)
case .parakeet:
var params = parakeet_context_default_params()
params.use_gpu = true
ctx = parakeet_init_from_file_with_params(path, params)
case .cohere:
var params = cohere_context_default_params()
params.use_gpu = true
params.n_threads = threads
// It narrates every load to stderr otherwise — see silenceEngineLogs.
params.verbosity = 0
ctx = cohere_init_from_file(path, params)
if let ctx = ctx { language = Self.cohereLanguage(ctx) }
case .voxtral:
var params = voxtral4b_context_default_params()
params.use_gpu = true
params.n_threads = threads
params.verbosity = 0
ctx = voxtral4b_init_from_file(path, params)
case .nemotron:
var params = nemotron_context_default_params()
params.use_gpu = true
params.n_threads = threads
params.verbosity = 0
ctx = nemotron_init_from_file(path, params)
if let ctx = ctx { nemotron_set_language(ctx, Self.nemotronLanguage) }
case .qwen3:
var params = qwen3_asr_context_default_params()
params.use_gpu = true
params.n_threads = threads
params.verbosity = 0
ctx = qwen3_asr_init_from_file(path, params)
}
guard ctx != nil else {
throw NSError(domain: "SayCtrl", code: 10, userInfo: [
NSLocalizedDescriptionKey:
"Couldn't load that model file — it may be incomplete. Delete it and download again."])
}
}
deinit {
guard let ctx = ctx else { return }
// A take still streaming holds the context's caches.
closeStream()
switch family {
case .whisper: whisper_free(ctx)
case .parakeet: parakeet_free(ctx)
case .cohere: cohere_free(ctx)
case .voxtral: voxtral4b_free(ctx)
case .nemotron: nemotron_free(ctx)
case .qwen3: qwen3_asr_free(ctx)
}
}
/// Leave two cores for the UI and the paste path; ggml will happily
/// saturate everything otherwise and the HUD starts to stutter.
private var threads: Int32 {
Int32(max(1, min(8, ProcessInfo.processInfo.activeProcessorCount - 2)))
}
/// Cohere Transcribe takes a language code and has no detector of its own:
/// handed the wrong one it writes a fluent transcript in the wrong language
/// and never says so. Its probe-based detection costs a decode per
/// candidate — fourteen of them for the base model — which is no use at the
/// end of a dictation. So the language is the first of the Mac's preferred
/// languages the model lists, the same source Lexicon reads, and English
/// when none matches. A GGUF without the list is told "en" too.
private static func cohereLanguage(_ ctx: OpaquePointer) -> String {
let supported = (0..<cohere_n_supported_languages(ctx)).compactMap { i in
cohere_supported_language(ctx, i).map { String(cString: $0) }
}
guard !supported.isEmpty else { return "en" }
for pref in Locale.preferredLanguages {
let base = String(pref.prefix(while: { $0 != "-" && $0 != "_" })).lowercased()
if supported.contains(base) { return base }
}
return supported.contains("en") ? "en" : supported[0]
}
/// Nemotron conditions its encoder on a language prompt and, told nothing,
/// assumes US English. It publishes no list, so this is the one its
/// runtime maps (nemotron.cpp): the first preferred language it has,
/// regional form where it has one (en-GB, pt-BR, fr-CA), else the base.
private static let nemotronLanguage: String = {
let locales: Set<String> = [
"en-US", "en-GB", "es-ES", "es-US", "zh-CN", "zh-TW", "hi-IN", "ar-AR", "fr-FR", "de-DE",
"ja-JP", "ru-RU", "pt-BR", "pt-PT", "ko-KR", "it-IT", "nl-NL", "pl-PL", "tr-TR", "uk-UA",
"ro-RO", "el-GR", "cs-CZ", "hu-HU", "sv-SE", "da-DK", "fi-FI", "nb-NO", "sk-SK", "hr-HR",
"bg-BG", "lt-LT", "th-TH", "vi-VN", "et-EE", "lv-LV", "sl-SI", "he-IL", "fr-CA", "nn-NO",
]
let bases = Set(locales.map { String($0.prefix(2)) })
for pref in Locale.preferredLanguages {
let parts = pref.split(whereSeparator: { $0 == "-" || $0 == "_" }).map(String.init)
guard let base = parts.first?.lowercased(), bases.contains(base) else { continue }
if let region = parts.last?.uppercased(), parts.count > 1, locales.contains("\(base)-\(region)") {
return "\(base)-\(region)"
}
return base
}
return "en"
}()
/// `draft` is the last live pass's text — Qwen3 checks it rather than
/// writing the take from scratch (same text, far fewer model calls); the
/// other families ignore it.
func transcribe(_ samples: [Float], prompt: String, draft: String = "") async throws -> String {
try await withCheckedThrowingContinuation { continuation in
queue.async { [weak self] in
guard let self = self, let ctx = self.ctx else {
return continuation.resume(throwing: NSError(
domain: "SayCtrl", code: 11,
userInfo: [NSLocalizedDescriptionKey: "Local model was unloaded mid-transcription."]))
}
do {
let text: String
switch self.family {
case .whisper: text = try self.runWhisper(ctx, samples, prompt)
case .parakeet: text = try self.runParakeet(ctx, samples)
case .cohere: text = try self.runCohere(ctx, samples)
case .voxtral: text = try self.runVoxtral(ctx, samples)
case .nemotron: text = try self.runNemotron(ctx, samples)
case .qwen3: text = try self.runQwen3(ctx, samples, prompt, draft)
}
continuation.resume(returning: text.trimmingCharacters(in: .whitespacesAndNewlines))
} catch {
continuation.resume(throwing: error)
}
}
}
}
// MARK: Fed as the audio arrives (Voxtral, Nemotron)
/// The take being streamed. One at a time: both keep their state on the
/// model's context (Nemotron's encoder caches, Voxtral's LLM cache).
/// Touched only on `queue`, like the context itself.
private var stream: OpaquePointer?
/// Voxtral hands its words over a piece at a time; they collect here.
private var streamText = ""
/// Runs `body` on the model's queue, the way every call into it goes.
private func onQueue<T>(_ body: @escaping (OpaquePointer) throws -> T) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
queue.async { [weak self] in
guard let self = self, let ctx = self.ctx else {
return continuation.resume(throwing: NSError(
domain: "SayCtrl", code: 11,
userInfo: [NSLocalizedDescriptionKey: "Local model was unloaded mid-transcription."]))
}
do { continuation.resume(returning: try body(ctx)) } catch { continuation.resume(throwing: error) }
}
}
}
func streamOpen() async throws {
try await onQueue { ctx in
self.closeStream()
switch self.family {
case .voxtral:
self.stream = voxtral4b_stream_open(ctx, 0, 0)
// Words as they decode, not all at the end.
if let s = self.stream { voxtral4b_stream_set_live_decode(s, 1) }
case .nemotron:
self.stream = sayctrl_nemotron_stream_open(ctx)
default:
break
}
guard self.stream != nil else { throw LocalRunner.failed }
}
}
/// Hands over the audio since the last call and answers the text so far.
func streamFeed(_ chunk: [Float]) async throws -> String {
try await onQueue { _ in try self.feed(chunk) }
}
/// The last of the audio, then the stream's own end: Voxtral's ten
/// tokens of trailing silence, Nemotron's tail over the zero pad.
func streamFinish(_ tail: [Float]) async throws -> String {
try await onQueue { _ in
defer { self.closeStream() }
_ = try self.feed(tail)
guard let s = self.stream else { throw LocalRunner.failed }
switch self.family {
case .voxtral:
guard voxtral4b_stream_flush(s) >= 0 else { throw LocalRunner.failed }
self.drainVoxtral(s)
return self.streamText.trimmingCharacters(in: .whitespacesAndNewlines)
default:
guard sayctrl_nemotron_stream_finish(s) == 0 else { throw LocalRunner.failed }
return self.nemotronText(s)
}
}
}
/// Voxtral and Nemotron build their Metal kernels on first use: several
/// seconds (measured 2026-10-21, M5 Pro) that would otherwise land on
/// the first take after a load, as words that don't come and then
/// rush in. Two seconds of silence through the stream, straight after
/// loading, pays for it while nobody is waiting.
func prime() {
guard family.streamsNatively else { return }
queue.async { [weak self] in
guard let self = self, let ctx = self.ctx, self.stream == nil else { return }
defer { self.closeStream() }
switch self.family {
case .voxtral:
self.stream = voxtral4b_stream_open(ctx, 0, 0)
if let s = self.stream { voxtral4b_stream_set_live_decode(s, 1) }
default:
self.stream = sayctrl_nemotron_stream_open(ctx)
}
guard let s = self.stream else { return }
let quiet = [Float](repeating: 0, count: 3200)
for _ in 0..<10 { _ = try? self.feed(quiet) }
switch self.family {
case .voxtral: _ = voxtral4b_stream_flush(s)
default: _ = sayctrl_nemotron_stream_finish(s)
}
}
}
/// A take thrown away, or a model unloaded under one.
func streamCancel() {
queue.async { [weak self] in self?.closeStream() }
}
private func feed(_ chunk: [Float]) throws -> String {
guard let s = stream else { throw LocalRunner.failed }
if !chunk.isEmpty {
let rc = chunk.withUnsafeBufferPointer { buf -> Int32 in
switch family {
case .voxtral: return voxtral4b_stream_feed(s, buf.baseAddress, Int32(buf.count))
default: return sayctrl_nemotron_stream_feed(s, buf.baseAddress, Int32(buf.count))
}
}
guard rc == 0 else { throw LocalRunner.failed }
}
switch family {
case .voxtral:
drainVoxtral(s)
return streamText.trimmingCharacters(in: .whitespacesAndNewlines)
default:
return nemotronText(s)
}
}
private func drainVoxtral(_ s: OpaquePointer) {
var buf = [CChar](repeating: 0, count: 4096)
while voxtral4b_stream_get_text(s, &buf, Int32(buf.count), nil, nil, nil) > 0 {
streamText += String(cString: buf)
}
}
private func nemotronText(_ s: OpaquePointer) -> String {
guard let out = sayctrl_nemotron_stream_text(s) else { return "" }
defer { free(out) }
return Self.withoutLanguageTag(String(cString: out))
}
private func closeStream() {
guard let s = stream else { return }
switch family {
case .voxtral: voxtral4b_stream_close(s)
default: sayctrl_nemotron_stream_close(s)
}
stream = nil
streamText = ""
}
/// Ten seconds of speech, timed: a replayed model's pass over all of
/// it, or a streamed one's compute per second of audio fed in the
/// pieces a take would feed. nil while a take is streaming on this
/// context — the two would share its caches.
func benchmark(_ clip: [Float]) async throws -> Double? {
let prompt = LocalEngine.hint(for: family)
if family.streamsNatively {
return try await onQueue { ctx -> Double? in
guard self.stream == nil else { return nil }
defer { self.closeStream() }
switch self.family {
case .voxtral:
self.stream = voxtral4b_stream_open(ctx, 0, 0)
if let s = self.stream { voxtral4b_stream_set_live_decode(s, 1) }
default:
self.stream = sayctrl_nemotron_stream_open(ctx)
}
guard self.stream != nil else { throw LocalRunner.failed }
var spent: TimeInterval = 0
for start in stride(from: 0, to: clip.count, by: 3200) {
let piece = Array(clip[start..<min(start + 3200, clip.count)])
let t0 = Date()
_ = try self.feed(piece)
spent += Date().timeIntervalSince(t0)
}
return spent / (Double(clip.count) / 16000)
}
}
// The first pass pays for loading kernels, so it doesn't count; it
// is also the earlier pass a live one has before it, whose text Qwen3
// checks rather than writing the take again (the others ignore it).
let draft = try await transcribe(Array(clip.prefix(clip.count / 2)), prompt: prompt)
let t0 = Date()
_ = try await transcribe(clip, prompt: prompt, draft: draft)
return Date().timeIntervalSince(t0)
}
private func runWhisper(_ ctx: OpaquePointer, _ samples: [Float], _ prompt: String) throws -> String {
var params = whisper_full_default_params(WHISPER_SAMPLING_GREEDY)
params.print_realtime = false
params.print_progress = false
params.print_timestamps = false
params.print_special = false
params.translate = false
params.no_context = true
params.suppress_blank = true
params.n_threads = threads
// Both C strings must outlive the whisper_full call, which is why these
// are nested closures rather than locals.
let text: String? = "auto".withCString { lang in
params.language = lang
return prompt.withCString { hint -> String? in
if !prompt.isEmpty { params.initial_prompt = hint }
let status = samples.withUnsafeBufferPointer { buf in
whisper_full(ctx, params, buf.baseAddress, Int32(buf.count))
}
guard status == 0 else { return nil }
var out = ""
for i in 0..<whisper_full_n_segments(ctx) {
if let seg = whisper_full_get_segment_text(ctx, i) { out += String(cString: seg) }
}
return out
}
}
guard let text = text else { throw LocalRunner.failed }
return text
}
/// Parakeet's params are a smaller set — no language to pick (the model is
/// multilingual by construction) and no prompt to bias it with.
private func runParakeet(_ ctx: OpaquePointer, _ samples: [Float]) throws -> String {
var params = parakeet_full_default_params(PARAKEET_SAMPLING_GREEDY)
params.no_context = true
params.n_threads = threads
let status = samples.withUnsafeBufferPointer { buf in
parakeet_full(ctx, params, buf.baseAddress, Int32(buf.count))
}
guard status == 0 else { throw LocalRunner.failed }
var out = ""
for i in 0..<parakeet_full_n_segments(ctx) {
if let seg = parakeet_full_get_segment_text(ctx, i) { out += String(cString: seg) }
}
return out
}
/// Cohere's is the smallest interface of the three: samples in, one
/// malloc'd string out. Clips past its 30-second window are split and
/// joined inside the library, so nothing here cares how long one is. No
/// prompt to bias it with either — the Dictionary does that work after.
private func runCohere(_ ctx: OpaquePointer, _ samples: [Float]) throws -> String {
let out = samples.withUnsafeBufferPointer { buf in
cohere_transcribe(ctx, buf.baseAddress, Int32(buf.count), language)
}
guard let out = out else { throw LocalRunner.failed }
defer { free(out) }
return String(cString: out)
}
/// Voxtral: the same one-call shape as Cohere, through the app's own
/// decode loop (scripts/engines/voxtral4b_run.cpp). It writes its own
/// punctuation and picks its own language.
private func runVoxtral(_ ctx: OpaquePointer, _ samples: [Float]) throws -> String {
let out = samples.withUnsafeBufferPointer { buf in
sayctrl_voxtral4b_transcribe(ctx, buf.baseAddress, Int32(buf.count))
}
guard let out = out else { throw LocalRunner.failed }
defer { free(out) }
return String(cString: out)
}
/// Nemotron closes every sentence it hears with the language it was told
/// — "…better than others. <en-US>" — which is bookkeeping, not speech.
private func runNemotron(_ ctx: OpaquePointer, _ samples: [Float]) throws -> String {
let out = samples.withUnsafeBufferPointer { buf in
nemotron_transcribe(ctx, buf.baseAddress, Int32(buf.count))
}
guard let out = out else { throw LocalRunner.failed }
defer { free(out) }
return Self.withoutLanguageTag(String(cString: out))
}
static func withoutLanguageTag(_ text: String) -> String {
text.replacingOccurrences(of: #"\s*<[a-z]{2}(-[A-Z]{2})?>"#, with: "", options: .regularExpression)
}
/// Qwen3-ASR, in English, with `context` (the user's names) as the
/// system turn and `draft` to check — see scripts/engines/qwen3_run.cpp
/// for all three.
private func runQwen3(_ ctx: OpaquePointer, _ samples: [Float], _ context: String, _ draft: String) throws -> String {
func decode(_ context: String) throws -> String {
let out = samples.withUnsafeBufferPointer { buf in
sayctrl_qwen3_transcribe(ctx, buf.baseAddress, Int32(buf.count), context, "English",
draft.isEmpty ? nil : draft)
}
guard let out = out else { throw LocalRunner.failed }
defer { free(out) }
return String(cString: out)
}
let text = try decode(context)
if Self.echoes(text, context) { return try decode("") }
// With the text before the caret in the context, the failure to watch
// for is the same one in prose: that text written back out. Eight
// words of it in a row and the clip is decoded with the names alone.
if let blank = context.range(of: "\n\n"),
Self.repeatsProse(text, String(context[blank.upperBound...])) {
return try decode(String(context[..<blank.lowerBound]))
}
return text
}
private static func repeatsProse(_ text: String, _ prose: String, run: Int = 8) -> Bool {
func words(_ s: String) -> [String] {
s.lowercased().split(whereSeparator: { !$0.isLetter && !$0.isNumber && $0 != "'" }).map(String.init)
}
let t = words(text), p = words(prose)
guard t.count >= run, p.count >= run else { return false }
let grams = Set((0...(p.count - run)).map { p[$0..<$0 + run].joined(separator: " ") })
return (0...(t.count - run)).contains { grams.contains(t[$0..<$0 + run].joined(separator: " ")) }
}
/// Qwen3-ASR's one failure with a context: on a clip with next to no
/// speech it can read the list back out instead of transcribing — 4 of
/// 279 dictations, each time the list itself, from the top, comma after
/// comma. Five of its terms in its own order is that answer; the clip is
/// decoded again without the list.
private static func echoes(_ text: String, _ context: String) -> Bool {
guard text.contains(",") else { return false }
let lower = text.lowercased()
var from = lower.startIndex
var inOrder = 0
for term in context.lowercased().components(separatedBy: ", ") where !term.isEmpty {
guard let hit = lower.range(of: term, range: from..<lower.endIndex) else { continue }
inOrder += 1
from = hit.upperBound
}
return inOrder >= 5
}
private static let failed = NSError(domain: "SayCtrl", code: 12, userInfo: [
NSLocalizedDescriptionKey: "Local transcription failed."])
}
// MARK: - Downloading
/// One model download. Split out because URLSession's delegate callbacks arrive
/// off the main thread and LocalEngine is main-actor throughout.
private final class DownloadJob: NSObject, URLSessionDownloadDelegate {
private let entry: LocalCatalogEntry
private let destination: URL
private let onProgress: (Double) -> Void
private let onFinish: (Error?) -> Void
private var session: URLSession!
private var task: URLSessionDownloadTask?
/// Kept so a dropped download resumes instead of restarting — it matters
/// when the file is three gigabytes.
private(set) var resumeData: Data?
private var finished = false
init(entry: LocalCatalogEntry, destination: URL, resuming: Data?,
onProgress: @escaping (Double) -> Void, onFinish: @escaping (Error?) -> Void) {
self.entry = entry
self.destination = destination
self.onProgress = onProgress
self.onFinish = onFinish
super.init()
let config = URLSessionConfiguration.default
config.timeoutIntervalForResource = 24 * 3600
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
if let resuming = resuming {
task = session.downloadTask(withResumeData: resuming)
} else {
task = session.downloadTask(with: URL(string: entry.url)!)
}
task?.resume()
}
/// Resume data arrives asynchronously, so the caller is told when it is
/// actually available rather than reading `resumeData` straight after.
func cancel(_ done: @escaping (Data?) -> Void) {
task?.cancel { [weak self] data in
self?.session.invalidateAndCancel()
done(data)
}
}
/// The one exit. Invalidating releases the session's strong hold on this
/// delegate; without it every completed download leaks its job.
private func finish(_ error: Error?) {
guard !finished else { return }
finished = true
session.finishTasksAndInvalidate()
onFinish(error)
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64) {
// The server does not always send a length; the catalogue always knows.
let total = totalBytesExpectedToWrite > 0 ? totalBytesExpectedToWrite : entry.bytes
onProgress(total > 0 ? Double(totalBytesWritten) / Double(total) : 0)
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
do {
// A partial file that hashes wrong is worse than no file, so verify
// before it is ever moved into place.
guard LocalEngine.sha256(of: location) == entry.sha256 else {
try? FileManager.default.removeItem(at: location)
throw NSError(domain: "SayCtrl", code: 13, userInfo: [
NSLocalizedDescriptionKey:
"Download finished but didn't match its checksum — try again."])
}
try? FileManager.default.removeItem(at: destination)
try FileManager.default.createDirectory(
at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
try FileManager.default.moveItem(at: location, to: destination)
finish(nil)
} catch {
finish(error)
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error = error else { return } // success already reported
let ns = error as NSError
if ns.code == NSURLErrorCancelled {
resumeData = ns.userInfo[NSURLSessionDownloadTaskResumeData] as? Data
return // a cancel is not a failure
}
resumeData = ns.userInfo[NSURLSessionDownloadTaskResumeData] as? Data
finish(error)
}
}
// MARK: - The engine
/// Catalog, downloads, storage and inference for on-device transcription.
/// Everything here is touched from the main thread, like the rest of this app —
/// the download delegate is the one exception and hops back explicitly. The
/// heavy work happens on LocalRunner's own queue either way.
final class LocalEngine {
static let shared = LocalEngine()
static var catalog: [LocalCatalogEntry] { LOCAL_CATALOG }
static func entry(_ id: String) -> LocalCatalogEntry? {
// A saved LOCAL_MODEL may carry a ":suffix" from the old server's ids.
let base = id.components(separatedBy: ":").first ?? id
return LOCAL_CATALOG.first { $0.id == base }
}
// MARK: Storage
/// Application Support, not the bundle: the bundle is signed and read-only,
/// and weights this large have no business inside a DMG anyway.
static var root: URL {
_ = migratedFromKotha
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return base.appendingPathComponent("SayCtrl/Models", isDirectory: true)
}
/// The app was Kotha, and its weights sat in Application Support/Kotha.
/// These files run to gigabytes: move the folder across on first launch
/// rather than let a rename bill the user for the same download twice.
/// `static let` so the work happens once, on whichever thread asks first.
private static let migratedFromKotha: Void = {
let fm = FileManager.default
guard let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
else { return }
let old = base.appendingPathComponent("Kotha", isDirectory: true)
let new = base.appendingPathComponent("SayCtrl", isDirectory: true)
guard fm.fileExists(atPath: old.path), !fm.fileExists(atPath: new.path) else { return }
try? fm.moveItem(at: old, to: new)
}()
static func path(for entry: LocalCatalogEntry) -> URL {
root.appendingPathComponent(entry.file)
}
func isInstalled(_ entry: LocalCatalogEntry) -> Bool {
let url = Self.path(for: entry)
guard let size = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int64
else { return false }
// A truncated file would load as a corrupt model; treat it as absent so
// the row offers Download again rather than Use.
return size == entry.bytes
}
func installedIDs() -> [String] { LOCAL_CATALOG.filter(isInstalled).map(\.id) }
/// What "use the local model" lands on: the biggest thing downloaded,
/// because on this axis size really does buy accuracy.
func bestInstalledID() -> String? {
LOCAL_CATALOG.filter(isInstalled).max { $0.bytes < $1.bytes }?.id
}
// MARK: The model picked for this Mac
/// The chip and the memory, which between them decide what runs well
/// here. Memory bandwidth is the thing that really matters and macOS
/// doesn't report it, but it tracks the chip: every Pro, Max and Ultra
/// ships with 16 GB or more, so memory alone sorts the fast Macs from
/// the base ones.
struct Machine {
let chip: String
let memoryGB: Int
let appleSilicon: Bool
/// "M5 Pro · 24 GB", the chip the Plans page wears.
var label: String { "\(chip) · \(memoryGB) GB" }
static let current: Machine = {
var arm: Int32 = 0
var armSize = MemoryLayout<Int32>.size
let appleSilicon = sysctlbyname("hw.optional.arm64", &arm, &armSize, nil, 0) == 0 && arm == 1
var size = 0
var brand = ""
if sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0) == 0, size > 0 {
var buf = [CChar](repeating: 0, count: size)
if sysctlbyname("machdep.cpu.brand_string", &buf, &size, nil, 0) == 0 { brand = String(cString: buf) }
}
let chip = brand.hasPrefix("Apple ") ? String(brand.dropFirst(6))
: (appleSilicon ? "Apple silicon" : "Intel")
let gb = Int((Double(ProcessInfo.processInfo.physicalMemory) / 1_073_741_824).rounded())
return Machine(chip: chip, memoryGB: gb, appleSilicon: appleSilicon)
}()
}
/// The base Cohere model's own list (config.json `supported_languages`,
/// the same one it reports at load), and Parakeet v3's 25 European ones.
private static let cohereLanguages: Set<String> = ["en", "fr", "de", "es", "it", "pt", "nl",
"pl", "el", "ar", "ja", "zh", "vi", "ko"]
private static let parakeetLanguages: Set<String> = ["bg", "hr", "cs", "da", "nl", "en", "et", "fi",
"fr", "de", "el", "hu", "it", "lv", "lt", "mt",
"pl", "pt", "ro", "sk", "sl", "es", "sv", "ru", "uk"]
/// The one local model a user's copy offers, chosen for them rather than
/// from a list. Cohere is the most accurate and streams, and wants a fast
/// Mac; Parakeet is the light one and streams too; Whisper is the fallback
/// for a language neither of them hears. The language is the Mac's first
/// preferred one — what Cohere would be told to listen for anyway.
static var recommendedID: String {
let mac = Machine.current
let pref = Locale.preferredLanguages.first ?? "en"
let lang = String(pref.prefix(while: { $0 != "-" && $0 != "_" })).lowercased()
guard mac.appleSilicon else {
return parakeetLanguages.contains(lang) ? "parakeet-v3-small" : "whisper-small"
}
if mac.memoryGB >= 16 && cohereLanguages.contains(lang) { return "cohere-transcribe" }
return parakeetLanguages.contains(lang) ? "parakeet-v3" : "whisper-turbo"
}
static func humanSize(_ bytes: Int64) -> String {
if bytes <= 0 { return "—" }
if bytes >= 1_000_000_000 { return String(format: "%.1f GB", Double(bytes) / 1e9) }
return String(format: "%.0f MB", Double(bytes) / 1e6)
}
/// Streamed so a three-gigabyte model doesn't become a three-gigabyte
/// allocation.
static func sha256(of url: URL) -> String? {
guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
defer { try? handle.close() }
var hasher = SHA256()
while let chunk = try? handle.read(upToCount: 4 << 20), !chunk.isEmpty {
hasher.update(data: chunk)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
// MARK: Download state
private var progress: [String: Double] = [:]
private var failures: [String: String] = [:]
private var jobs: [String: DownloadJob] = [:]
private var resumable: [String: Data] = [:]
/// The Models pane reads exactly this shape — the same keys the old HTTP
/// server answered /models with, so the table rendering never had to change.
func rows() -> [[String: Any]] {
LOCAL_CATALOG.map { entry in
var row: [String: Any] = [
"id": entry.id,
"name": entry.name,
"detail": entry.detail,
// The pane dims what can't stream when Live is on, the same
// way the paid lists do.
"streams": entry.family.streams,
"installed": isInstalled(entry),
"downloading": jobs[entry.id] != nil,
"progress": (progress[entry.id] ?? 0) * 100,
]
if let err = failures[entry.id] { row["error"] = err }
return row
}
}
func download(_ id: String) {
guard let entry = Self.entry(id), jobs[id] == nil else { return }
failures[id] = nil
progress[id] = 0
Config.shared.log("Local model download started: \(id) (\(Self.humanSize(entry.bytes)))")
jobs[id] = DownloadJob(
entry: entry, destination: Self.path(for: entry), resuming: resumable[id],
onProgress: { [weak self] fraction in
DispatchQueue.main.async { self?.progress[id] = fraction }
},
onFinish: { [weak self] error in
DispatchQueue.main.async {
guard let self = self else { return }
self.jobs[id] = nil
self.progress[id] = nil
if let error = error {
self.failures[id] = error.localizedDescription
// Stale resume data fails the same way every time, so
// Retry starts clean rather than replaying the fault.
self.resumable[id] = nil
Config.shared.log("Local model download failed: \(id) — \(error.localizedDescription)")
return
}
self.resumable[id] = nil
Config.shared.log("Local model installed: \(id)")
// First model in gets to be the one in use, so a fresh
// install is usable without a second click.
let current = Config.shared.model(for: .local)
if Self.entry(current).map({ !self.isInstalled($0) }) ?? true {
Config.shared.set(Provider.local.modelVar, id)
}