Skip to content
7 changes: 4 additions & 3 deletions Sources/Fluid/Networking/ModelDownloader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -365,10 +365,11 @@ final class HuggingFaceModelDownloader {
}
}
if url.pathExtension == "mlmodelc" {
// `model.mil` is optional in valid compiled bundles (the Flash preprocessor ships
// without it), so installation truth comes from compiled metadata plus weights.
// `model.mil` and `metadata.json` are descriptive and absent from some valid
// compiled bundles (the Flash preprocessor omits `model.mil`; the SenseVoice
// export omits `metadata.json`). Load-critical truth is the compiled index plus
// weights, so require only those two.
return self.fileHasContents(at: url.appendingPathComponent("coremldata.bin"))
&& self.fileHasContents(at: url.appendingPathComponent("metadata.json"))
&& self.fileHasContents(
at: url
.appendingPathComponent("weights", isDirectory: true)
Expand Down
33 changes: 33 additions & 0 deletions Sources/Fluid/Persistence/SettingsStore+SenseVoiceLanguage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation

extension SettingsStore {
/// Language selection for the SenseVoiceSmall model. The raw values are stable
/// persistence keys; `embedIndex` maps to the model's `lid_int_dict` language
/// embedding input (auto = 0, Mandarin/zh = 3, Cantonese/yue = 7). Verified
/// against vocab.json: token `<|yue|>` → embed index 7 (index 13 is
/// `<|nospeech|>`, not Cantonese).
enum SenseVoiceLanguage: String, CaseIterable, Identifiable, Codable {
case cantonese
case mandarin
case auto

var id: String { self.rawValue }

/// SenseVoice `language` encoder input (embed index).
var embedIndex: Int32 {
switch self {
case .auto: return 0
case .mandarin: return 3
case .cantonese: return 7
}
}

var displayName: String {
switch self {
case .cantonese: return "Cantonese (粵語)"
case .mandarin: return "Mandarin (國語)"
case .auto: return "Auto Detect"
}
}
}
}
56 changes: 51 additions & 5 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3737,6 +3737,7 @@ final class SettingsStore: ObservableObject {
case parakeetRealtime = "parakeet-realtime"
case qwen3Asr = "qwen3-asr"
case cohereTranscribeSixBit = "cohere-transcribe-6bit"
case senseVoiceSmall = "sensevoice-small"
case nemotronOffline = "nemotron-3.5-offline"
case nemotronStreaming = "nemotron-3.5-streaming"
case nemotronStreaming320 = "nemotron-3.5-streaming-320"
Expand Down Expand Up @@ -3768,6 +3769,7 @@ final class SettingsStore: ObservableObject {
case .parakeetRealtime: return "Parakeet Flash (Beta)"
case .qwen3Asr: return "Qwen3 ASR (Beta)"
case .cohereTranscribeSixBit: return "Cohere Transcribe"
case .senseVoiceSmall: return "SenseVoice Small"
case .nemotronOffline: return "Nemotron 3.5 Multilingual"
case .nemotronStreaming: return "Nemotron Speech 3.5 - Ultra Fast Low Latency"
case .nemotronStreaming320: return "Nemotron Speech 3.5 - Ultra Fast Low Latency"
Expand All @@ -3790,6 +3792,7 @@ final class SettingsStore: ObservableObject {
case .parakeetRealtime: return "English Only (Live Streaming)"
case .qwen3Asr: return "30 Languages"
case .cohereTranscribeSixBit: return "14 Languages (Select Manually)"
case .senseVoiceSmall: return "Cantonese, Mandarin & 50+ (Select Manually)"
case .nemotronOffline, .nemotronStreaming, .nemotronStreaming320: return "Around 40 Languages"
case .appleSpeech: return "System Languages"
case .appleSpeechAnalyzer: return "EN, ES, FR, DE, IT, JA, KO, PT, ZH"
Expand All @@ -3805,6 +3808,7 @@ final class SettingsStore: ObservableObject {
case .parakeetRealtime: return "~428.4 MiB"
case .qwen3Asr: return "~2.0 GiB"
case .cohereTranscribeSixBit: return "~1.54 GiB"
case .senseVoiceSmall: return "~215 MiB"
case .nemotronOffline: return "~530.8 MiB"
case .nemotronStreaming: return "~668.2 MiB"
case .nemotronStreaming320: return "~668.2 MiB"
Expand All @@ -3826,6 +3830,7 @@ final class SettingsStore: ObservableObject {
case .parakeetRealtime: return 449_190_189
case .qwen3Asr: return 2000 * 1024 * 1024
case .cohereTranscribeSixBit: return 1_650_748_785
case .senseVoiceSmall: return 225_000_000
case .nemotronOffline: return 556_552_620
case .nemotronStreaming, .nemotronStreaming320: return 700_685_415
case .whisperTiny: return 77_691_713
Expand All @@ -3840,14 +3845,14 @@ final class SettingsStore: ObservableObject {

var requiresAppleSilicon: Bool {
switch self {
case .parakeetTDT, .parakeetTDTv2, .parakeetRealtime, .qwen3Asr, .cohereTranscribeSixBit, .nemotronOffline, .nemotronStreaming, .nemotronStreaming320: return true
case .parakeetTDT, .parakeetTDTv2, .parakeetRealtime, .qwen3Asr, .cohereTranscribeSixBit, .senseVoiceSmall, .nemotronOffline, .nemotronStreaming, .nemotronStreaming320: return true
default: return false
}
}

var isWhisperModel: Bool {
switch self {
case .parakeetTDT, .parakeetTDTv2, .parakeetRealtime, .qwen3Asr, .cohereTranscribeSixBit, .nemotronOffline, .nemotronStreaming, .nemotronStreaming320, .appleSpeech, .appleSpeechAnalyzer: return false
case .parakeetTDT, .parakeetTDTv2, .parakeetRealtime, .qwen3Asr, .cohereTranscribeSixBit, .senseVoiceSmall, .nemotronOffline, .nemotronStreaming, .nemotronStreaming320, .appleSpeech, .appleSpeechAnalyzer: return false
default: return true
}
}
Expand Down Expand Up @@ -3943,6 +3948,7 @@ final class SettingsStore: ObservableObject {
case .parakeetRealtime: return "Flash Dictation"
case .qwen3Asr: return "Qwen3 - Multilingual"
case .cohereTranscribeSixBit: return "Cohere - High Accuracy"
case .senseVoiceSmall: return "Cantonese - Fast & Accurate"
case .nemotronOffline: return "Nemotron 3.5 Multilingual"
case .nemotronStreaming: return "Nemotron Speech 3.5 - Ultra Fast Low Latency"
case .nemotronStreaming320: return "Nemotron Speech 3.5 - Ultra Fast Low Latency"
Expand Down Expand Up @@ -3973,6 +3979,8 @@ final class SettingsStore: ObservableObject {
return "Qwen3 multilingual ASR via FluidAudio. Higher quality, heavier memory footprint."
case .cohereTranscribeSixBit:
return "High-accuracy multilingual transcription. Select the language manually before dictation for best results."
case .senseVoiceSmall:
return "Non-autoregressive FunASR model with state-of-the-art Cantonese accuracy. Runs on the Neural Engine with a tiny memory footprint. Select Cantonese, Mandarin, or auto-detect."
case .nemotronOffline:
return "Slower but more accurate NVIDIA Nemotron 3.5 transcription. Supports 40 language-locales with auto or manual language selection."
case .nemotronStreaming:
Expand Down Expand Up @@ -4007,6 +4015,8 @@ final class SettingsStore: ObservableObject {
return 8.0
case .cohereTranscribeSixBit:
return 8.0
case .senseVoiceSmall:
return 2.0
case .nemotronOffline, .nemotronStreaming, .nemotronStreaming320:
return 8.0
case .appleSpeech, .appleSpeechAnalyzer:
Expand Down Expand Up @@ -4050,6 +4060,7 @@ final class SettingsStore: ObservableObject {
case .parakeetRealtime: return 5
case .qwen3Asr: return 3
case .cohereTranscribeSixBit: return 3
case .senseVoiceSmall: return 5
case .nemotronOffline: return 3
case .nemotronStreaming, .nemotronStreaming320: return 4
case .appleSpeech: return 4
Expand All @@ -4071,6 +4082,7 @@ final class SettingsStore: ObservableObject {
case .parakeetRealtime: return 4
case .qwen3Asr: return 4
case .cohereTranscribeSixBit: return 5
case .senseVoiceSmall: return 4
case .nemotronOffline: return 5
case .nemotronStreaming, .nemotronStreaming320: return 4
case .appleSpeech: return 4
Expand All @@ -4092,6 +4104,7 @@ final class SettingsStore: ObservableObject {
case .parakeetRealtime: return 1.0
case .qwen3Asr: return 0.45
case .cohereTranscribeSixBit: return 0.85
case .senseVoiceSmall: return 0.95
case .nemotronOffline: return 0.85
case .nemotronStreaming, .nemotronStreaming320: return 1.0
case .appleSpeech: return 0.60
Expand All @@ -4113,6 +4126,7 @@ final class SettingsStore: ObservableObject {
case .parakeetRealtime: return 0.75
case .qwen3Asr: return 0.90
case .cohereTranscribeSixBit: return 0.98
case .senseVoiceSmall: return 0.90
case .nemotronOffline: return 0.90
case .nemotronStreaming, .nemotronStreaming320: return 0.85
case .appleSpeech: return 0.60
Expand Down Expand Up @@ -4143,7 +4157,7 @@ final class SettingsStore: ObservableObject {
/// Optimization level for Apple Silicon (for display)
var appleSiliconOptimized: Bool {
switch self {
case .parakeetTDT, .parakeetTDTv2, .parakeetRealtime, .qwen3Asr, .cohereTranscribeSixBit, .nemotronOffline, .nemotronStreaming, .nemotronStreaming320, .appleSpeechAnalyzer:
case .parakeetTDT, .parakeetTDTv2, .parakeetRealtime, .qwen3Asr, .cohereTranscribeSixBit, .senseVoiceSmall, .nemotronOffline, .nemotronStreaming, .nemotronStreaming320, .appleSpeechAnalyzer:
return true
default:
return false
Expand All @@ -4154,8 +4168,8 @@ final class SettingsStore: ObservableObject {
/// Large Whisper models are too slow for streaming, so they only do final transcription on stop.
var supportsStreaming: Bool {
switch self {
case .qwen3Asr, .whisperMedium, .whisperLargeTurbo, .whisperLarge:
return false // Too slow for real-time chunk processing
case .qwen3Asr, .senseVoiceSmall, .whisperMedium, .whisperLargeTurbo, .whisperLarge:
return false // Non-streaming / too slow for real-time chunk processing
default:
return true // All other models support streaming
}
Expand Down Expand Up @@ -4198,6 +4212,7 @@ final class SettingsStore: ObservableObject {
case openai = "OpenAI"
case qwen = "Qwen"
case cohere = "Cohere"
case funasr = "FunASR"
}

/// Which provider this model belongs to
Expand All @@ -4211,6 +4226,8 @@ final class SettingsStore: ObservableObject {
return .qwen
case .cohereTranscribeSixBit:
return .cohere
case .senseVoiceSmall:
return .funasr
case .whisperTiny, .whisperBase, .whisperSmall, .whisperMedium, .whisperLargeTurbo, .whisperLarge:
return .openai
}
Expand Down Expand Up @@ -4261,6 +4278,13 @@ final class SettingsStore: ObservableObject {
return false
}
return spec.validatesInstalledArtifacts(at: directory)
case .senseVoiceSmall:
#if arch(arm64)
guard let directory = SenseVoiceProvider.cacheDirectory() else { return false }
return SenseVoiceProvider.artifactsAreComplete(at: directory)
#else
return false
#endif
case .nemotronOffline, .nemotronStreaming, .nemotronStreaming320:
let hint: String
switch self {
Expand Down Expand Up @@ -4338,6 +4362,8 @@ final class SettingsStore: ObservableObject {
return "Qwen"
case .cohereTranscribeSixBit:
return "Cohere"
case .senseVoiceSmall:
return "FunASR"
case .appleSpeech, .appleSpeechAnalyzer:
return "Apple"
case .whisperTiny, .whisperBase, .whisperSmall, .whisperMedium, .whisperLargeTurbo, .whisperLarge:
Expand All @@ -4362,6 +4388,8 @@ final class SettingsStore: ObservableObject {
return "#E67E22"
case .cohereTranscribeSixBit:
return "#FA6B3C"
case .senseVoiceSmall:
return "#FF6A00" // Alibaba Orange
case .appleSpeech, .appleSpeechAnalyzer:
return "#A2AAAD" // Apple Gray
case .whisperTiny, .whisperBase, .whisperSmall, .whisperMedium, .whisperLargeTurbo, .whisperLarge:
Expand Down Expand Up @@ -4557,6 +4585,7 @@ private extension SettingsStore {
static let selectedSpeechModel = "SelectedSpeechModel"
static let selectedCohereLanguage = "SelectedCohereLanguage"
static let selectedNemotronLanguage = "SelectedNemotronLanguage"
static let senseVoiceLanguage = "SenseVoiceLanguage"
static let selectedAppleSpeechLocaleIdentifier = "SelectedAppleSpeechLocaleIdentifier"
static let externalCoreMLArtifactsDirectories = "ExternalCoreMLArtifactsDirectories"

Expand Down Expand Up @@ -4696,6 +4725,8 @@ extension SettingsStore.SpeechModel {
return "EN"
case .cohereTranscribeSixBit:
return "AR, DE, EL, EN, ES, FR, IT, JA, KO, NL, PL, PT, VI, ZH"
case .senseVoiceSmall:
return "YUE, ZH, EN, JA, KO + 50 more"
case .nemotronOffline, .nemotronStreaming, .nemotronStreaming320:
return "40 language-locales"
case .appleSpeechAnalyzer:
Expand Down Expand Up @@ -4838,6 +4869,21 @@ extension SettingsStore {
}
}

var senseVoiceLanguage: SenseVoiceLanguage {
get {
if let rawValue = self.defaults.string(forKey: Keys.senseVoiceLanguage),
let language = SenseVoiceLanguage(rawValue: rawValue)
{
return language
}
return .cantonese
}
set {
objectWillChange.send()
self.defaults.set(newValue.rawValue, forKey: Keys.senseVoiceLanguage)
}
}

func externalCoreMLArtifactsDirectory(for model: SpeechModel) -> URL? {
guard let spec = model.externalCoreMLSpec else { return nil }
let paths = self.defaults.dictionary(forKey: Keys.externalCoreMLArtifactsDirectories) as? [String: String] ?? [:]
Expand Down
16 changes: 16 additions & 0 deletions Sources/Fluid/Services/ASRService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ final class ASRService: ObservableObject {
private var parakeetRealtimeProvider: ParakeetRealtimeProvider?
private var externalCoreMLProvider: ExternalCoreMLTranscriptionProvider?
private var nemotronProviders: [NemotronProvider.Mode: NemotronProvider] = [:]
private var senseVoiceProvider: SenseVoiceProvider?
private var whisperProvider: WhisperProvider?
private var appleSpeechProvider: AppleSpeechProvider?
/// Stored as Any? because @available cannot be applied to stored properties
Expand Down Expand Up @@ -207,6 +208,8 @@ final class ASRService: ObservableObject {
return self.getParakeetRealtimeProvider()
case .cohereTranscribeSixBit:
return self.getExternalCoreMLProvider()
case .senseVoiceSmall:
return self.getSenseVoiceProvider()
case .nemotronOffline, .nemotronStreaming, .nemotronStreaming320:
return self.getNemotronProvider(mode: model.nemotronProviderMode)
case .qwen3Asr:
Expand Down Expand Up @@ -259,6 +262,16 @@ final class ASRService: ObservableObject {
return provider
}

private func getSenseVoiceProvider() -> SenseVoiceProvider {
if let existing = self.senseVoiceProvider {
return existing
}
let provider = SenseVoiceProvider()
self.senseVoiceProvider = provider
DebugLogger.shared.info("ASRService: Created SenseVoice provider", source: "ASRService")
return provider
}

private func getWhisperProvider() -> WhisperProvider {
if let existing = whisperProvider {
return existing
Expand Down Expand Up @@ -408,6 +421,8 @@ final class ASRService: ObservableObject {
return ParakeetRealtimeProvider()
case .cohereTranscribeSixBit:
return ExternalCoreMLTranscriptionProvider(modelOverride: model)
case .senseVoiceSmall:
return SenseVoiceProvider(modelOverride: model)
case .nemotronOffline, .nemotronStreaming, .nemotronStreaming320:
return NemotronProvider(mode: model.nemotronProviderMode)
case .qwen3Asr:
Expand Down Expand Up @@ -525,6 +540,7 @@ final class ASRService: ObservableObject {
self.fluidAudioProvider = nil
self.parakeetRealtimeProvider = nil
self.externalCoreMLProvider = nil
self.senseVoiceProvider = nil
self.whisperProvider = nil
self.appleSpeechProvider = nil
self._appleSpeechAnalyzerProvider = nil
Expand Down
Loading
Loading