Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version: 5.10
// swift-tools-version: 6.2
import PackageDescription

let package = Package(
Expand All @@ -11,19 +11,22 @@ let package = Package(
products: [
.library(name: "AIProcessingCore", targets: ["AIProcessingCore"]),
.library(name: "ASRCore", targets: ["ASRCore"]),
.library(name: "FluidASRMac", targets: ["FluidASRMac"]),
.library(name: "AudioCore", targets: ["AudioCore"]),
.library(name: "SessionCore", targets: ["SessionCore"]),
.library(name: "SnippetCore", targets: ["SnippetCore"]),
.library(name: "TextTargetMac", targets: ["TextTargetMac"]),
.library(name: "CapabilityCore", targets: ["CapabilityCore"])
],
dependencies: [.package(url: "https://github.com/FluidInference/FluidAudio", exact: "0.17.7", traits: [])],
targets: [
.target(
name: "AIProcessingCore"
),
.target(
name: "ASRCore"
),
.target(name: "FluidASRMac", dependencies: ["ASRCore", .product(name: "FluidAudio", package: "FluidAudio", condition: .when(platforms: [.macOS]))]),
.target(
name: "AudioCore",
dependencies: ["ASRCore"]
Expand All @@ -46,6 +49,7 @@ let package = Package(
dependencies: [
"AIProcessingCore",
"ASRCore",
"FluidASRMac",
"AudioCore",
"CapabilityCore",
"SessionCore",
Expand All @@ -62,6 +66,10 @@ let package = Package(
name: "AIProcessingCoreTests",
dependencies: ["AIProcessingCore"]
),
.testTarget(name: "FluidASRMacTests", dependencies: [
"FluidASRMac", "ASRCore",
.product(name: "FluidAudio", package: "FluidAudio", condition: .when(platforms: [.macOS]))
]),
.testTarget(
name: "ASRCoreTests",
dependencies: ["ASRCore"]
Expand Down Expand Up @@ -90,5 +98,6 @@ let package = Package(
name: "AppShellSupportTests",
dependencies: ["AppShellSupport"]
)
]
],
swiftLanguageModes: [.v5]
)
1 change: 1 addition & 0 deletions Sources/ASRCore/ASRTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation

public enum ASRBackend: String, Sendable, Codable {
case whisperCpp
case fluidAudio
case coreMLHybrid
case nemoSpeech
}
Expand Down
84 changes: 84 additions & 0 deletions Sources/ASRCore/NativeVoiceModelBundle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import CryptoKit
import Foundation

public struct NativeVoiceModelFile: Codable, Sendable {
public let path: String
public let bytes: Int64
public let sha256: String
}

public struct NativeVoiceModelBundle: Codable, Sendable {
public let modelID: String
public let repository: String
public let revision: String
public let bundleName: String
public let files: [NativeVoiceModelFile]

public var expectedBytes: Int64 { files.reduce(0) { $0 + $1.bytes } }

public static func manifest(modelID: String) throws -> NativeVoiceModelBundle {
guard let result = all.first(where: { $0.modelID == modelID }) else {
throw VoiceModelInstallerError.modelNotDownloadable(modelID)
}
return result
}

public func validate(at directory: URL) throws {
for file in files {
let url = directory.appendingPathComponent(file.path)
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey, .isSymbolicLinkKey])
guard values.isRegularFile == true, values.isSymbolicLink != true,
values.fileSize.map(Int64.init) == file.bytes else {
throw VoiceModelInstallerError.runtimeInstallFailed("Invalid model file: \(file.path)")
}
let data = try Data(contentsOf: url, options: .mappedIfSafe)
let checksum = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
guard checksum == file.sha256 else {
throw VoiceModelInstallerError.runtimeInstallFailed("Model checksum mismatch: \(file.path)")
}
}
}

public static let all: [NativeVoiceModelBundle] = [
NativeVoiceModelBundle(modelID: "fermion.phonon2", repository: "FluidInference/phonon-2-coreml", revision: "1702fc22e76f203d5ca464cd5d3ef130a45ce2c0", bundleName: "phonon-2-coreml", files: [
NativeVoiceModelFile(path: "Decoder.mlmodelc/analytics/coremldata.bin", bytes: 243, sha256: "3d1a9cd4d7b7956d20c48d9a8041717f9f02c6db72dfeda565080e1cff1db3a3"),
NativeVoiceModelFile(path: "Decoder.mlmodelc/coremldata.bin", bytes: 554, sha256: "2590e4588972f8d3d1ddb10ce603decffd2e9bb79a88c6096a58c5732f6369fd"),
NativeVoiceModelFile(path: "Decoder.mlmodelc/model.mil", bytes: 13110, sha256: "956f600207f88396017ca5c96cfa3acd5bfee60835a5b44b762e033a8fb28955"),
NativeVoiceModelFile(path: "Decoder.mlmodelc/weights/weight.bin", bytes: 23604992, sha256: "05181616ed1af70b2aad0463d08d1d73a245af4ccb6159df79994f28c135d9d6"),
NativeVoiceModelFile(path: "Encoder.mlmodelc/analytics/coremldata.bin", bytes: 243, sha256: "3d674b78b1a6916586611089d7ac80b4d20d94f691c0497f92b2a58339c5a0e2"),
NativeVoiceModelFile(path: "Encoder.mlmodelc/coremldata.bin", bytes: 500, sha256: "4b661445d3e93201e3d70d616eaa9f061117a4ee1e4a6191d18a23dc23068b84"),
NativeVoiceModelFile(path: "Encoder.mlmodelc/model.mil", bytes: 2734921, sha256: "b8954fb3ef2fedcb9ce01c67f0490cfd905b79862a33912dea12100cd24a5170"),
NativeVoiceModelFile(path: "Encoder.mlmodelc/weights/weight.bin", bytes: 338391488, sha256: "d6a51593fc07b3c81989d5f871f687e78c52998aae0b06d000faced47313edb0"),
NativeVoiceModelFile(path: "JointDecisionv3.mlmodelc/analytics/coremldata.bin", bytes: 243, sha256: "7b53f663c4400205f8bc00243076de50f95d8a1a64978dee7232eb0ddc7d683c"),
NativeVoiceModelFile(path: "JointDecisionv3.mlmodelc/coremldata.bin", bytes: 586, sha256: "8d2170f28b28466476f62484a0c8b07c2d31713ac254d1b7cf80eba8070e9554"),
NativeVoiceModelFile(path: "JointDecisionv3.mlmodelc/model.mil", bytes: 11777, sha256: "791b3c3cf3eb2079c84623fc880f6bba008d1366e5f8b03e9a8ed8bd4d7194a0"),
NativeVoiceModelFile(path: "JointDecisionv3.mlmodelc/weights/weight.bin", bytes: 12642764, sha256: "5f922e335bc57174645fd6ede581395f1ae846f7227898301b5a1a7585f4ab45"),
NativeVoiceModelFile(path: "Preprocessor.mlmodelc/analytics/coremldata.bin", bytes: 243, sha256: "c9beeb989c8d66f8be11df59bc6df277ec76cee404f6865b46243835ef562f6d"),
NativeVoiceModelFile(path: "Preprocessor.mlmodelc/coremldata.bin", bytes: 486, sha256: "dbde3f2300842c1fd51ef3ff948a0bcffe65ffd2dca10707f2509f32c1d65b1d"),
NativeVoiceModelFile(path: "Preprocessor.mlmodelc/metadata.json", bytes: 2841, sha256: "2a98699e22d279dd37fa1d238aeb1c6db1df0d6fad687775324157689d8f3acf"),
NativeVoiceModelFile(path: "Preprocessor.mlmodelc/model.mil", bytes: 28181, sha256: "4b8518a956450fec57f06c2a21bdffc26973f7f1fa6842fb38fe917f896b6b93"),
NativeVoiceModelFile(path: "Preprocessor.mlmodelc/weights/weight.bin", bytes: 491072, sha256: "129b76e3aeafa8afa3ea76d995b964b145fe83700d579f6ff42c4c38fa0968ea"),
NativeVoiceModelFile(path: "parakeet_vocab.json", bytes: 151122, sha256: "7ec60e05f1b24480736ec0eed40900f4626bce1fa9a60fd700ec7e2a59198735"),
]),
NativeVoiceModelBundle(modelID: "moondream.parakeet-redux", repository: "FluidInference/parakeet-redux-coreml", revision: "8c5ef97a29cd120dc76b354b3f22b7fec3b486f9", bundleName: "parakeet-redux-coreml", files: [
NativeVoiceModelFile(path: "Decoder.mlmodelc/analytics/coremldata.bin", bytes: 243, sha256: "7f24e7248d57024c6f1c5a2ea306f4a6a89db45a363218523bb6410c4b1aecf0"),
NativeVoiceModelFile(path: "Decoder.mlmodelc/coremldata.bin", bytes: 560, sha256: "422da51d983e93a3e48cd6d46a17d6c32844452414c8e242fb0caaca2b8af5ed"),
NativeVoiceModelFile(path: "Decoder.mlmodelc/model.mil", bytes: 13110, sha256: "956f600207f88396017ca5c96cfa3acd5bfee60835a5b44b762e033a8fb28955"),
NativeVoiceModelFile(path: "Decoder.mlmodelc/weights/weight.bin", bytes: 23604992, sha256: "6c6c88c23ee5492a6b229e8cc636ddadb2b01ae0dd559d9cb63123bebf507d77"),
NativeVoiceModelFile(path: "Encoder.mlmodelc/analytics/coremldata.bin", bytes: 243, sha256: "7862e972e09315df099d4bfd320743bf106acbf7fe4343317f9de45b0635d7b7"),
NativeVoiceModelFile(path: "Encoder.mlmodelc/coremldata.bin", bytes: 506, sha256: "ca0b212fe3c06c3ccd35e508fa61e4dfaad19dd0c55b82e4f5bf8711e7758fd9"),
NativeVoiceModelFile(path: "Encoder.mlmodelc/model.mil", bytes: 965906, sha256: "165487e314ed93c4a44dfec99d57be4e436e7c3dba6eef49fd3f0daf9de6d0d4"),
NativeVoiceModelFile(path: "Encoder.mlmodelc/weights/weight.bin", bytes: 182389312, sha256: "adbb5550dc6488717d3a1407b53122fbdb1af5e99c330ee20f8d8cfd9ec49252"),
NativeVoiceModelFile(path: "JointDecisionv3.mlmodelc/analytics/coremldata.bin", bytes: 243, sha256: "fc857a756b8c9c9f31aa9e1062766cc0a82ac035ef369d6e1cbe97184921479c"),
NativeVoiceModelFile(path: "JointDecisionv3.mlmodelc/coremldata.bin", bytes: 592, sha256: "611ea653e2adede7cc1cfe70da36296844f97e95e2c7dd961d45230e5599389d"),
NativeVoiceModelFile(path: "JointDecisionv3.mlmodelc/model.mil", bytes: 11777, sha256: "791b3c3cf3eb2079c84623fc880f6bba008d1366e5f8b03e9a8ed8bd4d7194a0"),
NativeVoiceModelFile(path: "JointDecisionv3.mlmodelc/weights/weight.bin", bytes: 12642764, sha256: "73b0288acd115fc4c36038f66b6cbf49233182488febf5d8c787bea4009535f7"),
NativeVoiceModelFile(path: "Preprocessor.mlmodelc/analytics/coremldata.bin", bytes: 243, sha256: "c9beeb989c8d66f8be11df59bc6df277ec76cee404f6865b46243835ef562f6d"),
NativeVoiceModelFile(path: "Preprocessor.mlmodelc/coremldata.bin", bytes: 486, sha256: "dbde3f2300842c1fd51ef3ff948a0bcffe65ffd2dca10707f2509f32c1d65b1d"),
NativeVoiceModelFile(path: "Preprocessor.mlmodelc/metadata.json", bytes: 2841, sha256: "2a98699e22d279dd37fa1d238aeb1c6db1df0d6fad687775324157689d8f3acf"),
NativeVoiceModelFile(path: "Preprocessor.mlmodelc/model.mil", bytes: 28181, sha256: "4b8518a956450fec57f06c2a21bdffc26973f7f1fa6842fb38fe917f896b6b93"),
NativeVoiceModelFile(path: "Preprocessor.mlmodelc/weights/weight.bin", bytes: 491072, sha256: "129b76e3aeafa8afa3ea76d995b964b145fe83700d579f6ff42c4c38fa0968ea"),
NativeVoiceModelFile(path: "parakeet_vocab.json", bytes: 151122, sha256: "7ec60e05f1b24480736ec0eed40900f4626bce1fa9a60fd700ec7e2a59198735"),
]),
]
}
31 changes: 31 additions & 0 deletions Sources/ASRCore/VoiceModelCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import Foundation
public enum VoiceProviderID: String, Codable, CaseIterable, Sendable {
case whisperCpp = "whisper.cpp"
case nvidiaParakeet = "nvidia.parakeet"
case fermionPhonon = "fermion.phonon2"
case moondreamRedux = "moondream.parakeet-redux"
}

public struct VoiceProviderDescriptor: Identifiable, Codable, Equatable, Sendable {
Expand Down Expand Up @@ -166,6 +168,11 @@ public enum LocalVoiceModelCatalog {
)
)

for (id, name) in [(VoiceProviderID.fermionPhonon, "Fermion Research"), (.moondreamRedux, "Moondream")] {
providers.append(VoiceProviderDescriptor(id: id.rawValue, displayName: name,
summary: "Lokale Core-ML-Spracherkennung über FluidAudio (Apple Silicon, macOS 15+).",
isAvailable: nativeModelsAvailable))
}
return providers
}

Expand All @@ -174,13 +181,37 @@ public enum LocalVoiceModelCatalog {
if includeParakeet {
models.append(contentsOf: parakeetModels)
}
models.append(contentsOf: nativeModels)
return models
}

public static func model(id: String, includeParakeet: Bool = true) -> VoiceModelDescriptor? {
availableModels(includeParakeet: includeParakeet).first(where: { $0.id == id })
}

public static var nativeModelsAvailable: Bool {
#if os(macOS) && arch(arm64)
if #available(macOS 15, *) { return true }
#endif
return false
}

private static let nativeModels: [VoiceModelDescriptor] = NativeVoiceModelBundle.all.map { bundle in
let english = bundle.modelID == "fermion.phonon2"
return VoiceModelDescriptor(
id: bundle.modelID, providerID: bundle.modelID,
displayName: english ? "Phonon-2 (English)" : "Parakeet Redux",
languageCode: english ? "en" : nil, languageScope: english ? .english : .multilingual,
supportsTranslationToEnglish: false, supportsLiveTranscription: true,
supportsAutomaticLanguageDetection: !english, supportsLanguageSelection: !english,
supportsQualityProfile: false,
supportedLanguageCodes: english ? ["en"] : ["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"],
speedScore: 5, accuracyScore: 5,
sizeLabel: formattedDownloadSize(bundle.expectedBytes), expectedDownloadBytes: bundle.expectedBytes,
installState: nativeModelsAvailable ? .downloadable : .unavailable,
localFileName: bundle.bundleName, downloadIdentifier: bundle.modelID, runtimeID: "fluidAudio")
}

private static let whisperModels: [VoiceModelDescriptor] = [
VoiceModelDescriptor(
id: "whisper.ultra-v3-turbo", providerID: VoiceProviderID.whisperCpp.rawValue,
Expand Down
88 changes: 88 additions & 0 deletions Sources/ASRCore/VoiceModelInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -606,3 +606,91 @@ enum NemoSpeechRuntimeManager {
return String(data: outData, encoding: .utf8) ?? ""
}
}

extension VoiceModelInstaller {
public static func nativeModelURL(modelID: String) throws -> URL {
let manifest = try NativeVoiceModelBundle.manifest(modelID: modelID)
let support = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
return support.appendingPathComponent("Cortexa/NativeVoiceModels", isDirectory: true)
.appendingPathComponent(manifest.bundleName, isDirectory: true)
}

public func installedNativeModelFileNames() throws -> Set<String> {
var names: Set<String> = []
for manifest in NativeVoiceModelBundle.all {
let directory = try Self.nativeModelURL(modelID: manifest.modelID)
guard fileManager.fileExists(atPath: directory.path) else { continue }
do {
try manifest.validate(at: directory)
names.insert(manifest.bundleName)
} catch {
NSLog("Cortexa native model validation failed: %@ (%@)", manifest.modelID, error.localizedDescription)
}
}
return names
}

public func installedVoiceModelFileNames() throws -> Set<String> {
let directory = try BundledWhisperRuntimeInstaller.defaultInstallDirectory(fileManager: fileManager)
.appendingPathComponent("models", isDirectory: true)
let whisperFiles: Set<String>
if fileManager.fileExists(atPath: directory.path) {
whisperFiles = Set(try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
.filter { $0.pathExtension == "bin" }.map(\.lastPathComponent))
} else {
whisperFiles = []
}
return try whisperFiles.union(installedParakeetModelFileNames()).union(installedNativeModelFileNames())
}

public func installNativeModel(
_ descriptor: VoiceModelDescriptor,
progressHandler: (@Sendable (VoiceModelInstallProgress) -> Void)?
) async throws -> URL {
guard #available(macOS 15, *) else {
throw VoiceModelInstallerError.runtimeInstallFailed("Native voice models require macOS 15 or later.")
}
guard LocalVoiceModelCatalog.nativeModelsAvailable else {
throw VoiceModelInstallerError.runtimeInstallFailed("Native voice models require an Apple Silicon Mac running macOS 15 or later.")
}
let manifest = try NativeVoiceModelBundle.manifest(modelID: descriptor.id)
let destination = try Self.nativeModelURL(modelID: descriptor.id)
let parent = destination.deletingLastPathComponent()
try fileManager.createDirectory(at: parent, withIntermediateDirectories: true)
let staging = parent.appendingPathComponent(".install-" + UUID().uuidString, isDirectory: true)
try fileManager.createDirectory(at: staging, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: staging) }
let client = VoiceModelDownloadClient(fileManager: fileManager)
var completed: Int64 = 0
for file in manifest.files {
try Task.checkCancellation()
let target = staging.appendingPathComponent(file.path)
try fileManager.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
let source = URL(string: "https://huggingface.co/\(manifest.repository)/resolve/\(manifest.revision)/\(file.path)")!
let previous = completed
try await client.download(from: source, to: target, expectedDownloadBytes: file.bytes) { progress in
let received = previous + Int64(Double(file.bytes) * progress.fractionCompleted)
progressHandler?(VoiceModelInstallProgress(phase: .downloading, fractionCompleted: Double(received) / Double(manifest.expectedBytes), receivedBytes: received, totalBytes: manifest.expectedBytes))
}
completed += file.bytes
}
try Task.checkCancellation()
progressHandler?(VoiceModelInstallProgress(phase: .finalizing, fractionCompleted: 0, isIndeterminate: true))
try manifest.validate(at: staging)
try Task.checkCancellation()
if fileManager.fileExists(atPath: destination.path) {
_ = try fileManager.replaceItemAt(destination, withItemAt: staging)
} else {
try fileManager.moveItem(at: staging, to: destination)
}
progressHandler?(VoiceModelInstallProgress(phase: .finalizing, fractionCompleted: 1))
return destination
}

public func removeNativeModel(_ descriptor: VoiceModelDescriptor) throws {
let destination = try Self.nativeModelURL(modelID: descriptor.id)
if fileManager.fileExists(atPath: destination.path) {
try fileManager.removeItem(at: destination)
}
}
}
Loading
Loading