From cbb5278cffa92bc8b3ce1a712ec643a522321603 Mon Sep 17 00:00:00 2001 From: Ankur Mittal Date: Mon, 31 Aug 2026 12:23:19 +0530 Subject: [PATCH 1/3] fix(tts/kokoro-ane): stem possessives instead of G2P-ing the whole token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Misaki lexicon stores the `-s` clitic as its own `'s` entry and carries no glued possessive keys, so `today's` / `someone's` / `the boss's` miss every lexicon tier in `KokoroAneEnglishPhonemizer.resolveWord` and the whole inflected token reaches the BART G2P fallback, which mangles it (`someone's` was heard as "Samian's" in a whisper transcription of the rendered audio). Python Misaki does not have this problem because `Lexicon.__call__` falls through to `stem_s`: the stem is looked up on its own and the clitic phoneme is appended by rule. This ports that rule. After a full lexicon miss (and after the #710 initialism spell-out), a token ending in `'s` — case-insensitively, and post the #774 apostrophe folding, so `Today's`, `TODAY'S` and `Today\u{2019}s` all qualify — resolves its stem through the normal chain minus the G2P fallback, then appends the clitic per `Lexicon._s`: * `/s/` after a voiceless non-sibilant (`p t k f θ`) — `cat's` -> `kˈæts` * `/ᵻz/` after a sibilant (`s z ʃ ʒ ʧ ʤ`) — `boss's` -> `bˈɑsᵻz` * `/z/` otherwise — `today's` -> `tədˈAz` `ᵻ` is Misaki's US form of the epenthetic vowel (`ɪ` is the `british=True` form); this frontend loads the US lexicon and `ᵻ` is present in the chain's `vocab.json`. Faithful to `stem_s`, the rule only fires when the stem is a *known* word, so an OOV stem leaves the token on the existing whole-word G2P path rather than being re-shaped from a guess. The stem going through the normal chain means #775's hyphen split still applies underneath the clitic (`mother-in-law's` -> `mˈʌðɜɹ ɪn lˈɔz`), and a glued lexicon entry that does exist (`it's`) still wins, because stemming runs only after the miss. `resolveWord` / `resolveHyphenatedCompound` gain an `allowFallback` flag (defaulting to today's behavior) to express "resolve, but only if known". Tests: voiced / voiceless / sibilant / vowel-final stems, curly apostrophe, uppercase `'S` with a case-sensitive stem, hyphenated-compound stem, OOV-stem fallback, lexicon-entry precedence, and a direct table check of the clitic rule against `Lexicon._s`. `swift build`, `swift test --filter TTS` (57 tests) and `swift-format lint` are clean. Co-Authored-By: Claude Fable 5 --- .../English/KokoroAneEnglishPhonemizer.swift | 90 +++++++++++- .../KokoroAneEnglishPhonemizerTests.swift | 128 ++++++++++++++++++ 2 files changed, 214 insertions(+), 4 deletions(-) diff --git a/Sources/FluidAudio/TTS/KokoroAne/G2P/English/KokoroAneEnglishPhonemizer.swift b/Sources/FluidAudio/TTS/KokoroAne/G2P/English/KokoroAneEnglishPhonemizer.swift index 2783103ce..b477c8309 100644 --- a/Sources/FluidAudio/TTS/KokoroAne/G2P/English/KokoroAneEnglishPhonemizer.swift +++ b/Sources/FluidAudio/TTS/KokoroAne/G2P/English/KokoroAneEnglishPhonemizer.swift @@ -16,7 +16,9 @@ import Foundation /// citation form (`tˈO`) that over-stresses them (issue #691) /// 6. strict ASCII all-caps initialisms (`FBI`, `ATP`) spelled as /// letter names after a full lexicon miss (issue #710) -/// 7. BART G2P CoreML fallback for OOV words (injected by the caller) +/// 7. `-'s` stem + clitic for possessives whose stem is a known word +/// (`today's` → `today` + /z/), mirroring Misaki's `stem_s` +/// 8. BART G2P CoreML fallback for OOV words (injected by the caller) /// /// Punctuation supported by the chain's `vocab.json` (`, . ! ? ; …` etc.) /// is preserved and attached to the preceding word — Kokoro treats those @@ -111,8 +113,14 @@ struct KokoroAneEnglishPhonemizer: Sendable { // MARK: - Word resolution + /// - Parameter allowFallback: when `false`, the BART G2P fallback is + /// skipped and an OOV word resolves to `nil`. Used by the possessive + /// rule, which — like Misaki's `stem_s` — only fires when the stem is a + /// *known* word; an OOV stem must leave the whole token on its original + /// path instead of quietly re-shaping it. private func resolveWord( _ word: String, + allowFallback: Bool = true, fallback: (String) async throws -> [String]? ) async throws -> String? { let normalized = Self.normalizeKey(word) @@ -162,18 +170,31 @@ struct KokoroAneEnglishPhonemizer: Sendable { return spelled } + // A possessive / `-'s` clitic whose stem is a known word (`today's`, + // `someone's`, `the boss's`). The lexicon stores the clitic `'s` as its + // own entry and carries no glued `today's` key, so these always miss + // above and the whole inflected token goes to BART G2P, which mangles + // it (`someone's` → "Samian's"). Resolve the stem and append the clitic + // by rule instead — same shape as Misaki's `Lexicon.stem_s`. + if let possessive = try await resolvePossessive( + word, lowered: lowered, fallback: fallback) + { + return possessive + } + // A hyphenated compound that missed every lexicon as a whole // (`tales-to-amaze`) — resolve each part and join, so it reads as // `tales to amaze` instead of BART G2P on the glued `talestoamaze` // (issue #775). Real lexicon compounds (`twenty-one`) already returned // above, so only genuine misses reach here. if word.contains("-"), - let compound = try await resolveHyphenatedCompound(word, fallback: fallback) + let compound = try await resolveHyphenatedCompound( + word, allowFallback: allowFallback, fallback: fallback) { return compound } - guard !normalized.isEmpty else { return nil } + guard allowFallback, !normalized.isEmpty else { return nil } do { if let phonemes = try await fallback(normalized), !phonemes.isEmpty { return phonemes.joined() @@ -194,6 +215,7 @@ struct KokoroAneEnglishPhonemizer: Sendable { /// recurse back into itself. private func resolveHyphenatedCompound( _ word: String, + allowFallback: Bool = true, fallback: (String) async throws -> [String]? ) async throws -> String? { let parts = word.split(separator: "-", omittingEmptySubsequences: true).map(String.init) @@ -202,7 +224,11 @@ struct KokoroAneEnglishPhonemizer: Sendable { var resolved: [String] = [] resolved.reserveCapacity(parts.count) for part in parts { - guard let ipa = try await resolveWord(part, fallback: fallback), !ipa.isEmpty else { + guard + let ipa = try await resolveWord( + part, allowFallback: allowFallback, fallback: fallback), + !ipa.isEmpty + else { return nil } resolved.append(ipa) @@ -210,6 +236,62 @@ struct KokoroAneEnglishPhonemizer: Sendable { return resolved.joined(separator: " ") } + // MARK: - Possessive / `-'s` clitic + + /// Resolve a lower-cased token ending in `'s` as stem + `-s` clitic. + /// + /// Mirrors Misaki's `Lexicon.stem_s`, which only accepts the split when + /// the stem is a known word — so an OOV stem returns `nil` here and the + /// caller falls through to whole-token G2P exactly as before. The stem is + /// resolved through the normal chain minus the G2P fallback, which keeps + /// custom-lexicon overrides, letter-name spelling and hyphen splitting + /// working for compounds like `mother-in-law's`. + /// + /// - Parameters: + /// - word: the token as written (apostrophes already folded to ASCII by + /// ``normalizeApostrophes``). Original case is preserved so the stem + /// can still reach case-sensitive entries (`NASA's`, `iPhone's`). + /// - lowered: `word.lowercased()`, so `TODAY'S` matches too. + private func resolvePossessive( + _ word: String, + lowered: String, + fallback: (String) async throws -> [String]? + ) async throws -> String? { + // `len(word) < 3` in Misaki: a bare `'s` (and anything shorter than + // three characters) never stems. + guard lowered.count >= 3, lowered.hasSuffix("'s") else { return nil } + let stem = String(word.dropLast(2)) + guard !stem.isEmpty, !stem.hasSuffix("'") else { return nil } + + guard + let stemIPA = try await resolveWord(stem, allowFallback: false, fallback: fallback), + !stemIPA.isEmpty + else { + return nil + } + return stemIPA + Self.clitic(after: stemIPA) + } + + /// Voiceless non-sibilant obstruents — the `-s` clitic devoices after + /// these (`cat's` → `kˈæts`). + private static let voicelessNonSibilants: Set = ["p", "t", "k", "f", "θ"] + + /// Sibilants — the clitic takes an epenthetic vowel after these + /// (`boss's` → `bˈɑsᵻz`). Note the Misaki lexicon spells the affricates + /// with the single-scalar ligatures `ʧ` / `ʤ`, not `tʃ` / `dʒ`. + private static let sibilants: Set = ["s", "z", "ʃ", "ʒ", "ʧ", "ʤ"] + + /// The `-s` clitic phoneme for a stem, by English phonology — a direct + /// port of Misaki's `Lexicon._s`. The US form of the epenthetic vowel is + /// `ᵻ` (Misaki uses `ɪ` only when `british`); this frontend loads the US + /// lexicon, and `ᵻ` is in the chain's `vocab.json`. + static func clitic(after stemIPA: String) -> String { + guard let last = stemIPA.last else { return "z" } + if voicelessNonSibilants.contains(last) { return "s" } + if sibilants.contains(last) { return "ᵻz" } + return "z" + } + // MARK: - Letter-name initialisms (issue #710) /// Spell a token as a sequence of letter names using the per-letter diff --git a/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneEnglishPhonemizerTests.swift b/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneEnglishPhonemizerTests.swift index 7d93e9d28..511560292 100644 --- a/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneEnglishPhonemizerTests.swift +++ b/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneEnglishPhonemizerTests.swift @@ -352,6 +352,134 @@ final class KokoroAneEnglishPhonemizerTests: XCTestCase { XCTAssertEqual(result, "") } + // MARK: - Possessive `-'s` clitic + + /// Lexicon stand-in for the possessive cases. Mirrors the real + /// `us_lexicon_cache.json`, which stores the clitic `'s` as its own entry + /// and carries no glued `today's` / `someone's` / `boss's` keys. + private let possessiveLexicon: [String: [String]] = [ + "'s": ["z"], + "today": ["t", "ə", "d", "ˈ", "A"], + "someone": ["s", "ˈ", "ʌ", "m", "w", "ʌ", "n"], + "boss": ["b", "ˈ", "ɑ", "s"], + "cat": ["k", "ˈ", "æ", "t"], + "coat": ["k", "ˈ", "O", "t"], + "is": ["ɪ", "z"], + "here": ["h", "ˈ", "ɪ", "ɹ"], + "law": ["l", "ˈ", "ɔ"], + "in": ["ɪ", "n"], + "mother": ["m", "ˈ", "ʌ", "ð", "ɜ", "ɹ"], + ] + + private func makePossessivePhonemizer() -> KokoroAneEnglishPhonemizer { + KokoroAneEnglishPhonemizer( + wordToPhonemes: possessiveLexicon, + caseSensitiveWordToPhonemes: caseSensitive, + allowedPunctuation: punctuation + ) + } + + func testPossessiveVoicedStemTakesZ() async throws { + let recorder = FallbackRecorder() + // `someone's` is absent from the lexicon; before the fix `normalizeKey` + // stripped the apostrophe and G2P sounded out `someones`. + let result = try await makePossessivePhonemizer() + .phonemize("someone's coat is here.") { await recorder.g2p($0) } + XCTAssertEqual(result, "sˈʌmwʌnz kˈOt ɪz hˈɪɹ.") + let recorded = await recorder.words + XCTAssertTrue(recorded.isEmpty, "stem + clitic must not reach G2P") + } + + func testPossessiveVowelFinalStemTakesZ() async throws { + let recorder = FallbackRecorder() + let result = try await makePossessivePhonemizer() + .phonemize("today's") { await recorder.g2p($0) } + XCTAssertEqual(result, "tədˈAz") + let recorded = await recorder.words + XCTAssertTrue(recorded.isEmpty, "stem + clitic must not reach G2P") + } + + func testPossessiveVoicelessStemTakesS() async throws { + // `kˈæt` ends in /t/ — voiceless non-sibilant, so the clitic devoices. + let result = try await makePossessivePhonemizer() + .phonemize("the cat's bowl") { _ in [""] } + XCTAssertTrue(result.contains("kˈæts"), "expected devoiced clitic, got \(result)") + } + + func testPossessiveSibilantStemTakesEpentheticVowel() async throws { + // `bˈɑs` ends in /s/ — the clitic needs the epenthetic `ᵻ` (the US + // form; Misaki uses `ɪ` only in British mode). + let result = try await makePossessivePhonemizer() + .phonemize("the boss's office") { _ in [""] } + XCTAssertTrue(result.contains("bˈɑsᵻz"), "expected `ᵻz` clitic, got \(result)") + } + + func testPossessiveFoldsCurlyApostrophe() async throws { + // U+2019 must fold before the suffix test (issue #774 + this rule). + let result = try await makePossessivePhonemizer() + .phonemize("today\u{2019}s") { _ in [""] } + XCTAssertEqual(result, "tədˈAz") + } + + func testPossessiveIsCaseInsensitiveAndKeepsStemCase() async throws { + let recorder = FallbackRecorder() + // `NASA` is a case-sensitive entry; upper-cased `'S` must still stem, + // and the stem must reach the case-sensitive lexicon. + let result = try await makePossessivePhonemizer() + .phonemize("NASA'S") { await recorder.g2p($0) } + XCTAssertEqual(result, "nˈæsəz") + let recorded = await recorder.words + XCTAssertTrue(recorded.isEmpty, "case-sensitive stem must not reach G2P") + } + + func testPossessiveWithUnknownStemFallsBackToWholeToken() async throws { + let recorder = FallbackRecorder() + // Misaki's `stem_s` only fires on a *known* stem. An OOV stem must + // leave the token on the pre-existing whole-word G2P path rather than + // being re-shaped from a guessed stem. + // `normalizeKey` keeps the apostrophe, so G2P sees the token as written. + let result = try await makePossessivePhonemizer() + .phonemize("zzzyx's") { await recorder.g2p($0) } + XCTAssertEqual(result, "") + let recorded = await recorder.words + XCTAssertEqual(recorded, ["zzzyx's"]) + } + + func testPossessiveOnHyphenatedCompound() async throws { + let recorder = FallbackRecorder() + // The stem resolves through the normal chain, so #775's hyphen split + // still applies underneath the clitic. + let result = try await makePossessivePhonemizer() + .phonemize("mother-in-law's") { await recorder.g2p($0) } + XCTAssertEqual(result, "mˈʌðɜɹ ɪn lˈɔz") + let recorded = await recorder.words + XCTAssertTrue(recorded.isEmpty, "every part is in the lexicon") + } + + func testLexiconEntryStillWinsOverStemming() async throws { + // A glued entry that *is* in the lexicon must be used verbatim; the + // stemming rule only runs after a full lexicon miss. + let phonemizer = KokoroAneEnglishPhonemizer( + wordToPhonemes: possessiveLexicon.merging(["it's": ["ɪ", "t", "s"]]) { _, new in new }, + allowedPunctuation: punctuation + ) + let result = try await phonemizer.phonemize("it's") { _ in [""] } + XCTAssertEqual(result, "ɪts") + } + + func testCliticRuleMatchesMisakiUnderscoreS() { + // Direct port check of Misaki `Lexicon._s`. + for voiceless in ["p", "t", "k", "f", "θ"] { + XCTAssertEqual(KokoroAneEnglishPhonemizer.clitic(after: "ˈɑ" + voiceless), "s") + } + for sibilant in ["s", "z", "ʃ", "ʒ", "ʧ", "ʤ"] { + XCTAssertEqual(KokoroAneEnglishPhonemizer.clitic(after: "ˈɑ" + sibilant), "ᵻz") + } + for other in ["n", "d", "ɹ", "A", "ɔ", "b", "ɡ", "v", "ð", "m", "l", "ŋ"] { + XCTAssertEqual(KokoroAneEnglishPhonemizer.clitic(after: "ˈɑ" + other), "z") + } + } + // MARK: - Without lexicon (pre-#691 behavior preserved) func testEmptyLexiconFallsBackToG2PForEveryWord() async throws { From 03da7bc4a80f90a6714e0cbb337f99bf269cfa29 Mon Sep 17 00:00:00 2001 From: Ankur Mittal Date: Tue, 8 Sep 2026 21:47:01 +0530 Subject: [PATCH 2/3] fix(tts/kokoro-ane): split hyphenated compounds before stemming possessives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `-'s` stem+clitic rule was placed before the hyphenated-compound split in `resolveWord`, which broke lexicon precedence for compounds whose final part has a glued possessive entry of its own. The US lexicon carries 347 glued `-'s` keys, mostly noun/verb heteronyms whose possessive does not read as the bare word plus a clitic: use = jˈuz use's = jˈusᵻz produce = pɹədˈus produce's = pɹˈOdˌusᵻz With the possessive rule first, `land-use's` stemmed to `land-use`, split to `land` + `use`, and picked up the *verb* reading plus a clitic. Running the hyphen split first restores the pre-existing behaviour: parts are `land` + `use's`, and `use's` hits its own entry directly. Same for `fresh-produce's`. Compounds with no glued entry are unaffected, because each part is resolved through the same chain: `mother-in-law's` splits to `mother`, `in`, `law's`; `law's` misses, recurses, and stems to `law` + /z/. Adds regression tests for both heteronym compounds (they fail on the previous ordering) and updates the ordered resolution doc-comment. --- .../English/KokoroAneEnglishPhonemizer.swift | 56 +++++++++++++------ .../KokoroAneEnglishPhonemizerTests.swift | 39 ++++++++++++- 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/Sources/FluidAudio/TTS/KokoroAne/G2P/English/KokoroAneEnglishPhonemizer.swift b/Sources/FluidAudio/TTS/KokoroAne/G2P/English/KokoroAneEnglishPhonemizer.swift index b477c8309..970a6f592 100644 --- a/Sources/FluidAudio/TTS/KokoroAne/G2P/English/KokoroAneEnglishPhonemizer.swift +++ b/Sources/FluidAudio/TTS/KokoroAne/G2P/English/KokoroAneEnglishPhonemizer.swift @@ -16,9 +16,14 @@ import Foundation /// citation form (`tˈO`) that over-stresses them (issue #691) /// 6. strict ASCII all-caps initialisms (`FBI`, `ATP`) spelled as /// letter names after a full lexicon miss (issue #710) -/// 7. `-'s` stem + clitic for possessives whose stem is a known word -/// (`today's` → `today` + /z/), mirroring Misaki's `stem_s` -/// 8. BART G2P CoreML fallback for OOV words (injected by the caller) +/// 7. hyphenated-compound split for a token that missed as a whole +/// (`land-use's` → `land` + `use's`), resolving each part through +/// this same chain (issue #775) +/// 8. `-'s` stem + clitic for possessives whose stem is a known word +/// (`today's` → `today` + /z/), mirroring Misaki's `stem_s`. It runs +/// *after* the hyphen split so a compound's final part can still hit +/// a glued lexicon entry of its own +/// 9. BART G2P CoreML fallback for OOV words (injected by the caller) /// /// Punctuation supported by the chain's `vocab.json` (`, . ! ? ; …` etc.) /// is preserved and attached to the preceding word — Kokoro treats those @@ -170,23 +175,22 @@ struct KokoroAneEnglishPhonemizer: Sendable { return spelled } - // A possessive / `-'s` clitic whose stem is a known word (`today's`, - // `someone's`, `the boss's`). The lexicon stores the clitic `'s` as its - // own entry and carries no glued `today's` key, so these always miss - // above and the whole inflected token goes to BART G2P, which mangles - // it (`someone's` → "Samian's"). Resolve the stem and append the clitic - // by rule instead — same shape as Misaki's `Lexicon.stem_s`. - if let possessive = try await resolvePossessive( - word, lowered: lowered, fallback: fallback) - { - return possessive - } - // A hyphenated compound that missed every lexicon as a whole // (`tales-to-amaze`) — resolve each part and join, so it reads as // `tales to amaze` instead of BART G2P on the glued `talestoamaze` // (issue #775). Real lexicon compounds (`twenty-one`) already returned // above, so only genuine misses reach here. + // + // This runs BEFORE the possessive rule on purpose. The lexicon carries + // ~350 glued `-'s` entries for heteronyms whose possessive does not + // read as the bare word plus a clitic (`use` = `jˈuz` the verb but + // `use's` = `jˈusᵻz` the noun; `produce` = `pɹədˈus` but `produce's` = + // `pɹˈOdˌusᵻz`). Splitting first lets the final part reach its own + // entry (`land-use's` → `land` + `use's`); stemming first would strip + // the `'s`, split the stem, and derive the wrong (verb) reading. The + // stem-and-clitic derivation still handles compounds with no glued + // entry, because each part is resolved through this same chain + // (`mother-in-law's` → `mother` + `in` + `law's` → `law` + /z/). if word.contains("-"), let compound = try await resolveHyphenatedCompound( word, allowFallback: allowFallback, fallback: fallback) @@ -194,6 +198,20 @@ struct KokoroAneEnglishPhonemizer: Sendable { return compound } + // A possessive / `-'s` clitic whose stem is a known word (`today's`, + // `someone's`, `the boss's`). The lexicon stores the clitic `'s` as its + // own entry and has no glued key for ordinary words like `today's`, so + // these miss above and the whole inflected token goes to BART G2P, + // which mangles it (`someone's` → "Samian's"). Resolve the stem and + // append the clitic by rule instead — same shape as Misaki's + // `Lexicon.stem_s`. Glued entries that *do* exist won the lexicon + // lookups above, so this only fires on genuine misses. + if let possessive = try await resolvePossessive( + word, lowered: lowered, fallback: fallback) + { + return possessive + } + guard allowFallback, !normalized.isEmpty else { return nil } do { if let phonemes = try await fallback(normalized), !phonemes.isEmpty { @@ -244,8 +262,12 @@ struct KokoroAneEnglishPhonemizer: Sendable { /// the stem is a known word — so an OOV stem returns `nil` here and the /// caller falls through to whole-token G2P exactly as before. The stem is /// resolved through the normal chain minus the G2P fallback, which keeps - /// custom-lexicon overrides, letter-name spelling and hyphen splitting - /// working for compounds like `mother-in-law's`. + /// custom-lexicon overrides and letter-name spelling working. + /// + /// Hyphenated tokens are split before this rule is reached, so a compound + /// arrives here only one part at a time (`mother-in-law's` → `law's` → + /// `law` + /z/). That ordering keeps a part with its own glued lexicon + /// entry (`land-use's` → `use's`) from being re-derived from its stem. /// /// - Parameters: /// - word: the token as written (apostrophes already folded to ASCII by diff --git a/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneEnglishPhonemizerTests.swift b/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneEnglishPhonemizerTests.swift index 511560292..f351cbacc 100644 --- a/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneEnglishPhonemizerTests.swift +++ b/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneEnglishPhonemizerTests.swift @@ -356,7 +356,9 @@ final class KokoroAneEnglishPhonemizerTests: XCTestCase { /// Lexicon stand-in for the possessive cases. Mirrors the real /// `us_lexicon_cache.json`, which stores the clitic `'s` as its own entry - /// and carries no glued `today's` / `someone's` / `boss's` keys. + /// and carries no glued `today's` / `someone's` / `boss's` keys — but does + /// carry 347 glued `-'s` keys for heteronyms whose possessive reads + /// differently from the bare word (`use's`, `produce's`). private let possessiveLexicon: [String: [String]] = [ "'s": ["z"], "today": ["t", "ə", "d", "ˈ", "A"], @@ -369,6 +371,15 @@ final class KokoroAneEnglishPhonemizerTests: XCTestCase { "law": ["l", "ˈ", "ɔ"], "in": ["ɪ", "n"], "mother": ["m", "ˈ", "ʌ", "ð", "ɜ", "ɹ"], + // Heteronym pairs the real lexicon glues: the bare verb and the noun + // possessive have different vowels/stress, so the glued key is the + // only way to reach the noun reading. + "use": ["j", "ˈ", "u", "z"], + "use's": ["j", "ˈ", "u", "s", "ᵻ", "z"], + "produce": ["p", "ɹ", "ə", "d", "ˈ", "u", "s"], + "produce's": ["p", "ɹ", "ˈ", "O", "d", "ˌ", "u", "s", "ᵻ", "z"], + "land": ["l", "ˈ", "æ", "n", "d"], + "fresh": ["f", "ɹ", "ˈ", "ɛ", "ʃ"], ] private func makePossessivePhonemizer() -> KokoroAneEnglishPhonemizer { @@ -456,6 +467,32 @@ final class KokoroAneEnglishPhonemizerTests: XCTestCase { XCTAssertTrue(recorded.isEmpty, "every part is in the lexicon") } + func testHyphenatedCompoundPartHitsGluedPossessiveEntry() async throws { + let recorder = FallbackRecorder() + // `use` is a verb (`jˈuz`) but `use's` is the noun possessive + // (`jˈusᵻz`) — a heteronym pair the real lexicon spells out. The + // hyphen split has to run *before* the possessive rule so `use's` + // reaches its own entry; stemming first would produce the verb plus a + // clitic (`jˈuzz`-shaped). + let result = try await makePossessivePhonemizer() + .phonemize("land-use's") { await recorder.g2p($0) } + XCTAssertEqual(result, "lˈænd jˈusᵻz") + let recorded = await recorder.words + XCTAssertTrue(recorded.isEmpty, "every part is in the lexicon") + } + + func testHyphenatedCompoundPartKeepsHeteronymStress() async throws { + let recorder = FallbackRecorder() + // Same shape as above with a stress-shifting heteronym: the verb + // `produce` is `pɹədˈus`, the noun possessive `produce's` is + // `pɹˈOdˌusᵻz`. Only the glued entry carries the noun stress. + let result = try await makePossessivePhonemizer() + .phonemize("fresh-produce's") { await recorder.g2p($0) } + XCTAssertEqual(result, "fɹˈɛʃ pɹˈOdˌusᵻz") + let recorded = await recorder.words + XCTAssertTrue(recorded.isEmpty, "every part is in the lexicon") + } + func testLexiconEntryStillWinsOverStemming() async throws { // A glued entry that *is* in the lexicon must be used verbatim; the // stemming rule only runs after a full lexicon miss. From 35ffe11f9c8d1237246e7c20b6d0131d346ad3fd Mon Sep 17 00:00:00 2001 From: Ankur Mittal Date: Thu, 10 Sep 2026 11:50:03 +0530 Subject: [PATCH 3/3] fix(tts/kokoro-ane): preserve whole compound possessive stems --- .../English/KokoroAneEnglishPhonemizer.swift | 94 ++++++++------ .../KokoroAneEnglishPhonemizerTests.swift | 119 +++++++++++++++++- 2 files changed, 175 insertions(+), 38 deletions(-) diff --git a/Sources/FluidAudio/TTS/KokoroAne/G2P/English/KokoroAneEnglishPhonemizer.swift b/Sources/FluidAudio/TTS/KokoroAne/G2P/English/KokoroAneEnglishPhonemizer.swift index 970a6f592..7cf58a948 100644 --- a/Sources/FluidAudio/TTS/KokoroAne/G2P/English/KokoroAneEnglishPhonemizer.swift +++ b/Sources/FluidAudio/TTS/KokoroAne/G2P/English/KokoroAneEnglishPhonemizer.swift @@ -16,14 +16,13 @@ import Foundation /// citation form (`tˈO`) that over-stresses them (issue #691) /// 6. strict ASCII all-caps initialisms (`FBI`, `ATP`) spelled as /// letter names after a full lexicon miss (issue #710) -/// 7. hyphenated-compound split for a token that missed as a whole -/// (`land-use's` → `land` + `use's`), resolving each part through -/// this same chain (issue #775) -/// 8. `-'s` stem + clitic for possessives whose stem is a known word -/// (`today's` → `today` + /z/), mirroring Misaki's `stem_s`. It runs -/// *after* the hyphen split so a compound's final part can still hit -/// a glued lexicon entry of its own -/// 9. BART G2P CoreML fallback for OOV words (injected by the caller) +/// 7. whole-compound possessive stem lookup, using lexicons only +/// (`C-section's` → lexicon `C-section` + /z/) +/// 8. hyphenated-compound split after a whole-stem miss +/// (`land-use's` → `land` + lexicon `use's`) (issue #775) +/// 9. `-'s` stem + clitic for other known stems (`today's` → `today` + /z/), +/// including letter-name initialisms (`FBI's`) +/// 10. BART G2P CoreML fallback for OOV words (injected by the caller) /// /// Punctuation supported by the chain's `vocab.json` (`, . ! ? ; …` etc.) /// is preserved and attached to the preceding word — Kokoro treats those @@ -157,13 +156,8 @@ struct KokoroAneEnglishPhonemizer: Sendable { + "falling back to the bundled pronunciation") } - if let phonemes = caseSensitiveWordToPhonemes[word] - ?? caseSensitiveWordToPhonemes[normalized] - ?? wordToPhonemes[lowered] - ?? wordToPhonemes[normalized], - !phonemes.isEmpty - { - return phonemes.joined() + if let phonemes = lookupMisakiWord(word) { + return phonemes } // After a full lexicon miss, read strict ASCII all-caps tokens of a @@ -175,22 +169,19 @@ struct KokoroAneEnglishPhonemizer: Sendable { return spelled } - // A hyphenated compound that missed every lexicon as a whole - // (`tales-to-amaze`) — resolve each part and join, so it reads as - // `tales to amaze` instead of BART G2P on the glued `talestoamaze` - // (issue #775). Real lexicon compounds (`twenty-one`) already returned - // above, so only genuine misses reach here. - // - // This runs BEFORE the possessive rule on purpose. The lexicon carries - // ~350 glued `-'s` entries for heteronyms whose possessive does not - // read as the bare word plus a clitic (`use` = `jˈuz` the verb but - // `use's` = `jˈusᵻz` the noun; `produce` = `pɹədˈus` but `produce's` = - // `pɹˈOdˌusᵻz`). Splitting first lets the final part reach its own - // entry (`land-use's` → `land` + `use's`); stemming first would strip - // the `'s`, split the stem, and derive the wrong (verb) reading. The - // stem-and-clitic derivation still handles compounds with no glued - // entry, because each part is resolved through this same chain - // (`mother-in-law's` → `mother` + `in` + `law's` → `law` + /z/). + // A known whole-compound stem carries stress and reduced vowels + // that splitting would lose (`mother-in-law's`, `C-section's`). This + // probe must be lexicon-only: recursively resolving `land-use` would + // derive from the verb `use` before its noun-possessive entry `use's` + // gets a chance to match in the component path below. + if let possessive = resolveWholeCompoundPossessive(word, lowered: lowered) { + return possessive + } + + // Whole token and whole possessive stem both missed: resolve parts + // independently, preserving any explicit possessive entry on a part + // (`land-use's` → `land` + `use's`). Ordinary compounds retain #775's + // behavior, including per-part G2P when needed. if word.contains("-"), let compound = try await resolveHyphenatedCompound( word, allowFallback: allowFallback, fallback: fallback) @@ -225,6 +216,40 @@ struct KokoroAneEnglishPhonemizer: Sendable { } } + /// Direct bundled lookup, shared by ordinary words and the whole-stem + /// probe. No initialism spelling, compound splitting, stemming, or G2P. + private func lookupMisakiWord(_ word: String) -> String? { + let normalized = Self.normalizeKey(word) + guard + let phonemes = caseSensitiveWordToPhonemes[word] + ?? caseSensitiveWordToPhonemes[normalized] + ?? wordToPhonemes[word.lowercased()] + ?? wordToPhonemes[normalized], + !phonemes.isEmpty + else { + return nil + } + return phonemes.joined() + } + + /// Try only a whole hyphenated stem's lexicon entries. Non-compound + /// stems keep the existing resolution path (notably `AI`/`US` letter-name + /// overrides), and explicit entries for the inflected token already won. + private func resolveWholeCompoundPossessive(_ word: String, lowered: String) -> String? { + guard word.contains("-"), lowered.hasSuffix("'s") else { return nil } + let stem = String(word.dropLast(2)) + guard !stem.isEmpty, !stem.hasSuffix("'") else { return nil } + guard + let stemIPA = customLexicon[stem] + ?? customLexicon[Self.normalizeKey(stem)] + ?? lookupMisakiWord(stem), + !stemIPA.isEmpty + else { + return nil + } + return stemIPA + Self.clitic(after: stemIPA) + } + /// Resolve a hyphenated compound that missed the lexicon by splitting on /// hyphens and resolving each part, joining the phoneme strings with a /// space (word boundary). Returns `nil` if the token isn't a multi-part @@ -264,10 +289,9 @@ struct KokoroAneEnglishPhonemizer: Sendable { /// resolved through the normal chain minus the G2P fallback, which keeps /// custom-lexicon overrides and letter-name spelling working. /// - /// Hyphenated tokens are split before this rule is reached, so a compound - /// arrives here only one part at a time (`mother-in-law's` → `law's` → - /// `law` + /z/). That ordering keeps a part with its own glued lexicon - /// entry (`land-use's` → `use's`) from being re-derived from its stem. + /// Known whole-compound stems have already returned through the direct + /// lexicon probe. Otherwise the hyphen split gives each part its own + /// lexicon lookup before this derivation (`land-use's` → `use's`). /// /// - Parameters: /// - word: the token as written (apostrophes already folded to ASCII by diff --git a/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneEnglishPhonemizerTests.swift b/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneEnglishPhonemizerTests.swift index f351cbacc..efb135aa0 100644 --- a/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneEnglishPhonemizerTests.swift +++ b/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneEnglishPhonemizerTests.swift @@ -456,10 +456,10 @@ final class KokoroAneEnglishPhonemizerTests: XCTestCase { XCTAssertEqual(recorded, ["zzzyx's"]) } - func testPossessiveOnHyphenatedCompound() async throws { + func testPossessiveOnHyphenatedCompoundWithoutWholeStem() async throws { let recorder = FallbackRecorder() - // The stem resolves through the normal chain, so #775's hyphen split - // still applies underneath the clitic. + // This fixture deliberately lacks the whole `mother-in-law` entry: + // #775's split must still resolve each known component. let result = try await makePossessivePhonemizer() .phonemize("mother-in-law's") { await recorder.g2p($0) } XCTAssertEqual(result, "mˈʌðɜɹ ɪn lˈɔz") @@ -493,6 +493,119 @@ final class KokoroAneEnglishPhonemizerTests: XCTestCase { XCTAssertTrue(recorded.isEmpty, "every part is in the lexicon") } + func testWholeCompoundPossessiveUsesLexiconStemBeforeSplitting() async throws { + // Real Misaki cache entries: whole compounds preserve stress and weak + // vowels that are lost when their components are pronounced separately. + let stems = [ + "C-section": "sˈisˌɛkʃən", + "X-ray": "ˈɛksɹˌA", + "T-shirt": "tˈiʃˌɜɹt", + "well-being": "wˈɛlbˌiɪŋ", + "mother-in-law": "mˈʌðəɹənlˌɔ", + ] + let expected = [ + "C-section": "sˈisˌɛkʃənz", + "X-ray": "ˈɛksɹˌAz", + "T-shirt": "tˈiʃˌɜɹts", + "well-being": "wˈɛlbˌiɪŋz", + "mother-in-law": "mˈʌðəɹənlˌɔz", + ] + let lower = stems.reduce(into: possessiveLexicon) { result, entry in + result[entry.key.lowercased()] = entry.value.map(String.init) + } + let phonemizer = KokoroAneEnglishPhonemizer( + wordToPhonemes: lower, + caseSensitiveWordToPhonemes: caseSensitive, + allowedPunctuation: punctuation + ) + let recorder = FallbackRecorder() + for stem in stems.keys.sorted() { + for suffix in ["'s", "'S", "’s", "ʼs"] { + let result = try await phonemizer.phonemize(stem + suffix) { await recorder.g2p($0) } + XCTAssertEqual(result, expected[stem], stem + suffix) + } + } + let recorded = await recorder.words + XCTAssertTrue(recorded.isEmpty, "whole lexicon stems must never be split or sent to G2P") + } + + func testWholeCompoundPossessivePreservesLexiconPrecedence() async throws { + // Case-sensitive whole stems beat the lower-case and normalized keys. + let phonemizer = KokoroAneEnglishPhonemizer( + wordToPhonemes: ["c-section": ["l", "o"], "csection": ["n", "o"]], + caseSensitiveWordToPhonemes: ["C-section": ["s", "ˈ", "i", "s", "ˌ", "ɛ", "k", "ʃ", "ə", "n"]] + ) + let recorder = FallbackRecorder() + let exact = try await phonemizer.phonemize("C-section's") { await recorder.g2p($0) } + let lower = try await phonemizer.phonemize("c-section's") { await recorder.g2p($0) } + XCTAssertEqual(exact, "sˈisˌɛkʃənz") + XCTAssertEqual(lower, "loz") + let recorded = await recorder.words + XCTAssertTrue(recorded.isEmpty) + } + + func testWholeCompoundPossessiveUsesCustomStemOverride() async throws { + let phonemizer = KokoroAneEnglishPhonemizer( + wordToPhonemes: ["mother-in-law": ["m", "ˈ", "ʌ", "ð", "ə", "ɹ", "ə", "n", "l", "ˌ", "ɔ"]], + customLexicon: ["mother-in-law": "mʌðəɹɪnlɔ"] + ) + let result = try await phonemizer.phonemize("mother-in-law's") { _ in nil } + XCTAssertEqual(result, "mʌðəɹɪnlɔz") + } + + func testWholeCompoundPossessiveKeepsNormalizedCustomLookup() async throws { + let phonemizer = KokoroAneEnglishPhonemizer( + wordToPhonemes: ["c-section": ["l", "o"]], + customLexicon: ["csection": "sɛkʃən"] + ) + let result = try await phonemizer.phonemize("C-section's") { _ in nil } + XCTAssertEqual(result, "sɛkʃənz") + } + + func testExplicitWholeCompoundPossessiveWinsOverStem() async throws { + // The real glued entry has different stress from the stem. It must + // still win even when a custom override exists for the bare stem. + let phonemizer = KokoroAneEnglishPhonemizer( + wordToPhonemes: [ + "re-count": ["ɹ", "ˌ", "i", "k", "ˈ", "W", "n", "t"], + "re-count's": ["ɹ", "ˈ", "i", "k", "ˌ", "W", "n", "t", "s"], + ], + customLexicon: ["re-count": "kWnt"] + ) + let result = try await phonemizer.phonemize("re-count's") { _ in nil } + XCTAssertEqual(result, "ɹˈikˌWnts") + } + + func testCompoundPossessiveMissDoesNotG2PTheBareStem() async throws { + let recorder = FallbackRecorder() + // No whole stem: retain #775's component fallback and the explicit + // noun-possessive entry. Never guess `zzzyx-use` or re-derive `use's`. + let result = try await makePossessivePhonemizer() + .phonemize("zzzyx-use's") { await recorder.g2p($0) } + XCTAssertEqual(result, " jˈusᵻz") + let recorded = await recorder.words + XCTAssertEqual(recorded, ["zzzyx"]) + } + + func testCompoundPossessiveWithUnknownFinalStemKeepsInflectedFallback() async throws { + let recorder = FallbackRecorder() + let result = try await makePossessivePhonemizer() + .phonemize("land-zzzyx's") { await recorder.g2p($0) } + XCTAssertEqual(result, "lˈænd ") + let recorded = await recorder.words + XCTAssertEqual(recorded, ["zzzyx's"]) + } + + func testPossessiveInitialismsKeepLetterNameRules() async throws { + let recorder = FallbackRecorder() + for (word, expected) in [("AI's", "ˈA ˈIz"), ("US's", "jˈu ˈɛsᵻz"), ("FBI's", "ˈɛf bˈi ˈIz")] { + let result = try await makePossessivePhonemizer().phonemize(word) { await recorder.g2p($0) } + XCTAssertEqual(result, expected) + } + let recorded = await recorder.words + XCTAssertTrue(recorded.isEmpty) + } + func testLexiconEntryStillWinsOverStemming() async throws { // A glued entry that *is* in the lexicon must be used verbatim; the // stemming rule only runs after a full lexicon miss.