Skip to content

feat(package): make NemoTextProcessing an opt-out trait for ASR-only consumers (#880, #888) - #892

Merged
Alex-Wengg merged 3 commits into
mainfrom
fix/888-optional-nemo-text-processing
Sep 9, 2026
Merged

feat(package): make NemoTextProcessing an opt-out trait for ASR-only consumers (#880, #888)#892
Alex-Wengg merged 3 commits into
mainfrom
fix/888-optional-nemo-text-processing

Conversation

@Alex-Wengg

@Alex-Wengg Alex-Wengg commented Sep 8, 2026

Copy link
Copy Markdown
Member

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_personality and 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-on NemoTextProcessing trait; the binary target becomes a conditional dependency. Gated at 6.2, not 6.1: SwiftPM 6.1 (Xcode 16.4) accepts --disable-default-traits but still links the trait-conditioned binary target (first CI run: 1944 symbols); Swift 6.2 links 0. Consumers opt out with:
    .package(url: "https://github.com/FluidInference/FluidAudio.git", from: "0.15.7", traits: [])
    Package.swift stays at tools 6.0 (CI's Xcode 16 iOS job, older consumers) and always links the engine.
  • TextNormalizer / NemoTextNormalizer stay public on every build. Only the import and the FFI call bodies are guarded with canImport(CNemoTextProcessing), which is true under both manifests whenever the engine is a dependency. Trait off: isNativeAvailable, isTnAvailable, and the new NemoTextNormalizer.isAvailable report false, every call passes text through, version is nil, 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.
  • Kokoro Mandarin verbalizes numeric-only input with MandarinNumberNormalizer when the engine is absent, so $5.50 still reaches G2P instead of the bopomofo passthrough.
  • Tests: engine-backed classes skip when the trait is off; new TextNormalizerUnavailableTests / NemoTextNormalizerUnavailableTests pin the passthrough contract and run only then.
  • CI: new macos-15 job on Xcode 26.3 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" section, README pointer.

Verification (local, Swift 6.2.3)

Universal release build of fluidaudiocli (arm64 + x86_64 slices built per-triple, lipo -create), strip -x applied:

build arm64 slice x86_64 slice universal engine symbols/slice
default 16.15 MB 16.90 MB 33.05 MB 671 / 680
traits: [] 7.98 MB 8.75 MB 16.76 MB 0 / 0

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 lint clean. XCTest is unavailable locally, so the test target is exercised by CI.

Notes

🤖 Generated with Claude Code

https://claude.ai/code/session_018UHoFANi4DcvU6TzyTPnHH

…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.
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Supertonic3 Smoke Test ✅

Check Result
Build
Model download (incl. VectorEstimatorVariants/ int4 buckets)
Model load
Synthesis pipeline (--ve-variant int4)
Output WAV ✅ (364.7 KB)

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.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

VAD Benchmark Results

Performance Comparison

Dataset Accuracy Precision Recall F1-Score RTFx Files
MUSAN 94.0% 89.3% 100.0% 94.3% 469.9x faster 50
VOiCES 94.0% 89.3% 100.0% 94.3% 512.9x faster 50

Dataset Details

  • MUSAN: Music, Speech, and Noise dataset - standard VAD evaluation
  • VOiCES: Voices Obscured in Complex Environmental Settings - tests robustness in real-world conditions

✅: Average F1-Score above 70%

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Speaker Diarization Benchmark Results

Speaker Diarization Performance

Evaluating "who spoke when" detection accuracy

Metric Value Target Status Description
DER 15.1% <30% Diarization Error Rate (lower is better)
JER 24.9% <25% Jaccard Error Rate
RTFx 17.12x >1.0x Real-Time Factor (higher is faster)

Diarization Pipeline Timing Breakdown

Time spent in each stage of speaker diarization

Stage Time (s) % Description
Model Download 13.210 21.6 Fetching diarization models
Model Compile 5.662 9.2 CoreML compilation
Audio Load 0.108 0.2 Loading audio file
Segmentation 18.377 30.0 Detecting speech regions
Embedding 30.629 50.0 Extracting speaker voices
Clustering 12.252 20.0 Grouping same speakers
Total 61.296 100 Full pipeline

Speaker Diarization Research Comparison

Research baselines typically achieve 18-30% DER on standard datasets

Method DER Notes
FluidAudio 15.1% On-device CoreML
Research baseline 18-30% Standard dataset performance

Note: RTFx shown above is from GitHub Actions runner. On Apple Silicon with ANE:

  • M2 MacBook Air (2022): Runs at 150 RTFx real-time
  • Performance scales with Apple Neural Engine capabilities

🎯 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

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Sortformer High-Latency Benchmark Results

ES2004a Performance (30.4s latency config)

Metric Value Target Status
DER 30.3% <35%
Miss Rate 28.2% - -
False Alarm 0.9% - -
Speaker Error 1.2% - -
RTFx 16.0x >1.0x
Speakers 4/4 - -

Sortformer High-Latency • ES2004a • Runtime: 2m 48s • 2026-09-08T23:57:04.794Z

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Parakeet EOU Benchmark Results ✅

Status: Benchmark passed
Chunk Size: 320ms
Files Tested: 100/100

Performance Metrics

Metric Value Description
WER (Avg) 7.03% Average Word Error Rate
WER (Med) 4.17% Median Word Error Rate
RTFx 11.15x Real-time factor (higher = faster)
Total Audio 470.6s Total audio duration processed
Total Time 43.0s Total processing time

Streaming Metrics

Metric Value Description
Avg Chunk Time 0.043s Average chunk processing time
Max Chunk Time 0.086s Maximum chunk processing time
EOU Detections 0 Total End-of-Utterance detections

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

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

PocketTTS Smoke Test ✅

Check Result
Build
Model download
Model load
Synthesis pipeline
Output WAV ✅ (161.3 KB)

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.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Offline VBx Pipeline Results

Speaker Diarization Performance (VBx Batch Mode)

Optimal clustering with Hungarian algorithm for maximum accuracy

Metric Value Target Status Description
DER 10.4% <20% Diarization Error Rate (lower is better)
RTFx 13.57x >1.0x Real-Time Factor (higher is faster)

Offline VBx Pipeline Timing Breakdown

Time spent in each stage of batch diarization

Stage Time (s) % Description
Model Download 18.479 23.9 Fetching diarization models
Model Compile 7.919 10.2 CoreML compilation
Audio Load 0.032 0.0 Loading audio file
Segmentation 22.011 28.5 VAD + speech detection
Embedding 77.090 99.7 Speaker embedding extraction
Clustering (VBx) 0.094 0.1 Hungarian algorithm + VBx clustering
Total 77.307 100 Full VBx pipeline

Speaker Diarization Research Comparison

Offline VBx achieves competitive accuracy with batch processing

Method DER Mode Description
FluidAudio (Offline) 10.4% VBx Batch On-device CoreML with optimal clustering
FluidAudio (Streaming) 17.7% Chunk-based First-occurrence speaker mapping
Research baseline 18-30% Various Standard dataset performance

Pipeline Details:

  • Mode: Offline VBx with Hungarian algorithm for optimal speaker-to-cluster assignment
  • Segmentation: VAD-based voice activity detection
  • Embeddings: WeSpeaker-compatible speaker embeddings
  • Clustering: PowerSet with VBx refinement
  • Accuracy: Higher than streaming due to optimal post-hoc mapping

🎯 Offline VBx Test • AMI Corpus ES2004a • 1049.0s meeting audio • 99.2s processing • Test runtime: 1m 48s • 09/08/2026, 08:01 PM EST

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

ASR Benchmark Results ✅

Status: All benchmarks passed

Parakeet v3 (multilingual)

Dataset WER Avg WER Med RTFx Status
test-clean 0.57% 0.00% 4.96x
test-other 1.35% 0.00% 3.15x

Parakeet v2 (English-optimized)

Dataset WER Avg WER Med RTFx Status
test-clean 0.80% 0.00% 6.19x
test-other 1.00% 0.00% 3.76x

Streaming (v3)

Metric Value Description
WER 0.00% Word Error Rate in streaming mode
RTFx 0.57x Streaming real-time factor
Avg Chunk Time 1.565s Average time to process each chunk
Max Chunk Time 1.834s Maximum chunk processing time
First Token 1.871s Latency to first transcription token
Total Chunks 31 Number of chunks processed

Streaming (v2)

Metric Value Description
WER 0.00% Word Error Rate in streaming mode
RTFx 0.68x Streaming real-time factor
Avg Chunk Time 1.313s Average time to process each chunk
Max Chunk Time 1.435s Maximum chunk processing time
First Token 1.335s Latency to first transcription token
Total Chunks 31 Number of chunks processed

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
Processing time includes: Model inference on Apple Neural Engine, audio preprocessing, state resets between files, token-to-text conversion, and file I/O
Example: RTFx of 2.0x means 10 seconds of audio processed in 5 seconds (2x faster than real-time)

Expected RTFx Performance on Physical M1 Hardware:

• M1 Mac: ~28x (clean), ~25x (other)
• CI shows ~0.5-3x due to virtualization limitations

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.
@Alex-Wengg
Alex-Wengg force-pushed the fix/888-optional-nemo-text-processing branch from 61be0c8 to 97c6e82 Compare September 8, 2026 21:21
@JulianPscheid

Copy link
Copy Markdown
Contributor

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 97c6e82, only the traits: line differs.

build executable arm64 slice x86_64 slice text_processing_rs (arm64 / x86_64) _nemo_* per slice
default traits 132.41 MB 65,231,664 67,146,432 650 / 658 12
traits: [] 115.56 MB 56,868,880 58,666,960 0 / 0 0

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 .app bundle drops the same 16.8 MB (650.6 to 633.8 MB). Same order as your fluidaudiocli numbers. These are the as-built release binaries with no extra strip -x, so they run a bit larger than yours.

Nothing regressed on the ASR side. AsrManager, StreamingAsrManager and DiarizerManager symbols are all still in both slices, and we don't reference TextNormalizer anywhere.

Two things worth knowing, neither a blocker.

Xcode projects can't set traits. We consume FluidAudio as an XCRemoteSwiftPackageReference in an .xcodeproj, and Xcode 26.3 has nowhere to put traits: []: no UI for it, no pbxproj key. Trait support seems to have landed in 26.4, which I haven't tested. What worked was a one-target local package that declares the dependency and re-exports the module, with the app target linking that instead of FluidAudio directly:

// 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 @_exported import FluidAudio as the target's only source file, so existing import FluidAudio lines keep compiling. Might be worth a couple of lines in the "Opting out" section, since app consumers are more likely to be Xcode projects than packages.

The xcframework still downloads. With a cold --cache-path, traits: [] still fetches and extracts the 49 MB NemoTextProcessing.xcframework.zip. The binary target is declared unconditionally and only the dependency edge is trait-conditioned, so resolution pulls it either way. Ship size is fixed, but CI and clean checkouts still pay for the download. Probably a SwiftPM limitation rather than something this PR can address.

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.
@Alex-Wengg
Alex-Wengg merged commit 6b90a08 into main Sep 9, 2026
13 checks passed
@Alex-Wengg
Alex-Wengg deleted the fix/888-optional-nemo-text-processing branch September 9, 2026 01:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants