From 698972891ff5d19766c4ff190edf530291584a04 Mon Sep 17 00:00:00 2001 From: Alex-Wengg Date: Wed, 19 Aug 2026 04:33:12 -0400 Subject: [PATCH] fix(itn): link the bundled NeMo engine directly instead of dlopen(nil) discovery TextNormalizer resolved nemo_* symbols at runtime via dlopen(nil) + dlsym, a leftover from when the native library was an optional consumer-provided link. Since #790 the NemoTextProcessing xcframework ships with the package as a binary target, so runtime discovery is pure downside: - releases <= 0.15.5 shipped no artifact at all, making normalize() a silent no-op for every SwiftPM consumer (issue #839) - even with the bundled lib, dlsym silently regresses to the no-op in any app that strips global symbols from its executable (common in Release) - normalizeSentence(_:maxSpanTokens:) looked up nemo_normalize_sentence_with_max_span, which does not exist in the shipped v0.3.0 library, so the parameter was silently ignored Import CNemoTextProcessing and call the C API directly, like the TTS-side NemoTextNormalizer already does. Availability is now a link-time guarantee; isNativeAvailable/isTnAvailable are kept (always true) for source compat. The max-span variant now calls nemo_normalize_sentence_with_options, which actually honors maxSpanTokens. Tests previously guarded on !isNativeAvailable (permanently skipped once the lib is linked) now assert real engine output, including the exact issue repro: normalize("twelve dollars") == "$12". Expectations verified against the bundled v0.3.0 engine. Fixes #839 --- Documentation/ASR/PostProcessing.md | 19 +- Sources/FluidAudio/ITN/TextNormalizer.swift | 318 +++--------------- .../TTS/TextNormalizerTests.swift | 131 ++------ 3 files changed, 75 insertions(+), 393 deletions(-) diff --git a/Documentation/ASR/PostProcessing.md b/Documentation/ASR/PostProcessing.md index 0b1550c0e..cb1f684f9 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 includes optional support for text-processing-rs through the `TextNormalizer` class. The library uses dynamic loading, so it's completely optional — if not linked, `normalize()` returns the input unchanged. +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. ### ITN (Spoken to Written) @@ -39,14 +39,11 @@ import FluidAudio let normalizer = TextNormalizer.shared -// Check if native library is available -if normalizer.isNativeAvailable { - print("ITN version: \(normalizer.version ?? "unknown")") -} +print("ITN version: \(normalizer.version ?? "unknown")") // Normalize spoken-form text let result = normalizer.normalize("two hundred dollars") -// Returns "$200" (with native library) or "two hundred dollars" (without) +// Returns "$200" ``` ### TN (Written to Spoken) @@ -71,12 +68,6 @@ let normalizedResult = normalizer.normalize(result: asrResult) print(normalizedResult.text) // Written form ``` -### Linking the Native Library +### Native Library -To enable text processing support, link your app against `libnemo_text_processing`: - -1. Build text-processing-rs for your target platform -2. Add the library to your Xcode project's linker settings -3. `TextNormalizer.isNativeAvailable` will return `true` - -See the [text-processing-rs README](https://github.com/FluidInference/text-processing-rs) for build instructions. +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. diff --git a/Sources/FluidAudio/ITN/TextNormalizer.swift b/Sources/FluidAudio/ITN/TextNormalizer.swift index b8aec9e1a..82d4779c1 100644 --- a/Sources/FluidAudio/ITN/TextNormalizer.swift +++ b/Sources/FluidAudio/ITN/TextNormalizer.swift @@ -1,6 +1,6 @@ +import CNemoTextProcessing import Foundation import NaturalLanguage -import OSLog /// Inverse Text Normalization (ITN) for post-processing ASR output. /// @@ -17,12 +17,23 @@ import OSLog /// /// Uses Apple NaturalLanguage framework to avoid false positives on ambiguous words /// (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. public final class TextNormalizer: Sendable { - private let logger = Logger(subsystem: "FluidAudio", category: "ITN") - /// Whether the native NeMo library is available. - public let isNativeAvailable: Bool + /// + /// 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). + public let isNativeAvailable = true + + /// 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 } /// Shared instance for convenience. public static let shared = TextNormalizer() @@ -34,180 +45,7 @@ public final class TextNormalizer: Sendable { "period", "dash", "colon", "pipe", "slash", "dot", "plus", "hash", "percent", ] - /// Resolved C function pointers (set once during init, then immutable). - private let nemoNormalize: - ( - @convention(c) (UnsafePointer?) -> UnsafeMutablePointer? - )? - private let nemoNormalizeSentence: - ( - @convention(c) (UnsafePointer?) -> UnsafeMutablePointer? - )? - private let nemoNormalizeSentenceMaxSpan: - ( - @convention(c) (UnsafePointer?, UInt32) -> UnsafeMutablePointer? - )? - private let nemoTnNormalize: - ( - @convention(c) (UnsafePointer?) -> UnsafeMutablePointer? - )? - private let nemoTnNormalizeSentence: - ( - @convention(c) (UnsafePointer?) -> UnsafeMutablePointer? - )? - private let nemoFreeString: - ( - @convention(c) (UnsafeMutablePointer?) -> Void - )? - private let nemoAddRule: - ( - @convention(c) (UnsafePointer?, UnsafePointer?) -> Void - )? - private let nemoRemoveRule: - ( - @convention(c) (UnsafePointer?) -> Int32 - )? - private let nemoClearRules: - ( - @convention(c) () -> Void - )? - private let nemoRuleCount: - ( - @convention(c) () -> UInt32 - )? - private let nemoVersion: - ( - @convention(c) () -> UnsafePointer? - )? - - public init() { - guard let handle = dlopen(nil, RTLD_NOW) else { - self.isNativeAvailable = false - self.nemoNormalize = nil - self.nemoNormalizeSentence = nil - self.nemoNormalizeSentenceMaxSpan = nil - self.nemoTnNormalize = nil - self.nemoTnNormalizeSentence = nil - self.nemoFreeString = nil - self.nemoAddRule = nil - self.nemoRemoveRule = nil - self.nemoClearRules = nil - self.nemoRuleCount = nil - self.nemoVersion = nil - return - } - - guard let normalizePtr = dlsym(handle, "nemo_normalize"), - let freePtr = dlsym(handle, "nemo_free_string"), - let versionPtr = dlsym(handle, "nemo_version") - else { - self.isNativeAvailable = false - self.nemoNormalize = nil - self.nemoNormalizeSentence = nil - self.nemoNormalizeSentenceMaxSpan = nil - self.nemoTnNormalize = nil - self.nemoTnNormalizeSentence = nil - self.nemoFreeString = nil - self.nemoAddRule = nil - self.nemoRemoveRule = nil - self.nemoClearRules = nil - self.nemoRuleCount = nil - self.nemoVersion = nil - return - } - - self.nemoNormalize = unsafeBitCast( - normalizePtr, - to: (@convention(c) (UnsafePointer?) -> UnsafeMutablePointer?).self - ) - self.nemoFreeString = unsafeBitCast( - freePtr, - to: (@convention(c) (UnsafeMutablePointer?) -> Void).self - ) - self.nemoVersion = unsafeBitCast( - versionPtr, - to: (@convention(c) () -> UnsafePointer?).self - ) - - // Sentence-mode functions (optional — may not be present in older library builds) - if let sentencePtr = dlsym(handle, "nemo_normalize_sentence") { - self.nemoNormalizeSentence = unsafeBitCast( - sentencePtr, - to: (@convention(c) (UnsafePointer?) -> UnsafeMutablePointer?).self - ) - } else { - self.nemoNormalizeSentence = nil - } - - if let sentenceMaxPtr = dlsym(handle, "nemo_normalize_sentence_with_max_span") { - self.nemoNormalizeSentenceMaxSpan = unsafeBitCast( - sentenceMaxPtr, - to: (@convention(c) (UnsafePointer?, UInt32) -> UnsafeMutablePointer?).self - ) - } else { - self.nemoNormalizeSentenceMaxSpan = nil - } - - // Text-normalization (written → spoken) functions — optional; present - // only when the linked library exposes the TN surface (issue #711 - // follow-up). Used by the TTS frontends for richer normalization. - if let tnPtr = dlsym(handle, "nemo_tn_normalize") { - self.nemoTnNormalize = unsafeBitCast( - tnPtr, - to: (@convention(c) (UnsafePointer?) -> UnsafeMutablePointer?).self - ) - } else { - self.nemoTnNormalize = nil - } - - if let tnSentencePtr = dlsym(handle, "nemo_tn_normalize_sentence") { - self.nemoTnNormalizeSentence = unsafeBitCast( - tnSentencePtr, - to: (@convention(c) (UnsafePointer?) -> UnsafeMutablePointer?).self - ) - } else { - self.nemoTnNormalizeSentence = nil - } - - // Custom rules functions (optional) - if let addPtr = dlsym(handle, "nemo_add_rule") { - self.nemoAddRule = unsafeBitCast( - addPtr, - to: (@convention(c) (UnsafePointer?, UnsafePointer?) -> Void).self - ) - } else { - self.nemoAddRule = nil - } - - if let removePtr = dlsym(handle, "nemo_remove_rule") { - self.nemoRemoveRule = unsafeBitCast( - removePtr, - to: (@convention(c) (UnsafePointer?) -> Int32).self - ) - } else { - self.nemoRemoveRule = nil - } - - if let clearPtr = dlsym(handle, "nemo_clear_rules") { - self.nemoClearRules = unsafeBitCast( - clearPtr, - to: (@convention(c) () -> Void).self - ) - } else { - self.nemoClearRules = nil - } - - if let countPtr = dlsym(handle, "nemo_rule_count") { - self.nemoRuleCount = unsafeBitCast( - countPtr, - to: (@convention(c) () -> UInt32).self - ) - } else { - self.nemoRuleCount = nil - } - - self.isNativeAvailable = true - } + public init() {} // MARK: - Normalization @@ -216,55 +54,32 @@ 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 { - guard isNativeAvailable, - let normalizeFn = nemoNormalize, - let freeFn = nemoFreeString - else { + guard let resultPtr = nemo_normalize(input) else { return input } - - guard let resultPtr = input.withCString({ normalizeFn($0) }) else { - return input - } - - defer { freeFn(resultPtr) } + defer { nemo_free_string(resultPtr) } return String(cString: resultPtr) } // MARK: - Text Normalization (written → spoken) - /// Whether the linked library exposes the TN (written→spoken) surface used - /// by the TTS frontends. False when no native library is linked or it only - /// provides the ITN symbols. - public var isTnAvailable: Bool { - isNativeAvailable && nemoTnNormalizeSentence != nil - } - /// Normalize written-form text to spoken form (single expression), e.g. - /// `"$5.50"` → `"five dollars fifty cents"`. Returns the input unchanged - /// when the native TN surface is unavailable. + /// `"$5.50"` → `"five dollars fifty cents"`. public func tnNormalize(_ input: String) -> String { - guard let tnFn = nemoTnNormalize, let freeFn = nemoFreeString else { - return input - } - guard let resultPtr = input.withCString({ tnFn($0) }) else { + guard let resultPtr = nemo_tn_normalize(input) else { return input } - defer { freeFn(resultPtr) } + defer { nemo_free_string(resultPtr) } return String(cString: resultPtr) } /// Normalize a full sentence to spoken form, rewriting written-form spans - /// in place (`"I paid $5"` → `"I paid five dollars"`). Returns the input - /// unchanged when the native TN surface is unavailable. + /// in place (`"I paid $5"` → `"I paid five dollars"`). public func tnNormalizeSentence(_ input: String) -> String { - guard let tnFn = nemoTnNormalizeSentence, let freeFn = nemoFreeString else { - return input - } - guard let resultPtr = input.withCString({ tnFn($0) }) else { + guard let resultPtr = nemo_tn_normalize_sentence(input) else { return input } - defer { freeFn(resultPtr) } + defer { nemo_free_string(resultPtr) } return String(cString: resultPtr) } @@ -277,12 +92,12 @@ public final class TextNormalizer: Sendable { /// - Parameter input: Full sentence from ASR /// - Returns: Sentence with spoken-form spans replaced public func normalizeSentence(_ input: String) -> String { - guard isNativeAvailable else { + let (masked, restore) = maskAmbiguousWords(in: input) + guard let resultPtr = nemo_normalize_sentence(masked) else { return input } - - let (masked, restore) = maskAmbiguousWords(in: input) - return restoreMaskedWords(callNormalizeSentence(masked), restore) + defer { nemo_free_string(resultPtr) } + return restoreMaskedWords(String(cString: resultPtr), restore) } /// Normalize a full sentence with a configurable max span size. @@ -292,19 +107,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 { - guard isNativeAvailable else { + let (masked, restore) = maskAmbiguousWords(in: input) + guard let resultPtr = nemo_normalize_sentence_with_options(masked, 0, maxSpanTokens, 0) else { return input } - - let (masked, restore) = maskAmbiguousWords(in: input) - return restoreMaskedWords( - callNormalizeSentenceWithMaxSpan(masked, maxSpanTokens: maxSpanTokens), restore) + defer { nemo_free_string(resultPtr) } + return restoreMaskedWords(String(cString: resultPtr), restore) } /// Normalize an ASR result, returning a new result with normalized text. /// - /// Uses sentence-mode normalization if available, otherwise falls back to single-expression mode. - /// /// - Parameter result: The original ASR result /// - Returns: A new ASR result with normalized text public func normalize(result: ASRResult) -> ASRResult { @@ -336,12 +148,7 @@ 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) { - guard let addFn = nemoAddRule else { return } - spoken.withCString { spokenPtr in - written.withCString { writtenPtr in - addFn(spokenPtr, writtenPtr) - } - } + nemo_add_rule(spoken, written) } /// Remove a custom normalization rule. @@ -350,31 +157,24 @@ public final class TextNormalizer: Sendable { /// - Returns: True if the rule was found and removed @discardableResult public func removeRule(spoken: String) -> Bool { - guard let removeFn = nemoRemoveRule else { return false } - return spoken.withCString { spokenPtr in - removeFn(spokenPtr) != 0 - } + nemo_remove_rule(spoken) != 0 } /// Clear all custom normalization rules. public func clearRules() { - nemoClearRules?() + nemo_clear_rules() } /// The number of custom rules currently registered. public var ruleCount: Int { - guard let countFn = nemoRuleCount else { return 0 } - return Int(countFn()) + Int(nemo_rule_count()) } // MARK: - Info - /// Get the native library version, or nil if not available. + /// The native library version. public var version: String? { - guard isNativeAvailable, - let getVersion = nemoVersion, - let versionPtr = getVersion() - else { + guard let versionPtr = nemo_version() else { return nil } return String(cString: versionPtr) @@ -382,15 +182,6 @@ public final class TextNormalizer: Sendable { // MARK: - NLTagger Context Spotting - /// Filter ambiguous words in a sentence using NLTagger part-of-speech analysis. - /// - /// Words like "period", "dash", "colon" can be either punctuation commands or - /// natural language. This method uses NLTagger to check if ambiguous words are - /// being used as nouns/verbs/adjectives (natural language) and wraps them in - /// a passthrough marker so the Rust normalizer skips them. - /// - /// - Parameter input: The raw sentence - /// - Returns: Sentence with ambiguous natural-language words preserved /// Mask ambiguous words that NLTagger identifies as natural language, so the /// native normalizer can't rewrite them (e.g. the noun "period" → "."). /// @@ -462,37 +253,4 @@ public final class TextNormalizer: Sendable { } return out } - - // MARK: - Private FFI Helpers - - private func callNormalizeSentence(_ input: String) -> String { - // Prefer sentence-mode API if available - if let sentenceFn = nemoNormalizeSentence, - let freeFn = nemoFreeString - { - guard let resultPtr = input.withCString({ sentenceFn($0) }) else { - return input - } - defer { freeFn(resultPtr) } - return String(cString: resultPtr) - } - - // Fallback: use single-expression normalize on the whole input - return normalize(input) - } - - private func callNormalizeSentenceWithMaxSpan(_ input: String, maxSpanTokens: UInt32) -> String { - if let sentenceMaxFn = nemoNormalizeSentenceMaxSpan, - let freeFn = nemoFreeString - { - guard let resultPtr = input.withCString({ sentenceMaxFn($0, maxSpanTokens) }) else { - return input - } - defer { freeFn(resultPtr) } - return String(cString: resultPtr) - } - - // Fallback to default sentence normalization - return callNormalizeSentence(input) - } } diff --git a/Tests/FluidAudioTests/TTS/TextNormalizerTests.swift b/Tests/FluidAudioTests/TTS/TextNormalizerTests.swift index f62ed0096..62afdea96 100644 --- a/Tests/FluidAudioTests/TTS/TextNormalizerTests.swift +++ b/Tests/FluidAudioTests/TTS/TextNormalizerTests.swift @@ -114,47 +114,41 @@ final class TextNormalizerTests: XCTestCase { // MARK: - TextNormalizer Instance - func testTextNormalizerInit() { + func testNativeLibraryAlwaysAvailable() { + // The engine is linked via the bundled NemoTextProcessing binary target, + // so it must be available in every consumer build (issue #839). let normalizer = TextNormalizer() - // isNativeAvailable depends on whether the Rust library is linked. - // In unit tests it won't be, so just verify it initializes without crashing. - XCTAssertNotNil(normalizer) + XCTAssertTrue(normalizer.isNativeAvailable) + XCTAssertTrue(normalizer.isTnAvailable) + XCTAssertNotNil(normalizer.version) } - func testTextNormalizerFallbackWithoutNativeLib() { + func testNormalizeConvertsSpokenNumbers() { let normalizer = TextNormalizer() - - guard !normalizer.isNativeAvailable else { - // If native lib IS available (e.g., in integration tests), skip this test - return - } - - // Without native library, normalize should return input unchanged - XCTAssertEqual(normalizer.normalize("twenty one"), "twenty one") - XCTAssertEqual(normalizer.normalizeSentence("I have twenty one apples"), "I have twenty one apples") + XCTAssertEqual(normalizer.normalize("twenty one"), "21") + XCTAssertEqual(normalizer.normalize("two hundred"), "200") + // Exact repro from issue #839 + XCTAssertEqual(normalizer.normalize("twelve dollars"), "$12") } - func testTextNormalizerVersionWithoutNativeLib() { + func testNormalizeSentenceConvertsSpans() { let normalizer = TextNormalizer() - - guard !normalizer.isNativeAvailable else { - return - } - - XCTAssertNil(normalizer.version) + XCTAssertEqual(normalizer.normalizeSentence("I have twenty one apples"), "I have 21 apples") + XCTAssertEqual(normalizer.normalizeSentence("it costs twelve dollars"), "it costs $12") } - func testTextNormalizerCustomRulesWithoutNativeLib() { + func testTnNormalizeConvertsWrittenToSpoken() { let normalizer = TextNormalizer() + XCTAssertEqual(normalizer.tnNormalize("$5.50"), "five dollars fifty cents") + } - guard !normalizer.isNativeAvailable else { - return - } - - // Custom rules should be no-ops without native lib - normalizer.addRule(spoken: "test", written: "TEST") + func testCustomRules() { + let normalizer = TextNormalizer() + normalizer.addRule(spoken: "gee pee tee", written: "GPT") + XCTAssertEqual(normalizer.ruleCount, 1) + XCTAssertEqual(normalizer.normalize("gee pee tee"), "GPT") + XCTAssertTrue(normalizer.removeRule(spoken: "gee pee tee")) XCTAssertEqual(normalizer.ruleCount, 0) - XCTAssertFalse(normalizer.removeRule(spoken: "test")) } func testTextNormalizerIsSendable() { @@ -322,18 +316,6 @@ final class TextNormalizerTests: XCTestCase { // MARK: - filterAmbiguousWords Logic - func testFilterReturnsUnchangedWhenNoAmbiguousWords() throws { - let normalizer = TextNormalizer() - // This asserts the no-native-lib fallback (normalizeSentence returns the - // input unchanged). When the native ITN lib is linked it deliberately - // rewrites spoken forms (e.g. "twenty one" → "21"); that path is - // covered in text-processing-rs, so skip here. - try XCTSkipIf(normalizer.isNativeAvailable, "native ITN linked; asserts the fallback path") - let input = "I have twenty one apples" - let result = normalizer.normalizeSentence(input) - XCTAssertEqual(result, input) - } - func testFilterWithAmbiguousWordInSentence() { let normalizer = TextNormalizer() // Ambiguous words used as natural language (nouns) must survive @@ -350,33 +332,17 @@ final class TextNormalizerTests: XCTestCase { } } - func testStandaloneAmbiguousWordStillNormalizes() throws { - let normalizer = TextNormalizer() + func testStandaloneAmbiguousWordStillNormalizes() { // The mask only protects natural-language usage; a standalone spoken - // command still normalizes when the native lib is linked. - try XCTSkipIf(!normalizer.isNativeAvailable, "requires the native normalizer") - XCTAssertEqual(normalizer.normalizeSentence("period"), ".") - } - - func testFilterWithStandalonePunctuationWord() { + // command still normalizes. let normalizer = TextNormalizer() - // Standalone "period" — should be treated as punctuation command - let input = "period" - let result = normalizer.normalizeSentence(input) - // Without native lib, returns unchanged. With native lib, - // standalone "period" should normalize to "." - if normalizer.isNativeAvailable { - XCTAssertEqual(result, ".") - } else { - XCTAssertEqual(result, input) - } + XCTAssertEqual(normalizer.normalizeSentence("period"), ".") } // MARK: - TextNormalizer normalize(result:) Method - func testNormalizeASRResultWithoutNativeLib() { + func testNormalizeASRResult() { let normalizer = TextNormalizer() - guard !normalizer.isNativeAvailable else { return } let asrResult = ASRResult( text: "I have twenty one apples", @@ -388,56 +354,23 @@ final class TextNormalizerTests: XCTestCase { ctcAppliedTerms: [] ) let normalized = normalizer.normalize(result: asrResult) - // Without native lib, text should be unchanged - XCTAssertEqual(normalized.text, "I have twenty one apples") + XCTAssertEqual(normalized.text, "I have 21 apples") // Metadata should be preserved XCTAssertEqual(normalized.confidence, 0.95) XCTAssertEqual(normalized.duration, 2.0) } - // MARK: - TextNormalizer Shared Instance - - func testSharedInstanceIsSameType() { - let shared = TextNormalizer.shared - XCTAssertNotNil(shared) - // Verify shared instance is consistent - XCTAssertEqual(shared.isNativeAvailable, TextNormalizer.shared.isNativeAvailable) - } - // MARK: - TextNormalizer maxSpanTokens Variant - func testNormalizeSentenceWithMaxSpanWithoutNativeLib() { + func testNormalizeSentenceWithMaxSpan() { let normalizer = TextNormalizer() - guard !normalizer.isNativeAvailable else { return } - - let input = "twenty one apples" - let result = normalizer.normalizeSentence(input, maxSpanTokens: 8) - XCTAssertEqual(result, input) + XCTAssertEqual(normalizer.normalizeSentence("twenty one apples", maxSpanTokens: 8), "21 apples") } // MARK: - TN (written → spoken) surface - func testTnPassthroughWithoutNativeLib() { + func testTnNormalizeSentence() { let normalizer = TextNormalizer() - guard !normalizer.isNativeAvailable else { return } - - // No native library linked → TN unavailable and inputs pass through. - XCTAssertFalse(normalizer.isTnAvailable) - XCTAssertEqual(normalizer.tnNormalize("$5.50"), "$5.50") - XCTAssertEqual(normalizer.tnNormalizeSentence("I paid $5"), "I paid $5") - } - - /// When the native TN surface is unavailable (the default), the shared TTS - /// entry point must equal the conservative baseline so spoken output is - /// unchanged. - func testFrontendNormalizationFallsBackToBaseline() { - guard !TextNormalizer.shared.isTnAvailable else { return } - - for input in ["I am 26 years old.", "The score is 3.14.", "Agent 007", "hello world"] { - XCTAssertEqual( - EnglishTextNormalizer.normalizeForFrontend(input), - EnglishTextNormalizer.normalize(input), - "frontend normalization should match the baseline without the native lib") - } + XCTAssertEqual(normalizer.tnNormalizeSentence("I paid $5"), "I paid five dollars") } }