Skip to content

Build the library for tvOS: make NemoTextProcessing platform-conditional - #890

Open
cakesmachina wants to merge 1 commit into
FluidInference:mainfrom
cakesmachina:tvos-platform-support
Open

Build the library for tvOS: make NemoTextProcessing platform-conditional#890
cakesmachina wants to merge 1 commit into
FluidInference:mainfrom
cakesmachina:tvos-platform-support

Conversation

@cakesmachina

Copy link
Copy Markdown

The prebuilt NemoTextProcessing.xcframework ships ios, ios-simulator and macos slices only, so building the FluidAudio library for tvOS fails with "no library for this platform was found". The framework is imported by exactly two files and used at two TTS call sites; the Parakeet ASR pipeline never touches it.

This PR:

  • makes the binary dependency .when(platforms: [.macOS, .iOS]) in Package.swift;
  • wraps ITN/TextNormalizer.swift and TTS/Shared/NemoTextNormalizer.swift in #if canImport(CNemoTextProcessing);
  • lets EnglishTextNormalizer.normalizeForFrontend fall back to its baseline and the Mandarin Kokoro path pass text through when the FST engine is absent.

Witnessed with Xcode 27 beta: swift build --product FluidAudio --triple arm64-apple-tvos26.0-simulator and --triple arm64-apple-tvos26.0 both complete. macOS and iOS builds are unchanged. platforms: in Package.swift is deliberately left alone; adding .tvOS there is your call.

Use case: bundling parakeet-tdt-0.6b-v3-coreml inside an Apple TV app for offline read-along transcripts.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HZbYTEH3aemaaG2otBWesj

The prebuilt NemoTextProcessing.xcframework ships ios, ios-simulator and
macos slices only, so `swift build --triple arm64-apple-tvos26.0` fails with
"no library for this platform was found". It is imported by exactly two
files (ITN/TextNormalizer, TTS/Shared/NemoTextNormalizer) and used at two
TTS call sites; the ASR pipeline never touches it.

- Package.swift: the dependency is `.when(platforms: [.macOS, .iOS])`.
- The two importers are wrapped in `#if canImport(CNemoTextProcessing)`.
- EnglishTextNormalizer falls back to its own baseline normaliser and the
  Mandarin Kokoro path passes text through where the FST engine is absent.

Witnessed: `swift build --product FluidAudio` completes for both
arm64-apple-tvos26.0-simulator and arm64-apple-tvos26.0 (Xcode 27 beta).
No behaviour change on macOS or iOS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZbYTEH3aemaaG2otBWesj
@Alex-Wengg

Copy link
Copy Markdown
Member

Thanks. Conditional binary dependency is the right call (confirmed v0.3.0 ships only macOS/iOS slices). Two fixes before merge:

1. Don't remove TextNormalizer / NemoTextNormalizer on tvOS. Wrapping the whole files drops two public types, so anything calling TextNormalizer.shared won't compile on tvOS. It also breaks the documented contract (PostProcessing.md): API always present, isNativeAvailable reports linkage, normalize() passes through otherwise. Guard only the import and the FFI call bodies, return input with isNativeAvailable == false. Then the #if blocks in KokoroAneManager and EnglishTextNormalizer can go, since NemoTextNormalizer.normalize already returns text unchanged when the engine declines.

2. Mandarin numeric-only input. MandarinG2P already runs MandarinNumberNormalizer, but looksLikeHanzi gates before it. Without the FST, $5.50 / 99% / 2025-05-03 have no Hanzi, hit the bopomofo passthrough, and digits become tone marks. When the FST is a no-op and there's no Hanzi, run MandarinNumberNormalizer.normalize before the gate.

Also:

Alex-Wengg added a commit that referenced this pull request Sep 9, 2026
…consumers (#880, #888) (#892)

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:
  ```swift
.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 #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

- Relates to #890 (tvOS): the same `canImport` guards are what that PR's
review asked for, so it can reduce to the `.when(platforms:)` condition
on top of this.
- @JulianPscheid offered a before/after universal build in #888; a run
of your app against this branch with `traits: []` would confirm the size
delta on a real target.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants