feat(package): make NemoTextProcessing an opt-out trait for ASR-only consumers (#880, #888) - #892
Conversation
…consumers (#880, #888) Since 0.15.5 every FluidAudio consumer links the NemoTextProcessing xcframework, a ~18 MB-per-slice prebuilt Rust staticlib that only the TTS frontends and the ITN API call. #888 measured +18 MB on a universal macOS binary for an ASR/diarization-only app; #880 cannot link at all because a second Rust runtime duplicates the std symbols. Expose the dependency as a SwiftPM package trait instead of splitting the module: - Package@swift-6.1.swift: same manifest plus a default-on `NemoTextProcessing` trait; the binary target becomes a conditional dependency of FluidAudio. Consumers opt out with `traits: []`. Package.swift stays at tools 6.0 for older toolchains (CI's Xcode 16 iOS job included) and always links the engine. - TextNormalizer / NemoTextNormalizer keep their public surface on every build. The import and the FFI call bodies are guarded with `canImport(CNemoTextProcessing)`, which is true for both manifests whenever the engine is a dependency. Without it, `isNativeAvailable`, `isTnAvailable`, and the new `NemoTextNormalizer.isAvailable` report false, every call returns its input, `version` is nil, and rule mutations log a warning — not the silent dlopen no-op #867 removed. - KokoroAneManager (Mandarin): without the engine a numeric-only input has no Hanzi and would fall into the bopomofo passthrough; verbalize with MandarinNumberNormalizer first so it still reaches G2P. - Tests: engine-backed classes skip when the trait is off; new *UnavailableTests pin the passthrough contract and run only then. - CI: new macos-15 job builds fluidaudiocli with --disable-default-traits, asserts zero text_processing_rs/nemo_ symbols in the binary, and runs the normalizer tests trait-off. - Docs: PostProcessing.md "Opting out of the engine" + README pointer. Verified locally (Swift 6.2.3): default build links 1944 engine symbols into fluidaudiocli; --disable-default-traits build links 0 and compiles the library and CLI clean. swift-format lint clean. XCTest is unavailable locally; the test target is exercised by CI.
Supertonic3 Smoke Test ✅
Runtime: 0m29s Note: CI VMs lack a physical Neural Engine; the ANE-bucketed VectorEstimator falls back to CPU here. This validates download + variant resolution + synthesis, not ANE residency/perf. |
VAD Benchmark ResultsPerformance Comparison
Dataset Details
✅: Average F1-Score above 70% |
Speaker Diarization Benchmark ResultsSpeaker Diarization PerformanceEvaluating "who spoke when" detection accuracy
Diarization Pipeline Timing BreakdownTime spent in each stage of speaker diarization
Speaker Diarization Research ComparisonResearch baselines typically achieve 18-30% DER on standard datasets
Note: RTFx shown above is from GitHub Actions runner. On Apple Silicon with ANE:
🎯 Speaker Diarization Test • AMI Corpus ES2004a • 1049.0s meeting audio • 61.3s diarization time • Test runtime: 2m 35s • 09/08/2026, 08:07 PM EST |
Sortformer High-Latency Benchmark ResultsES2004a Performance (30.4s latency config)
Sortformer High-Latency • ES2004a • Runtime: 2m 48s • 2026-09-08T23:57:04.794Z |
Parakeet EOU Benchmark Results ✅Status: Benchmark passed Performance Metrics
Streaming Metrics
Test runtime: 0m49s • 09/08/2026, 07:54 PM EST RTFx = Real-Time Factor (higher is better) • Processing includes: Model inference, audio preprocessing, state management, and file I/O |
PocketTTS Smoke Test ✅
Runtime: 0m7s Note: PocketTTS uses CoreML MLState (macOS 15) KV cache + Mimi streaming state. CI VM lacks physical GPU — audio quality and performance may differ from Apple Silicon. |
Offline VBx Pipeline ResultsSpeaker Diarization Performance (VBx Batch Mode)Optimal clustering with Hungarian algorithm for maximum accuracy
Offline VBx Pipeline Timing BreakdownTime spent in each stage of batch diarization
Speaker Diarization Research ComparisonOffline VBx achieves competitive accuracy with batch processing
Pipeline Details:
🎯 Offline VBx Test • AMI Corpus ES2004a • 1049.0s meeting audio • 99.2s processing • Test runtime: 1m 48s • 09/08/2026, 08:01 PM EST |
ASR Benchmark Results ✅Status: All benchmarks passed Parakeet v3 (multilingual)
Parakeet v2 (English-optimized)
Streaming (v3)
Streaming (v2)
Streaming tests use 5 files with 0.5s chunks to simulate real-time audio streaming 25 files per dataset • Test runtime: 20m4s • 09/08/2026, 08:15 PM EST RTFx = Real-Time Factor (higher is better) • Calculated as: Total audio duration ÷ Total processing time Expected RTFx Performance on Physical M1 Hardware:• M1 Mac: ~28x (clean), ~25x (other) Testing methodology follows HuggingFace Open ASR Leaderboard |
… trait-off CI job on Xcode 26 SwiftPM 6.1 (Xcode 16.4, the macos-15 image default) accepts --disable-default-traits but still links the trait-conditioned binary target: the first CI run of the new job linked all 1944 engine symbols. Swift 6.2.3 links 0. Rename the manifest to Package@swift-6.2.swift so 6.1 toolchains fall back to the always-link Package.swift, document the 6.2 / Xcode 26 requirement, and pin Xcode_26.3 in the CI job. Also: state the measured per-slice cost (~8 MB linked+stripped, not the ~18 MB two-release app delta) in the docs and trait comment, and ignore .build-*/ scratch paths.
61be0c8 to
97c6e82
Compare
|
Ran the before/after I offered in #888. Universal release build of our macOS app, Xcode 26.3 / Swift 6.2.4, same tree and same app version for both, PR head
That's 16.85 MB off the universal executable, 8.36 MB off arm64 and 8.48 MB off x86_64, or 12.7% of the binary. The Nothing regressed on the ASR side. Two things worth knowing, neither a blocker. Xcode projects can't set traits. We consume FluidAudio as an // swift-tools-version: 6.2
let package = Package(
name: "FluidAudioShim",
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: [])
],
targets: [
.target(name: "FluidAudioShim", dependencies: [.product(name: "FluidAudio", package: "FluidAudio")])
]
)with The xcframework still downloads. With a cold One caveat on our end: this is a build and symbol check, not a live session run. The link succeeded and our ASR path never touched the engine, so I'd be surprised by a runtime difference, but I haven't proven one way or the other yet. |
…load caveat Xcode 26.3 cannot set package traits (no UI, no pbxproj key); a one-target local package that declares the dependency with `traits: []` and `@_exported import FluidAudio` works, verified by @JulianPscheid on a universal macOS app (-16.85 MB, 12.7%, 0 engine symbols). Also note that the unconditional binary target is still downloaded on resolve.
Closes #888, closes #880.
Problem
Since 0.15.5 every consumer links
NemoTextProcessing, a ~18 MB-per-slice prebuilt Rust staticlib that only the TTS frontends and the ITN API call. #888 measured +18 MB on a universal macOS binary for an ASR/diarization-only app. #880 can't link at all: a second Rust runtime duplicates_rust_eh_personalityand 143 std symbols.Approach: a package trait, not a module split
Splitting TTS/ITN into a separate target would force every shared internal (
ModelHub,AppLogger, download/audio utils) public and change every TTS consumer's imports. A SwiftPM trait keeps one module and one API:Package@swift-6.2.swift— same manifest plus a default-onNemoTextProcessingtrait; the binary target becomes a conditional dependency. Gated at 6.2, not 6.1: SwiftPM 6.1 (Xcode 16.4) accepts--disable-default-traitsbut still links the trait-conditioned binary target (first CI run: 1944 symbols); Swift 6.2 links 0. Consumers opt out with:Package.swiftstays at tools 6.0 (CI's Xcode 16 iOS job, older consumers) and always links the engine.TextNormalizer/NemoTextNormalizerstay public on every build. Only the import and the FFI call bodies are guarded withcanImport(CNemoTextProcessing), which is true under both manifests whenever the engine is a dependency. Trait off:isNativeAvailable,isTnAvailable, and the newNemoTextNormalizer.isAvailablereportfalse, every call passes text through,versionisnil, rule mutations log a warning. This is the explicit-availability contract, not the silent dlopen no-op fix(itn): link the bundled NeMo engine directly instead of dlopen(nil) discovery #867 removed.MandarinNumberNormalizerwhen the engine is absent, so$5.50still reaches G2P instead of the bopomofo passthrough.TextNormalizerUnavailableTests/NemoTextNormalizerUnavailableTestspin the passthrough contract and run only then.fluidaudiocliwith--disable-default-traits, asserts zerotext_processing_rs/nemo_symbols in the binary, and runs the normalizer tests trait-off.Verification (local, Swift 6.2.3)
Universal release build of
fluidaudiocli(arm64 + x86_64 slices built per-triple,lipo -create),strip -xapplied:traits: []Delta: -16.3 MB universal (-8.2 MB per slice), the same order as the +18 MB @JulianPscheid measured across two releases on a universal app in #888. Library + CLI compile clean both ways;
swift format lintclean. XCTest is unavailable locally, so the test target is exercised by CI.Notes
canImportguards are what that PR's review asked for, so it can reduce to the.when(platforms:)condition on top of this.traits: []would confirm the size delta on a real target.🤖 Generated with Claude Code
https://claude.ai/code/session_018UHoFANi4DcvU6TzyTPnHH