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
36 changes: 34 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ jobs:
sudo xcode-select -s /Applications/Xcode_26.3.app
swift --version

# The CLI and tests need the TTS and Diarizer traits (#990); keep those on.
- name: Build with the trait disabled
run: swift build --disable-default-traits --product fluidaudiocli
run: swift build --traits TTS,Diarizer --product fluidaudiocli

- name: Assert no engine symbols linked
run: |
Expand All @@ -66,9 +67,40 @@ jobs:
test "$count" -eq 0

- name: Run tests with the trait disabled
run: swift test --disable-default-traits --filter 'TextNormalizerUnavailableTests|NemoTextNormalizerUnavailableTests|TextNormalizerTests|NemoTextNormalizerTests'
run: swift test --traits TTS,Diarizer --filter 'TextNormalizerUnavailableTests|NemoTextNormalizerUnavailableTests|TextNormalizerTests|NemoTextNormalizerTests'
timeout-minutes: 20

build-library-trait-subsets:
name: Build library with TTS / Diarizer traits disabled (macOS)
runs-on: macos-15
steps:
- name: Checkout code
uses: actions/checkout@v5

# #990: ASR-only consumers resolve with `traits: []` (or `["Diarizer"]`) to drop
# the TTS and diarization subsystems. Only the library target has to build that way.
- name: Select Xcode 26
run: |
sudo xcode-select -s /Applications/Xcode_26.3.app
swift --version

- name: Build with no traits
run: swift build --disable-default-traits --target FluidAudio

- name: Assert no TTS resource bundle
run: test ! -e .build/debug/FluidAudio_LuxTtsG2pResources.bundle

- name: Build with TTS only
run: swift build --traits TTS --target FluidAudio

# Positive control for the assertion above: the same path must appear once TTS is on,
# otherwise a renamed bundle or build-dir layout change would make the check pass vacuously.
- name: Assert TTS resource bundle present
run: test -e .build/debug/FluidAudio_LuxTtsG2pResources.bundle

- name: Build with Diarizer only
run: swift build --traits Diarizer --target FluidAudio

build-macos-x86_64:
name: Build Swift Package (macOS x86_64 cross-compile)
runs-on: macos-15
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ Resources/
!Tests/FluidAudioTests/TTS/LuxTts/Resources/**
Tests/FluidAudioTests/TTS/LuxTts/Resources/*.wav
# LuxTts bundled G2P lexicon (probe_lexicon.py in mobius zipvoice)
!Sources/FluidAudio/TTS/LuxTts/G2p/Resources/
!Sources/FluidAudio/TTS/LuxTts/G2p/Resources/**
!Sources/LuxTtsG2pResources/Resources/
!Sources/LuxTtsG2pResources/Resources/**
scripts/
!Scripts/parakeet_subset_benchmark.sh
!Scripts/diarizer_subset_benchmark.sh
Expand Down
9 changes: 6 additions & 3 deletions Documentation/ASR/PostProcessing.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ The engine is a prebuilt Rust static library (about 8 MB per architecture slice
.package(url: "https://github.com/FluidInference/FluidAudio.git", from: "0.15.7", traits: [])
```

Since #990, `traits: []` also turns off the `TTS` and `Diarizer` traits. To drop only the engine, list the traits you still need, e.g. `traits: ["TTS", "Diarizer"]` (see [Package traits](../../README.md#installation)).

With the trait disabled:

- `TextNormalizer` and `NemoTextNormalizer` remain in the API. `isNativeAvailable`, `isTnAvailable`, and `NemoTextNormalizer.isAvailable` report `false`.
Expand All @@ -99,7 +101,8 @@ let package = Package(
platforms: [.macOS(.v14), .iOS(.v17)],
products: [.library(name: "FluidAudioShim", targets: ["FluidAudioShim"])],
dependencies: [
.package(url: "https://github.com/FluidInference/FluidAudio.git", from: "0.15.7", traits: [])
// `traits: []` would also drop TTS and diarization (#990); list the subsystems you keep.
.package(url: "https://github.com/FluidInference/FluidAudio.git", from: "0.15.7", traits: ["TTS", "Diarizer"])
],
targets: [
.target(name: "FluidAudioShim", dependencies: [.product(name: "FluidAudio", package: "FluidAudio")])
Expand All @@ -112,8 +115,8 @@ let package = Package(
@_exported import FluidAudio
```

Existing `import FluidAudio` lines keep compiling. Measured on a universal macOS app this way (Xcode 26.3): 16.85 MB off the executable, 12.7%, zero engine symbols, ASR/diarization symbols unchanged.
Existing `import FluidAudio` lines keep compiling. Measured on a universal macOS app this way (Xcode 26.3, engine trait off, TTS and Diarizer on): 16.85 MB off the executable, 12.7%, zero engine symbols, ASR/diarization/TTS symbols unchanged. An ASR-only app can use `traits: []` in the shim instead and drop those subsystems too.

**The xcframework still downloads.** The binary target is declared unconditionally and only the dependency edge is trait-conditioned, so a clean resolve still fetches the 49 MB `NemoTextProcessing.xcframework.zip` even with the trait off. Ship size is unaffected; CI and cold checkouts pay the download. That is a SwiftPM limitation, not something the package can change.

To build the package itself without the engine: `swift build --disable-default-traits`.
To build the package itself without the engine: `swift build --traits TTS,Diarizer` (the CLI and tests need both).
12 changes: 9 additions & 3 deletions FluidAudio.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ Pod::Spec.new do |spec|

spec.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
# SwiftPM trait flags (#990). CocoaPods has no trait support, so the pod always
# builds every subsystem; the LuxTTS G2P resources ship as a resource bundle below.
'SWIFT_ACTIVE_COMPILATION_CONDITIONS' => '$(inherited) TTS Diarizer',
'ARCHS[sdk=macosx*]' => 'arm64',
'EXCLUDED_ARCHS[sdk=macosx*]' => 'x86_64',
'ARCHS[sdk=iphonesimulator*]' => 'arm64',
Expand Down Expand Up @@ -57,11 +60,14 @@ Pod::Spec.new do |spec|
spec.subspec "Core" do |core|
core.dependency "#{spec.name}/FastClusterWrapper"
core.dependency "#{spec.name}/MachTaskSelfWrapper"
core.source_files = "Sources/FluidAudio/**/*.swift"
core.source_files = "Sources/FluidAudio/**/*.swift", "Sources/LuxTtsG2pResources/**/*.swift"
# SwiftPM serves these via a separate `LuxTtsG2pResources` target (trait-pruned);
# `LuxTtsG2pResources.bundle` falls back to this bundle when `Bundle.module` is absent.
core.resource_bundles = {
"LuxTtsG2pResources" => ["Sources/LuxTtsG2pResources/Resources/*"]
}

# iOS Configuration
# TTS sources are moved under `Sources/FluidAudioTTS` and are not part of the Core subspec.
# iOS builds include ASR, Diarization, and VAD.
core.ios.frameworks = "CoreML", "AVFoundation", "Accelerate", "UIKit"

# macOS Configuration
Expand Down
12 changes: 11 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,22 @@ let package = Package(
"MachTaskSelfWrapper",
// The prebuilt xcframework has no visionOS slice.
.target(name: "NemoTextProcessing", condition: .when(platforms: [.macOS, .iOS, .macCatalyst])),
"LuxTtsG2pResources",
],
path: "Sources/FluidAudio",
exclude: ["ASR/Parakeet/Unified/benchmark.md"],
swiftSettings: [
// Package@swift-6.2.swift exposes these as opt-out traits (#990).
.define("TTS"),
.define("Diarizer"),
]
),
.target(
name: "LuxTtsG2pResources",
path: "Sources/LuxTtsG2pResources",
resources: [
// Keep .process: .copy of a Resources-named directory breaks Apple code signing on iOS.
.process("TTS/LuxTts/G2p/Resources")
.process("Resources")
]
),
// Byte-exact NeMo text normalization (FST engine, all 7 languages).
Expand Down
29 changes: 23 additions & 6 deletions Package@swift-6.2.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
import PackageDescription
import Foundation

// Tools 6.2+ manifest: identical to Package.swift plus the `NemoTextProcessing`
// trait. Keep the two in sync; Package.swift serves toolchains < 6.2, which
// always link the engine. (SwiftPM 6.1 accepts the trait syntax but still
// Tools 6.2+ manifest: identical to Package.swift plus the `NemoTextProcessing`,
// `TTS` and `Diarizer` traits. Keep the two in sync; Package.swift serves
// toolchains < 6.2, which always link the engine and compile every subsystem. (SwiftPM 6.1 accepts the trait syntax but still
// links a trait-conditioned binary target — verified on Xcode 16.4 — so the
// opt-out is gated at 6.2.)

Expand Down Expand Up @@ -36,7 +36,18 @@ let package = Package(
name: "NemoTextProcessing",
description: "Link the bundled NeMo text-normalization engine (TTS frontends, ITN)."
),
.default(enabledTraits: ["NemoTextProcessing"]),
// Opt out of whole subsystems for apps that never reach them (#990):
// .package(url: ..., traits: []) // ASR/VAD only
// .package(url: ..., traits: ["Diarizer"]) // ASR/VAD + diarization
.trait(
name: "TTS",
description: "Text-to-speech backends (Kokoro, PocketTTS, LuxTTS, ...) and their bundled G2P resources."
),
.trait(
name: "Diarizer",
description: "Speaker diarization (pyannote, Sortformer, LS-EEND, Nemotron 3)."
),
.default(enabledTraits: ["NemoTextProcessing", "TTS", "Diarizer"]),
],
dependencies: [],
targets: [
Expand All @@ -50,12 +61,18 @@ let package = Package(
name: "NemoTextProcessing",
condition: .when(platforms: [.macOS, .iOS, .macCatalyst], traits: ["NemoTextProcessing"])
),
.target(name: "LuxTtsG2pResources", condition: .when(traits: ["TTS"])),
],
path: "Sources/FluidAudio",
exclude: ["ASR/Parakeet/Unified/benchmark.md"],
exclude: ["ASR/Parakeet/Unified/benchmark.md"]
),
// Separate target so the ~1 MB G2P bundle drops out with the `TTS` trait.
.target(
name: "LuxTtsG2pResources",
path: "Sources/LuxTtsG2pResources",
resources: [
// Keep .process: .copy of a Resources-named directory breaks Apple code signing on iOS.
.process("TTS/LuxTts/G2p/Resources")
.process("Resources")
]
),
// Byte-exact NeMo text normalization (FST engine, all 7 languages).
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,21 @@ dependencies: [
.product(name: "FluidAudio", package: "FluidAudio")
```

**Package traits (Swift 6.2+ / Xcode 26+):** all three are on by default. Drop the subsystems an app never reaches to shrink the build:

| Trait | Covers |
|-------|--------|
| `TTS` | All TTS backends plus the ~1 MB LuxTTS G2P resource bundle |
| `Diarizer` | Speaker diarization (pyannote, Sortformer, LS-EEND, Nemotron 3) |
| `NemoTextProcessing` | NeMo text normalization engine, see [PostProcessing.md](Documentation/ASR/PostProcessing.md#opting-out-of-the-engine) |

```swift
.package(url: "https://github.com/FluidInference/FluidAudio.git", from: "0.17.7", traits: []) // ASR + VAD only
.package(url: "https://github.com/FluidInference/FluidAudio.git", from: "0.17.7", traits: ["Diarizer"]) // + diarization
```

`traits: []` turns off all three. Before #990 it only dropped the NeMo engine, so apps that use diarization and pass `traits: []` should switch to `traits: ["Diarizer"]`. Older toolchains always build everything.

**CocoaPods:** We recommend using [cocoapods-spm](https://github.com/trinhngocthuyen/cocoapods-spm) for better SPM integration, but if needed, you can also use our podspec: `pod 'FluidAudio', '~> 0.12.4'`

### Other Frameworks
Expand Down
2 changes: 2 additions & 0 deletions Sources/FluidAudio/Diarizer/Clustering/SpeakerManager.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if Diarizer
import Accelerate
import Foundation
import OSLog
Expand Down Expand Up @@ -634,3 +635,4 @@ public struct SpeakerManager: Sendable {
}
}
}
#endif
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if Diarizer
import Accelerate
import Foundation
import OSLog
Expand Down Expand Up @@ -592,3 +593,4 @@ extension SpeakerManager {
return (speakers.count, totalDuration, min(1.0, averageConfidence), speakersWithHistory)
}
}
#endif
2 changes: 2 additions & 0 deletions Sources/FluidAudio/Diarizer/Clustering/SpeakerTypes.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if Diarizer
import Accelerate
import Foundation

Expand Down Expand Up @@ -280,3 +281,4 @@ public enum SpeakerInitializationMode: Sendable {
/// Skip speakers whose IDs match existing ones
case skip
}
#endif
35 changes: 35 additions & 0 deletions Sources/FluidAudio/Diarizer/Core/DiarizerError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#if Diarizer
import Foundation

public enum DiarizerError: Error, LocalizedError {
case notInitialized
case modelDownloadFailed
case modelCompilationFailed
case embeddingExtractionFailed
case invalidAudioData
case processingFailed(String)
case memoryAllocationFailed
case invalidArrayBounds

public var errorDescription: String? {
switch self {
case .notInitialized:
return "Diarization system not initialized. Call initialize() first."
case .modelDownloadFailed:
return "Failed to download required models."
case .modelCompilationFailed:
return "Failed to compile CoreML models."
case .embeddingExtractionFailed:
return "Failed to extract speaker embedding from audio."
case .invalidAudioData:
return "Invalid audio data provided."
case .processingFailed(let message):
return "Processing failed: \(message)"
case .memoryAllocationFailed:
return "Failed to allocate ANE-aligned memory."
case .invalidArrayBounds:
return "Array bounds exceeded for zero-copy view."
}
}
}
#endif
2 changes: 2 additions & 0 deletions Sources/FluidAudio/Diarizer/Core/DiarizerManager.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if Diarizer
import Accelerate
import CoreML
import Foundation
Expand Down Expand Up @@ -531,3 +532,4 @@ public final class DiarizerManager {
}

}
#endif
2 changes: 2 additions & 0 deletions Sources/FluidAudio/Diarizer/Core/DiarizerModels.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if Diarizer
@preconcurrency import CoreML
import Foundation
import OSLog
Expand Down Expand Up @@ -139,3 +140,4 @@ extension DiarizerModels {
compilationDuration: loadDuration)
}
}
#endif
34 changes: 2 additions & 32 deletions Sources/FluidAudio/Diarizer/Core/DiarizerTypes.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if Diarizer
import Foundation

// MARK: - Diarization Pipeline Types
Expand Down Expand Up @@ -233,35 +234,4 @@ public struct AudioValidationResult: Sendable {
self.issues = issues
}
}

public enum DiarizerError: Error, LocalizedError {
case notInitialized
case modelDownloadFailed
case modelCompilationFailed
case embeddingExtractionFailed
case invalidAudioData
case processingFailed(String)
case memoryAllocationFailed
case invalidArrayBounds

public var errorDescription: String? {
switch self {
case .notInitialized:
return "Diarization system not initialized. Call initialize() first."
case .modelDownloadFailed:
return "Failed to download required models."
case .modelCompilationFailed:
return "Failed to compile CoreML models."
case .embeddingExtractionFailed:
return "Failed to extract speaker embedding from audio."
case .invalidAudioData:
return "Invalid audio data provided."
case .processingFailed(let message):
return "Processing failed: \(message)"
case .memoryAllocationFailed:
return "Failed to allocate ANE-aligned memory."
case .invalidArrayBounds:
return "Array bounds exceeded for zero-copy view."
}
}
}
#endif
2 changes: 2 additions & 0 deletions Sources/FluidAudio/Diarizer/DiarizationDER.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if Diarizer
//
// DiarizationDER.swift
// LS-EEND-Test
Expand Down Expand Up @@ -229,3 +230,4 @@ public enum DiarizationDER {
return mask
}
}
#endif
2 changes: 2 additions & 0 deletions Sources/FluidAudio/Diarizer/DiarizerProtocol.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if Diarizer
import Foundation

// MARK: - Diarizer Protocol
Expand Down Expand Up @@ -115,3 +116,4 @@ public protocol Diarizer: AnyObject {
@discardableResult
func finalizeSession() throws -> DiarizerTimelineUpdate?
}
#endif
2 changes: 2 additions & 0 deletions Sources/FluidAudio/Diarizer/DiarizerTimeline.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if Diarizer
import Foundation

// MARK: - Timeline Configuration
Expand Down Expand Up @@ -1389,3 +1390,4 @@ public enum DiarizerTimelineError: Error, LocalizedError {
}
}
}
#endif
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if Diarizer
import Accelerate
import CoreML
import OSLog
Expand Down Expand Up @@ -239,3 +240,4 @@ public final class EmbeddingExtractor {
return embedding
}
}
#endif
2 changes: 2 additions & 0 deletions Sources/FluidAudio/Diarizer/HungarianAssignment.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if Diarizer
import Foundation

// MARK: - Hungarian (O(n^3) min-cost assignment on a square cost matrix)
Expand Down Expand Up @@ -96,3 +97,4 @@ enum HungarianAssignment {
}
}
}
#endif
Loading
Loading