diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 22585dc9e..782354762 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,6 +28,41 @@ jobs: run: swift test --parallel --num-workers $(sysctl -n hw.ncpu) timeout-minutes: 20 + build-without-nemo-text-processing: + name: Build without NemoTextProcessing trait (macOS) + runs-on: macos-15 + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Check versions + run: | + swift --version + xcodebuild -version + + # #880/#888: consumers on Swift 6.2+ can resolve with `traits: []` to keep the + # ~18 MB Rust engine out of ASR-only apps. Prove the package still builds and + # that no engine symbol reaches the linked product. SwiftPM 6.1 (Xcode 16.4, + # the image default) accepts the flag but still links the binary target, so + # this job must run on an Xcode 26 toolchain. + - name: Select Xcode 26 + run: | + sudo xcode-select -s /Applications/Xcode_26.3.app + swift --version + + - name: Build with the trait disabled + run: swift build --disable-default-traits --product fluidaudiocli + + - name: Assert no engine symbols linked + run: | + count=$(nm .build/debug/fluidaudiocli | grep -c 'text_processing_rs\|_nemo_' || true) + echo "engine symbols in fluidaudiocli: $count" + test "$count" -eq 0 + + - name: Run tests with the trait disabled + run: swift test --disable-default-traits --filter 'TextNormalizerUnavailableTests|NemoTextNormalizerUnavailableTests|TextNormalizerTests|NemoTextNormalizerTests' + timeout-minutes: 20 + build-macos-x86_64: name: Build Swift Package (macOS x86_64 cross-compile) runs-on: macos-15 diff --git a/.gitignore b/.gitignore index b0d3ac930..987ba0408 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ fluidaudio_cli/* # scripts + trials live in mobius repo, see models/tts/supertonic_3/). .venv-supertonic3/ build/supertonic-3-coreml/ +.build-*/ diff --git a/Documentation/ASR/PostProcessing.md b/Documentation/ASR/PostProcessing.md index cb1f684f9..f4d9edcca 100644 --- a/Documentation/ASR/PostProcessing.md +++ b/Documentation/ASR/PostProcessing.md @@ -30,7 +30,7 @@ TN converts written-form text to spoken form — useful for TTS preprocessing: ## Using with FluidAudio -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. +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](#opting-out-of-the-engine). ### ITN (Spoken to Written) @@ -70,4 +70,50 @@ print(normalizedResult.text) // Written form ### Native Library -The engine is bundled: `Package.swift` declares a `NemoTextProcessing` binary target (a prebuilt xcframework from [text-processing-rs](https://github.com/FluidInference/text-processing-rs) releases) that SwiftPM downloads and links automatically. `TextNormalizer.isNativeAvailable` always returns `true`; it is kept only for source compatibility with releases ≤ 0.15.6, which resolved the library at runtime and silently returned input unchanged when it was absent. +The engine is bundled: `Package.swift` declares a `NemoTextProcessing` binary target (a prebuilt xcframework from [text-processing-rs](https://github.com/FluidInference/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. + +### Opting out of the engine + +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.) + +```swift +// Package.swift of the consuming package / app +.package(url: "https://github.com/FluidInference/FluidAudio.git", from: "0.15.7", traits: []) +``` + +With the trait disabled: + +- `TextNormalizer` and `NemoTextNormalizer` remain in the API. `isNativeAvailable`, `isTnAvailable`, and `NemoTextNormalizer.isAvailable` report `false`. +- Every normalization call returns its input unchanged; `version` is `nil`; custom rules are ignored (a warning is logged). +- TTS frontends run without NeMo normalization: Kokoro English falls back to the built-in `EnglishTextNormalizer` rules, and Kokoro Mandarin verbalizes numerals with `MandarinNumberNormalizer`. 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: + +```swift +// 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")]) + ] +) +``` + +```swift +// FluidAudioShim/Sources/FluidAudioShim/Reexport.swift +@_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. + +**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`. diff --git a/Package.swift b/Package.swift index 8545870d2..e90bea55d 100644 --- a/Package.swift +++ b/Package.swift @@ -36,6 +36,8 @@ let package = Package( ), // Byte-exact NeMo text normalization (FST engine, all 7 languages). // Prebuilt xcframework from FluidInference/text-processing-rs v0.3.0. + // Always linked on tools < 6.2; Package@swift-6.2.swift exposes it as + // the opt-out `NemoTextProcessing` trait (#880, #888). .binaryTarget( name: "NemoTextProcessing", url: diff --git a/Package@swift-6.2.swift b/Package@swift-6.2.swift new file mode 100644 index 000000000..4c1bd5368 --- /dev/null +++ b/Package@swift-6.2.swift @@ -0,0 +1,95 @@ +// swift-tools-version: 6.2 +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 +// links a trait-conditioned binary target — verified on Xcode 16.4 — so the +// opt-out is gated at 6.2.) + +let package = Package( + name: "FluidAudio", + platforms: [ + .macOS(.v14), + .iOS(.v17), + ], + products: [ + .library( + name: "FluidAudio", + targets: ["FluidAudio"] + ), + .executable( + name: "fluidaudiocli", + targets: ["FluidAudioCLI"] + ), + ], + traits: [ + // Opt out of the NeMo text-normalization engine (~8 MB per slice, a prebuilt + // Rust staticlib) for ASR/VAD/diarization-only apps, or when the app + // links its own Rust runtime (#880, #888): + // .package(url: ..., traits: []) + // TTS frontends and `TextNormalizer` then pass text through unchanged + // and report `isNativeAvailable == false`. + .trait( + name: "NemoTextProcessing", + description: "Link the bundled NeMo text-normalization engine (TTS frontends, ITN)." + ), + .default(enabledTraits: ["NemoTextProcessing"]), + ], + dependencies: [], + targets: [ + .target( + name: "FluidAudio", + dependencies: [ + "FastClusterWrapper", + "MachTaskSelfWrapper", + .target(name: "NemoTextProcessing", condition: .when(traits: ["NemoTextProcessing"])), + ], + path: "Sources/FluidAudio", + exclude: ["ASR/Parakeet/Unified/benchmark.md"], + resources: [ + // Keep .process: .copy of a Resources-named directory breaks Apple code signing on iOS. + .process("TTS/LuxTts/G2p/Resources") + ] + ), + // Byte-exact NeMo text normalization (FST engine, all 7 languages). + // Prebuilt xcframework from FluidInference/text-processing-rs v0.3.0. + .binaryTarget( + name: "NemoTextProcessing", + url: + "https://github.com/FluidInference/text-processing-rs/releases/download/v0.3.0/NemoTextProcessing.xcframework.zip", + checksum: "76d0ee9a32b1ee2193231299180ca9bc4fc7e98794e771b3d55d66498352d85f" + ), + .target( + name: "FastClusterWrapper", + path: "Sources/FastClusterWrapper", + publicHeadersPath: "include" + ), + .target( + name: "MachTaskSelfWrapper", + path: "Sources/MachTaskSelfWrapper", + publicHeadersPath: "include" + ), + .executableTarget( + name: "FluidAudioCLI", + dependencies: ["FluidAudio"], + path: "Sources/FluidAudioCLI", + exclude: ["README.md"], + resources: [ + .process("Utils/english.json") + ] + ), + .testTarget( + name: "FluidAudioTests", + dependencies: [ + "FluidAudio", + "FluidAudioCLI", + ], + resources: [ + .process("TTS/LuxTts/Resources") + ] + ), + ], + cxxLanguageStandard: .cxx17 +) diff --git a/README.md b/README.md index cdfb3755d..92b75ad29 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Want to convert your own model? Check [möbius](https://github.com/FluidInferenc ## Highlights - **Automatic Speech Recognition (ASR)**: [Parakeet TDT v3](Documentation/Models.md#batch-transcription-near-real-time) (0.6b) and other TDT/CTC models for batch transcription supporting 25 European languages and Japanese, plus SenseVoice and Paraformer for Mandarin Chinese; [Parakeet EOU](Documentation/Models.md#streaming-transcription-true-real-time) (120m) for streaming ASR with end-of-utterance detection (English only). See all [ASR models](Documentation/Models.md#asr-models). -- **Inverse Text Normalization (ITN)**: Post-process ASR output to convert spoken-form to written-form ("two hundred" → "200"). See [text-processing-rs](https://github.com/FluidInference/text-processing-rs) +- **Inverse Text Normalization (ITN)**: Post-process ASR output to convert spoken-form to written-form ("two hundred" → "200"). See [text-processing-rs](https://github.com/FluidInference/text-processing-rs). Optional: ASR-only apps can drop the engine (~8 MB per slice) with `traits: []` (Swift 6.2+), see [PostProcessing.md](Documentation/ASR/PostProcessing.md#opting-out-of-the-engine) - **Text-to-Speech (TTS)**: Kokoro (82m) for parallel synthesis with SSML and pronunciation control across 9 languages (EN, ES, FR, HI, IT, JA, PT, ZH); PocketTTS for streaming TTS with voice cloning support (EN, DE, ES, FR, IT, PT — 6L and 24L variants) - **Speaker Diarization (Online + Offline)**: Speaker separation and identification across audio streams. Streaming pipeline for real-time processing and offline batch pipeline with advanced clustering. - **Speaker Embedding Extraction**: Generate speaker embeddings for voice comparison and clustering, you can use this for speaker identification diff --git a/Sources/FluidAudio/ITN/TextNormalizer.swift b/Sources/FluidAudio/ITN/TextNormalizer.swift index 82d4779c1..50868b19e 100644 --- a/Sources/FluidAudio/ITN/TextNormalizer.swift +++ b/Sources/FluidAudio/ITN/TextNormalizer.swift @@ -1,4 +1,6 @@ +#if canImport(CNemoTextProcessing) import CNemoTextProcessing +#endif import Foundation import NaturalLanguage @@ -19,25 +21,35 @@ import NaturalLanguage /// (e.g., "period" as a noun vs. punctuation). /// /// The native engine (`text-processing-rs`) ships with the package as a binary -/// target and is linked directly — no runtime discovery, always available. +/// target and is linked directly — no runtime discovery. Consumers on Swift 6.2+ +/// may opt out with the `NemoTextProcessing` trait, in which case this class +/// stays present and passes text through (`isNativeAvailable == false`). public final class TextNormalizer: Sendable { /// Whether the native NeMo library is available. /// - /// Always `true`: the library is statically linked via the bundled - /// `NemoTextProcessing` binary target. Kept for source compatibility with - /// releases that resolved the library at runtime (≤ 0.15.6). + /// A compile-time constant: `true` whenever the bundled `NemoTextProcessing` + /// binary target is linked (the default), `false` only when the consumer + /// resolved the package with that trait disabled (#880, #888) — every + /// call then returns its input unchanged. Releases ≤ 0.15.6 resolved the + /// library at runtime instead. + #if canImport(CNemoTextProcessing) public let isNativeAvailable = true + #else + public let isNativeAvailable = false + #endif /// Whether the linked library exposes the TN (written→spoken) surface used /// by the TTS frontends. /// - /// Always `true` with the bundled library. Kept for source compatibility. - public var isTnAvailable: Bool { true } + /// Tracks `isNativeAvailable`: the bundled library carries both surfaces. + public var isTnAvailable: Bool { isNativeAvailable } /// Shared instance for convenience. public static let shared = TextNormalizer() + private static let logger = AppLogger(category: "ITN") + /// Words that are ambiguous — they could be punctuation spoken forms OR normal English words. /// When these appear in sentence context, NLTagger is used to check if they're nouns/verbs/adjectives /// (natural language) vs. standalone punctuation commands. @@ -54,11 +66,15 @@ public final class TextNormalizer: Sendable { /// - Parameter input: Spoken-form text from ASR (e.g., "two hundred") /// - Returns: Written-form text (e.g., "200"), or original if no normalization applies public func normalize(_ input: String) -> String { + #if canImport(CNemoTextProcessing) guard let resultPtr = nemo_normalize(input) else { return input } defer { nemo_free_string(resultPtr) } return String(cString: resultPtr) + #else + return input + #endif } // MARK: - Text Normalization (written → spoken) @@ -66,21 +82,29 @@ public final class TextNormalizer: Sendable { /// Normalize written-form text to spoken form (single expression), e.g. /// `"$5.50"` → `"five dollars fifty cents"`. public func tnNormalize(_ input: String) -> String { + #if canImport(CNemoTextProcessing) guard let resultPtr = nemo_tn_normalize(input) else { return input } defer { nemo_free_string(resultPtr) } return String(cString: resultPtr) + #else + return input + #endif } /// Normalize a full sentence to spoken form, rewriting written-form spans /// in place (`"I paid $5"` → `"I paid five dollars"`). public func tnNormalizeSentence(_ input: String) -> String { + #if canImport(CNemoTextProcessing) guard let resultPtr = nemo_tn_normalize_sentence(input) else { return input } defer { nemo_free_string(resultPtr) } return String(cString: resultPtr) + #else + return input + #endif } /// Normalize a full sentence, replacing spoken-form spans with written form. @@ -92,12 +116,16 @@ public final class TextNormalizer: Sendable { /// - Parameter input: Full sentence from ASR /// - Returns: Sentence with spoken-form spans replaced public func normalizeSentence(_ input: String) -> String { + #if canImport(CNemoTextProcessing) let (masked, restore) = maskAmbiguousWords(in: input) guard let resultPtr = nemo_normalize_sentence(masked) else { return input } defer { nemo_free_string(resultPtr) } return restoreMaskedWords(String(cString: resultPtr), restore) + #else + return input + #endif } /// Normalize a full sentence with a configurable max span size. @@ -107,12 +135,16 @@ public final class TextNormalizer: Sendable { /// - maxSpanTokens: Maximum consecutive tokens per normalizable span /// - Returns: Sentence with spoken-form spans replaced public func normalizeSentence(_ input: String, maxSpanTokens: UInt32) -> String { + #if canImport(CNemoTextProcessing) let (masked, restore) = maskAmbiguousWords(in: input) guard let resultPtr = nemo_normalize_sentence_with_options(masked, 0, maxSpanTokens, 0) else { return input } defer { nemo_free_string(resultPtr) } return restoreMaskedWords(String(cString: resultPtr), restore) + #else + return input + #endif } /// Normalize an ASR result, returning a new result with normalized text. @@ -148,7 +180,11 @@ public final class TextNormalizer: Sendable { /// - spoken: The spoken form to match (e.g., "gee pee tee") /// - written: The written replacement (e.g., "GPT") public func addRule(spoken: String, written: String) { + #if canImport(CNemoTextProcessing) nemo_add_rule(spoken, written) + #else + Self.logger.warning("addRule ignored: NemoTextProcessing engine not linked") + #endif } /// Remove a custom normalization rule. @@ -157,27 +193,43 @@ public final class TextNormalizer: Sendable { /// - Returns: True if the rule was found and removed @discardableResult public func removeRule(spoken: String) -> Bool { + #if canImport(CNemoTextProcessing) nemo_remove_rule(spoken) != 0 + #else + return false + #endif } /// Clear all custom normalization rules. public func clearRules() { + #if canImport(CNemoTextProcessing) nemo_clear_rules() + #else + return + #endif } /// The number of custom rules currently registered. public var ruleCount: Int { + #if canImport(CNemoTextProcessing) Int(nemo_rule_count()) + #else + return 0 + #endif } // MARK: - Info /// The native library version. public var version: String? { + #if canImport(CNemoTextProcessing) guard let versionPtr = nemo_version() else { return nil } return String(cString: versionPtr) + #else + return nil + #endif } // MARK: - NLTagger Context Spotting diff --git a/Sources/FluidAudio/TTS/KokoroAne/KokoroAneManager.swift b/Sources/FluidAudio/TTS/KokoroAne/KokoroAneManager.swift index e604ef525..69559fa32 100644 --- a/Sources/FluidAudio/TTS/KokoroAne/KokoroAneManager.swift +++ b/Sources/FluidAudio/TTS/KokoroAne/KokoroAneManager.swift @@ -227,7 +227,14 @@ public actor KokoroAneManager { // Normalize written forms to their Mandarin reading before // segmentation — e.g. "$5" → "五美元", "2024年" → "二零二四年" — // so the numeric/semiotic tokens reach MandarinG2P as Hanzi. - let normalized = NemoTextNormalizer.normalize(text, language: .mandarin) + var normalized = NemoTextNormalizer.normalize(text, language: .mandarin) + // Without the engine linked (`NemoTextProcessing` trait off), a + // numeric-only input ("$5.50", "99%") has no Hanzi and would fall + // into the bopomofo passthrough below, reading digits as tones. + // MandarinNumberNormalizer covers those forms so the gate sees Hanzi. + if !NemoTextNormalizer.isAvailable, !MandarinG2P.looksLikeHanzi(normalized) { + normalized = MandarinNumberNormalizer.normalize(normalized) + } if MandarinG2P.looksLikeHanzi(normalized) { let g2p = try await store.mandarinG2PPipeline() return try await g2p.phonemize(normalized) diff --git a/Sources/FluidAudio/TTS/Shared/NemoTextNormalizer.swift b/Sources/FluidAudio/TTS/Shared/NemoTextNormalizer.swift index 8fbbe9e01..1ed86aa47 100644 --- a/Sources/FluidAudio/TTS/Shared/NemoTextNormalizer.swift +++ b/Sources/FluidAudio/TTS/Shared/NemoTextNormalizer.swift @@ -1,4 +1,6 @@ +#if canImport(CNemoTextProcessing) import CNemoTextProcessing +#endif import Foundation /// Byte-exact NeMo text normalization via the bundled compiled-FST engine @@ -24,13 +26,29 @@ public enum NemoTextNormalizer { case hindi = "hi" } + /// Whether the engine is linked into this build. `false` when the package + /// was resolved with the `NemoTextProcessing` trait disabled (#880, #888); + /// `normalize` then returns its input unchanged. + public static var isAvailable: Bool { + #if canImport(CNemoTextProcessing) + return true + #else + return false + #endif + } + /// Normalize `text` for `language`. Returns `text` unchanged if the engine - /// declines the input (its own out-of-domain passthrough) or the underlying - /// library was built without the `fst-engine` feature — so this is always - /// safe to call as a frontend pre-pass. + /// declines the input (its own out-of-domain passthrough), the underlying + /// library was built without the `fst-engine` feature, or the engine is + /// not linked (`isAvailable == false`) — so this is always safe to call as + /// a frontend pre-pass. public static func normalize(_ text: String, language: Language) -> String { + #if canImport(CNemoTextProcessing) guard let ptr = nemo_tn_fst(text, language.rawValue) else { return text } defer { nemo_free_string(ptr) } return String(cString: ptr) + #else + return text + #endif } } diff --git a/Tests/FluidAudioTests/TTS/NemoTextNormalizerTests.swift b/Tests/FluidAudioTests/TTS/NemoTextNormalizerTests.swift index aa26268cc..51311649f 100644 --- a/Tests/FluidAudioTests/TTS/NemoTextNormalizerTests.swift +++ b/Tests/FluidAudioTests/TTS/NemoTextNormalizerTests.swift @@ -11,6 +11,12 @@ import XCTest /// bundled grammars are wired correctly. final class NemoTextNormalizerTests: XCTestCase { + override func setUpWithError() throws { + try XCTSkipUnless( + NemoTextNormalizer.isAvailable, + "NemoTextProcessing trait disabled; engine not linked") + } + private func assertNormalizes( _ input: String, _ language: NemoTextNormalizer.Language, @@ -105,3 +111,18 @@ final class NemoTextNormalizerTests: XCTestCase { } } } + +/// The one behaviour that must hold when the package is resolved with the +/// `NemoTextProcessing` trait disabled (#880, #888): the wrapper stays callable +/// and returns its input unchanged. +final class NemoTextNormalizerUnavailableTests: XCTestCase { + + override func setUpWithError() throws { + try XCTSkipIf(NemoTextNormalizer.isAvailable, "engine linked; passthrough path not reachable") + } + + func testNormalizePassesThroughWithoutEngine() { + XCTAssertEqual(NemoTextNormalizer.normalize("$5", language: .english), "$5") + XCTAssertEqual(NemoTextNormalizer.normalize("2024年", language: .mandarin), "2024年") + } +} diff --git a/Tests/FluidAudioTests/TTS/TextNormalizerTests.swift b/Tests/FluidAudioTests/TTS/TextNormalizerTests.swift index 62afdea96..1345e2ece 100644 --- a/Tests/FluidAudioTests/TTS/TextNormalizerTests.swift +++ b/Tests/FluidAudioTests/TTS/TextNormalizerTests.swift @@ -5,6 +5,12 @@ import XCTest final class TextNormalizerTests: XCTestCase { + override func setUpWithError() throws { + try XCTSkipUnless( + NemoTextNormalizer.isAvailable, + "NemoTextProcessing trait disabled; engine not linked") + } + // MARK: - NLTagger Context Spotting /// Ambiguous words that are both punctuation spoken forms AND common English words. @@ -374,3 +380,28 @@ final class TextNormalizerTests: XCTestCase { XCTAssertEqual(normalizer.tnNormalizeSentence("I paid $5"), "I paid five dollars") } } + +/// Contract when the package is resolved with the `NemoTextProcessing` trait +/// disabled (#880, #888): the API stays present, reports the engine as absent, +/// and every entry point is a passthrough instead of a crash or a silent rewrite. +final class TextNormalizerUnavailableTests: XCTestCase { + + override func setUpWithError() throws { + try XCTSkipIf(NemoTextNormalizer.isAvailable, "engine linked; passthrough path not reachable") + } + + func testReportsUnavailableAndPassesThrough() { + let normalizer = TextNormalizer() + XCTAssertFalse(normalizer.isNativeAvailable) + XCTAssertFalse(normalizer.isTnAvailable) + XCTAssertNil(normalizer.version) + XCTAssertEqual(normalizer.normalize("twelve dollars"), "twelve dollars") + XCTAssertEqual(normalizer.normalizeSentence("period"), "period") + XCTAssertEqual(normalizer.normalizeSentence("two hundred", maxSpanTokens: 3), "two hundred") + XCTAssertEqual(normalizer.tnNormalize("$12"), "$12") + XCTAssertEqual(normalizer.tnNormalizeSentence("$12"), "$12") + normalizer.addRule(spoken: "foo", written: "bar") + XCTAssertEqual(normalizer.ruleCount, 0) + XCTAssertFalse(normalizer.removeRule(spoken: "foo")) + } +}