Skip to content
Open
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
38 changes: 38 additions & 0 deletions Sources/FluidAudio/ModelNames.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ public enum Repo: String, CaseIterable, Sendable {
/// `tokens.txt` / `config.json`. Conversion lives in mobius
/// (`models/tts/zipvoice`).
case luxtts = "FluidInference/luxtts-coreml"
/// Inflect v2 (Micro / Nano) — ultra-tiny VITS-family English TTS. One repo
/// with two variant subdirectories (`micro/`, `nano/`), each holding
/// `encoder.mlmodelc` + 8 `synthesizer_f<N>.mlmodelc` frame buckets.
/// Conversion lives in mobius (`models/tts/inflect-v2`).
case inflectMicro = "FluidInference/inflect-v2-coreml/micro"
case inflectNano = "FluidInference/inflect-v2-coreml/nano"

/// Repository slug (without owner)
public var name: String {
Expand Down Expand Up @@ -142,6 +148,10 @@ public enum Repo: String, CaseIterable, Sendable {
return "supertonic-3-coreml"
case .luxtts:
return "luxtts-coreml"
case .inflectMicro:
return "inflect-v2-coreml/micro"
case .inflectNano:
return "inflect-v2-coreml/nano"
}
}

Expand Down Expand Up @@ -170,6 +180,8 @@ public enum Repo: String, CaseIterable, Sendable {
return "FluidInference/cohere-transcribe-03-2026-coreml"
case .styletts2:
return "FluidInference/StyleTTS-2-coreml"
case .inflectMicro, .inflectNano:
return "FluidInference/inflect-v2-coreml"
default:
return "FluidInference/\(name)"
}
Expand Down Expand Up @@ -208,6 +220,10 @@ public enum Repo: String, CaseIterable, Sendable {
return "q8"
case .styletts2:
return "iteration_3/compiled"
case .inflectMicro:
return "micro"
case .inflectNano:
return "nano"
default:
return nil
}
Expand Down Expand Up @@ -262,6 +278,10 @@ public enum Repo: String, CaseIterable, Sendable {
return "styletts2"
case .supertonic3:
return "supertonic-3"
case .inflectMicro:
return "inflect-v2-coreml/micro"
case .inflectNano:
return "inflect-v2-coreml/nano"
default:
return name.replacingOccurrences(of: "-coreml", with: "")
}
Expand Down Expand Up @@ -1154,6 +1174,22 @@ public enum ModelNames {
}
}

/// Inflect v2 (Micro / Nano) — VITS-family English TTS. Each variant
/// subdirectory holds a fixed-shape `encoder` and 8 `synthesizer_f<N>`
/// frame buckets. File names match `FluidInference/inflect-v2-coreml/<v>/`.
public enum Inflect {
public static let encoderFile = "encoder.mlmodelc"

public static func synthesizerFile(frames: Int) -> String {
"synthesizer_f\(frames).mlmodelc"
}

/// Encoder + all 8 synthesizer buckets (downloaded up front; buckets
/// load lazily). Buckets mirror `InflectConstants.frameBuckets`.
public static let requiredModels: Set<String> = Set(
[encoderFile] + InflectConstants.frameBuckets.map { synthesizerFile(frames: $0) })
}

/// LuxTTS (ZipVoice-Distill) model names. The HF repo publishes the same
/// text encoder + flow-matching decoder in two graph layouts:
/// - `gpu/` — original graph; fastest on Mac GPU (do NOT run on ANE:
Expand Down Expand Up @@ -1452,6 +1488,8 @@ public enum ModelNames {
case .luxtts:
// Variants: "gpu" (macOS) / "ane" (iOS); nil → platform default.
return ModelNames.LuxTts.requiredFiles(variant: variant)
case .inflectMicro, .inflectNano:
return ModelNames.Inflect.requiredModels
}
}
}
88 changes: 88 additions & 0 deletions Sources/FluidAudio/TTS/Inflect/Assets/InflectModelStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
@preconcurrency import CoreML
import Foundation

/// Actor store for one Inflect variant's CoreML bundles: the fixed-shape
/// `encoder` (loaded eagerly) and the eight `synthesizer_f<N>` frame buckets
/// (loaded lazily and cached the first time a bucket is needed).
///
/// All buckets are *downloaded* up front by `InflectResourceDownloader`
/// (they total ~8–19 MB), so bucket loads never hit the network.
public actor InflectModelStore {

private let logger = AppLogger(category: "InflectModelStore")

private let variant: InflectVariant
private let directory: URL?
private let computeUnits: MLComputeUnits

private var encoderModel: MLModel?
private var bucketModels: [Int: MLModel] = [:]
private var repoDirectory: URL?

public init(
variant: InflectVariant,
directory: URL? = nil,
computeUnits: MLComputeUnits = .cpuAndGPU
) {
self.variant = variant
self.directory = directory
self.computeUnits = computeUnits
}

/// Download (if missing) and load the encoder. Buckets stay lazy.
public func loadIfNeeded() async throws {
if encoderModel != nil { return }

let repoDir = try await InflectResourceDownloader.ensureModels(
variant: variant, directory: directory)
self.repoDirectory = repoDir

encoderModel = try loadModel(
repoDir: repoDir, fileName: ModelNames.Inflect.encoderFile)
logger.info("Inflect \(variant.rawValue) encoder loaded from \(repoDir.path)")
}

public func encoder() throws -> MLModel {
guard let encoderModel else { throw InflectError.notInitialized }
return encoderModel
}

/// Return the synthesizer for the smallest bucket ≥ `frames`, loading and
/// caching it on first use.
public func synthesizer(forFrames frames: Int) async throws -> (model: MLModel, bucket: Int) {
guard let bucket = InflectConstants.frameBuckets.first(where: { frames <= $0 }) else {
throw InflectError.durationOverflow(
frames: frames, maxFrames: InflectConstants.maxFrames)
}
if let cached = bucketModels[bucket] {
return (cached, bucket)
}
guard let repoDir = repoDirectory else { throw InflectError.notInitialized }
let model = try loadModel(
repoDir: repoDir, fileName: ModelNames.Inflect.synthesizerFile(frames: bucket))
bucketModels[bucket] = model
logger.info("Inflect \(variant.rawValue) synthesizer bucket f\(bucket) loaded")
return (model, bucket)
}

public func unload() {
encoderModel = nil
bucketModels.removeAll(keepingCapacity: false)
}

// MARK: - Helpers

private func loadModel(repoDir: URL, fileName: String) throws -> MLModel {
let url = repoDir.appendingPathComponent(fileName)
guard FileManager.default.fileExists(atPath: url.path) else {
throw InflectError.modelFileNotFound(fileName)
}
let config = MLModelConfiguration()
config.computeUnits = computeUnits
do {
return try MLModel(contentsOf: url, configuration: config)
} catch {
throw InflectError.corruptedModel(fileName, underlying: "\(error)")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Foundation

/// Downloads Inflect v2 CoreML bundles from
/// `FluidInference/inflect-v2-coreml`. Each variant lives under its own
/// subdirectory (`micro/`, `nano/`) holding `encoder.mlmodelc` plus the eight
/// `synthesizer_f<N>.mlmodelc` frame buckets.
public enum InflectResourceDownloader {

private static let logger = AppLogger(category: "InflectResourceDownloader")

/// Ensure the variant's encoder + all synthesizer buckets are cached
/// locally. Returns the directory that holds the `.mlmodelc` bundles.
@discardableResult
public static func ensureModels(
variant: InflectVariant,
directory: URL? = nil,
progressHandler: ProgressHandler? = nil
) async throws -> URL {
let repo = variant.repo
let modelsRoot = try directory ?? defaultCacheRoot()
let repoDir = modelsRoot.appendingPathComponent(repo.folderName)

let allPresent = ModelNames.Inflect.requiredModels.allSatisfy { entry in
FileManager.default.fileExists(atPath: repoDir.appendingPathComponent(entry).path)
}

if allPresent {
logger.info("Inflect \(variant.rawValue) models found in cache at \(repoDir.path)")
return repoDir
}

logger.info("Downloading Inflect \(variant.rawValue) CoreML models from HuggingFace…")
do {
try await ModelHub.download(repo, to: modelsRoot, progressHandler: progressHandler)
} catch {
throw InflectError.downloadFailed("\(error)")
}
return repoDir
}

private static func defaultCacheRoot() throws -> URL {
let root = try TtsCacheDirectory.ensure().appendingPathComponent("Models")
if !FileManager.default.fileExists(atPath: root.path) {
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
return root
}
}

extension InflectVariant {
/// HuggingFace repo case for this variant's subdirectory.
var repo: Repo {
switch self {
case .micro: return .inflectMicro
case .nano: return .inflectNano
}
}
}
46 changes: 46 additions & 0 deletions Sources/FluidAudio/TTS/Inflect/InflectConstants.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation

/// Fixed pipeline parameters for the Inflect v2 CoreML backend. Values mirror
/// the upstream `config.json` and the mobius conversion (`inflect-v2/coreml`).
public enum InflectConstants {

/// Output sample rate (24 kHz mono, matches `data.sampling_rate`).
public static let sampleRate = 24_000

/// HiFiGAN total upsampling factor (8·8·2·2) — samples per mel frame.
public static let hopLength = 256

/// Fixed token axis of the encoder bundle (`tokens`/`x_mask` shape `[1, 512]`).
public static let encoderTokens = 512

/// Latent channel count fed to the synthesizer (`inter_channels`).
/// Micro = 192, Nano = 128.
public static func interChannels(for variant: InflectVariant) -> Int {
switch variant {
case .micro: return 192
case .nano: return 128
}
}

/// Synthesizer frame buckets (each `synthesizer_f<N>.mlmodelc`). The
/// smallest bucket ≥ the predicted frame length is used; audio is trimmed
/// to the exact length afterwards.
public static let frameBuckets = [256, 384, 512, 640, 768, 896, 1024, 2048]

/// Largest bucket — a chunk whose predicted duration exceeds this throws.
public static var maxFrames: Int { frameBuckets.last! }

/// Max phoneme characters per synthesis chunk. Interspersed with blanks
/// this stays under the 512-token encoder axis (`2·255 + 1 = 511`); the
/// text path splits longer input at punctuation/whitespace.
public static let maxPhonemeChunkChars = 240

/// Prior sampling temperature (`noise_scale`, VITS `z_p` stddev multiplier).
public static let defaultNoiseScale: Float = 0.667

/// Speech-rate multiplier. Durations scale by `1 / speed`.
public static let defaultSpeed: Float = 1.0

/// Edge fade applied to each chunk (ms), matching upstream `edge_fade`.
public static let edgeFadeMs: Double = 5.0
}
37 changes: 37 additions & 0 deletions Sources/FluidAudio/TTS/Inflect/InflectError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation

/// Errors thrown by the Inflect v2 TTS backend.
public enum InflectError: Error, LocalizedError {
case notInitialized
case downloadFailed(String)
case modelFileNotFound(String)
case corruptedModel(String, underlying: String)
case phonemizationFailed(String)
case inputProcessingFailed(String)
/// A chunk's predicted duration exceeded the largest synthesizer bucket.
case durationOverflow(frames: Int, maxFrames: Int)
case predictionFailed(String)

public var errorDescription: String? {
switch self {
case .notInitialized:
return "Inflect backend is not initialized; call initialize() first."
case .downloadFailed(let detail):
return "Inflect model download failed: \(detail)"
case .modelFileNotFound(let name):
return "Inflect model file not found: \(name)"
case .corruptedModel(let name, let underlying):
return "Inflect model \(name) failed to load: \(underlying)"
case .phonemizationFailed(let detail):
return "Inflect phonemization failed: \(detail)"
case .inputProcessingFailed(let detail):
return "Inflect input processing failed: \(detail)"
case .durationOverflow(let frames, let maxFrames):
return
"Predicted duration \(frames) frames exceeds the largest bucket "
+ "(\(maxFrames)); shorten the input chunk."
case .predictionFailed(let detail):
return "Inflect CoreML prediction failed: \(detail)"
}
}
}
Loading
Loading