text-processing-rs provides both Inverse Text Normalization (ITN) and Text Normalization (TN) across 7 languages (EN, DE, ES, FR, HI, JA, ZH). 100% NeMo test compatibility (3,011 tests). Rust port of NVIDIA NeMo Text Processing with Swift wrapper.
ITN converts spoken-form ASR output to written form — useful for post-processing ASR transcriptions:
| Input (spoken) | Output (written) |
|---|---|
| "two hundred" | "200" |
| "five dollars and fifty cents" | "$5.50" |
| "january fifth twenty twenty five" | "January 5, 2025" |
| "two thirty pm" | "2:30 p.m." |
| "test at gmail dot com" | "test@gmail.com" |
TN converts written-form text to spoken form — useful for TTS preprocessing:
| Input (written) | Output (spoken) |
|---|---|
| "123" | "one hundred twenty three" |
| "$5.50" | "five dollars and fifty cents" |
| "January 5, 2025" | "january fifth twenty twenty five" |
| "2:30 PM" | "two thirty p m" |
| "1st" | "first" |
FluidAudio supports text-processing-rs through the TextNormalizer class. The native engine ships with the package as the NemoTextProcessing binary target and is linked directly — no setup required, it works out of the box for every SwiftPM consumer. Apps that don't use TTS or ITN can opt out of the engine (about 8 MB per architecture slice) with a package trait; see Opting out.
import FluidAudio
let normalizer = TextNormalizer.shared
print("ITN version: \(normalizer.version ?? "unknown")")
// Normalize spoken-form text
let result = normalizer.normalize("two hundred dollars")
// Returns "$200"// Convert written text to spoken form for TTS
let spoken = normalizer.tnNormalize("$5.50")
// Returns "five dollars and fifty cents"
let spoken = normalizer.tnNormalize("January 5, 2025")
// Returns "january fifth twenty twenty five"// Transcribe audio
let asrResult = try await asrManager.transcribe(samples, source: .system)
// Normalize the result (ITN: spoken → written)
let normalizedResult = normalizer.normalize(result: asrResult)
print(normalizedResult.text) // Written formThe engine is bundled: Package.swift declares a NemoTextProcessing binary target (a prebuilt xcframework from text-processing-rs releases) that SwiftPM downloads and links automatically. It is linked at build time, so TextNormalizer.isNativeAvailable is a compile-time constant: true whenever the engine is part of the build, false only when a consumer opts out (below). Releases ≤ 0.15.6 resolved the library at runtime and silently returned input unchanged when it was absent.
The engine is a prebuilt Rust static library (about 8 MB per architecture slice once linked and stripped, measured on fluidaudiocli; the xcframework itself is ~29 MB per iOS slice). ASR/VAD/diarization-only apps, and apps that ship their own Rust runtime (a second copy of the Rust std symbols fails to link), can leave it out with the NemoTextProcessing package trait. Requires Swift 6.2 / Xcode 26 or later; older toolchains read Package.swift and always link the engine. (SwiftPM 6.1 in Xcode 16.3–16.4 accepts traits: [] but still links the binary target, so it gives no size benefit there.)
// Package.swift of the consuming package / app
.package(url: "https://github.com/FluidInference/FluidAudio.git", from: "0.15.7", traits: [])With the trait disabled:
TextNormalizerandNemoTextNormalizerremain in the API.isNativeAvailable,isTnAvailable, andNemoTextNormalizer.isAvailablereportfalse.- Every normalization call returns its input unchanged;
versionisnil; custom rules are ignored (a warning is logged). - TTS frontends run without NeMo normalization: Kokoro English falls back to the built-in
EnglishTextNormalizerrules, and Kokoro Mandarin verbalizes numerals withMandarinNumberNormalizer. Keep the trait enabled for byte-exact NeMo readings.
Xcode projects. Xcode 26.3 has no UI or pbxproj key for package traits (support appears in 26.4). Until then, wrap the dependency in a one-target local package that sets the trait and re-exports the module, and link the app against that instead of FluidAudio directly:
// FluidAudioShim/Package.swift
// swift-tools-version: 6.2
import PackageDescription
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")])
]
)// FluidAudioShim/Sources/FluidAudioShim/Reexport.swift
@_exported import FluidAudioExisting 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.
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.