Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 20 additions & 24 deletions src/languages/japanese/japanese.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,46 +29,42 @@ const TOKEN =
/([㐀-鿿\u{20000}-\u{2a6df}々〻ぁ-ゖァ-ヺー゛゜]+)|(\d+)|([。..!!??、,,])/gu;
const KANA_ONLY = /[^ぁ-ゖァ-ヺー]/gu; // strip anything the reading pass left un-converted (unresolved kanji)

// #562 symbol normalization — Japanese: katakana loans, read by the ordinary kana engine.
// Katakana loans, read by the ordinary kana engine.
// UNITS moved to normalize.ts, which must resolve them before its decimal and exponent rules break the
// number-adjacency this tier matches on; see UNIT_KANA there. Percent stays, because nothing reorders it.
// Currency closes the CUR-DROP that tools/normalization/audit.ts flags for ja: the sign was dropped
// outright, so "$5" and "5" read identically. No sign occurs in this corpus (it writes 円 and ドル as
// words), but the reading is not in doubt, and a dropped sign is silent content loss wherever one does.
// ⚠ Without a currency declaration the sign is DROPPED outright, so "$5" and "5" read identically. Japanese
// prose normally writes 円 and ドル as words rather than using a sign, but the reading is not in doubt and a
// dropped sign is silent content loss wherever one appears.
const SYMBOLS = makeSymbolNormalizer({
// #586 `multiply` — this language's OWN word, harvested from its existing `×` rule, so nothing new is
// sourced. Declaring it here is what makes ASCII `x` read like `×`: `6x6 cm` read the `x` as a LETTER NAME,
// and `NxN` forms outnumber `×` roughly 85 to 20 across the corpora. One word, so `by` defaults to it.
// ⚠ Declaring `multiply` HERE is what makes ASCII `x` read like `×`: otherwise `6x6 cm` reads the `x` as a
// LETTER NAME, and `NxN` is the commoner written form. One word, so `by` defaults to it.
multiply: { times: "かける" },
percent: ["パーセント"],
// `&` was DROPPED: the corpus's `高級B&Bが…` read *ko̞ːkʲɯː biː biː ɡa*, two initialisms run together
// with nothing between them. SOURCED FROM THE CORPUS'S OWN AUDIO, because the word is absent from the text
// by construction — `&` is written as a GLYPH, so no amount of Japanese prose contains its reading.
// ⚠ Unread, `&` is DROPPED`高級B&Bが…` reads *ko̞ːkʲɯː biː biː ɡa*, two initialisms run together with
// nothing between them. The reading cannot come from text: `&` is written as a GLYPH, so no amount of
// Japanese prose contains it.
ampersand: "アンド",
// MIGRATION TEST: units + exponent from the shared tier instead of normalize.ts's local table.
// Units and exponent come from the shared tier rather than a local table.
units: {
km: ["キロメートル"], cm: ["センチメートル"], mm: ["ミリメートル"], nm: ["ナノメートル"], m: ["メートル"],
kg: ["キログラム"], mg: ["ミリグラム"], g: ["グラム"], t: ["トン"], ha: ["ヘクタール"],
ml: ["ミリリットル"], l: ["リットル"],
},
exponentWords: { squared: ["平方"], cubed: ["立方"], position: "compound" },
// #586 BARE EXPONENT — the reading for a power with NO unit to modify (`20²`, `mc²`), which every language
// BARE EXPONENT — the reading for a power with NO unit to modify (`20²`, `mc²`), which every language
// in the fleet was dropping silently. See `bareExponent` in core/normalizeSymbols.ts for why this cannot
// reuse `exponentWords` above: that is the unit MODIFIER and this is the PREDICATE, and in most languages
// they are different words (平方キロメートル but 二十の二乗).
// ⚠ PROVENANCE, stated because it is weaker than most data in this repo: these are STANDARD MATHEMATICAL
// REGISTER, not corpus attestations. The power words are ×0 in this language's artifact, and the apparent
// hits for other languages were substring traps of exactly the kind tools/normalization/attest.ts warns
// about — th `กำลัง` matched the progressive-aspect marker, fa `توان` and ar `أس` matched inside unrelated
// words. FLEURS is news and encyclopedia prose and simply does not contain spoken arithmetic.
// The cardinal is used for the generic power, never the ordinal — see core for that argument.
// ⚠ PROVENANCE, stated because it is weaker than most data here: these are STANDARD MATHEMATICAL REGISTER,
// not attestations. Power words do not occur in ordinary prose — news and encyclopedia text contains no
// spoken arithmetic — and the apparent hits in other languages are substring traps (th `กำลัง` is the
// progressive-aspect marker; fa `توان` and ar `أس` match inside unrelated words).
// The cardinal is used for the generic power, never the ordinal.
bareExponent: { squared: "{n}の二乗", cubed: "{n}の三乗", power: "{n}の{e}乗" , negative: "マイナス" },
currency: { $: ["ドル"], "€": ["ユーロ"], "£": ["ポンド"], "¥": ["円"], "₩": ["ウォン"] },
// Japanese has no spaces either, so the tier's letter-boundary guards were rejecting its ordinary case
// the same way they were Chinese's: `20℃は暑い` dropped the ℃ and `50 km²の` lost the exponent, while their
// punctuation-adjacent twins worked. Found by extending the cmn measurement to the other unspaced
// scripts. yue and th show the same readings but declare only `percent` through this tier, so theirs is a
// missing DECLARATION rather than a guard problem — recorded for the sweep, not fixed here.
// ⚠ THE TIER'S LETTER-BOUNDARY GUARDS REJECT AN UNSPACED SCRIPT'S ORDINARY CASE: `20℃は暑い` drops the ℃
// and `50 km²の` loses the exponent, while their punctuation-adjacent twins work. `unspacedScript` is what
// turns those guards off.
unspacedScript: true,
});

Expand Down Expand Up @@ -98,7 +94,7 @@ class JapanesePhonemizer implements Phonemizer {
// segmentText inserts bunsetsu spaces first, then assembleClauses runs the standard clause skeleton.
return assembleClauses(segmentText(input), TOKEN, (m, sink) => {
if (m[1]) {
const segments = readingSegments(m[1]); // kanji → kana per morpheme (boundaries kept, #552)
const segments = readingSegments(m[1]); // kanji → kana per morpheme (boundaries kept)
const reading = segments.join("");
const morae = segmentsToMorae(segments);
if (morae)
Expand Down
6 changes: 3 additions & 3 deletions src/languages/japanese/kana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export function kanaToMorae(word: string): string[] | null {

/**
* Sokuon っ geminates the FOLLOWING mora's initial consonant. Like the ん pass below, this is split out so it
* can run again over CONCATENATED segments: per-morpheme conversion (segmentsToMorae, #552) leaves a
* can run again over CONCATENATED segments: per-morpheme conversion (segmentsToMorae) leaves a
* segment-final っ unable to see the next segment's onset, so it fell back to the glottal ʔ — 吹っ切れ came
* out ɸɯᵝʔkiɾe̞ instead of ɸɯᵝkkiɾe̞. Idempotent: an already-geminated mora is no longer "ʔ".
*/
Expand All @@ -139,7 +139,7 @@ export function geminateSokuon(morae: string[]): string[] {
* else ɴ (before vowels/glides/fricatives or word-finally): こんにちは→ko̞nni…, にほんご→niho̞ŋɡo̞, さんぽ→sampo̞.
*
* Split out so it can run a second time over CONCATENATED segments: when a word is moraised per morpheme
* (segmentsToMorae, #552), a ん ending one segment cannot see the next segment's onset, and 健康 けん|こう
* (segmentsToMorae), a ん ending one segment cannot see the next segment's onset, and 健康 けん|こう
* came out ke̞ɴko̞ː instead of ke̞ŋko̞ː. Re-running over the joined morae fixes that, and is idempotent —
* an already-assimilated mora is no longer "ɴ", so the loop skips it.
*/
Expand Down Expand Up @@ -168,7 +168,7 @@ export function kanaToIpa(word: string): string | null {
*
* Long-vowel coalescence is confined to a segment, so the first vowel of one morpheme can never be absorbed
* into the previous morpheme's length: 経営 けい|えい → ke̞ːe̞ː (not ke̞ːːː), 聖域 せい|いき → se̞ːiki (not
* se̞ːːki), 子牛 こ|うし → ko̞ɯᵝɕi (not ko̞ːɕi). Issue #552.
* se̞ːːki), 子牛 こ|うし → ko̞ɯᵝɕi (not ko̞ːɕi).
*
* The mora COUNT is unchanged by this — a coalesced ː was already one mora — so accent-nucleus indices from
* the pitch dictionary keep pointing at the same mora. Only the vowel QUALITY is corrected.
Expand Down
8 changes: 4 additions & 4 deletions src/languages/japanese/kanji.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ function longestKeyMatch(
*
* The segmentation matters downstream: long-vowel coalescence (kanaToMorae) must not run ACROSS a reading
* boundary, or the next morpheme's initial vowel is absorbed into the previous one's length — 経営 けい|えい
* became ke̞ːːː instead of ke̞ːe̞ː, 聖域 せい|いき became se̞ːːki (issue #552). Flattening to one string here
* became ke̞ːːː instead of ke̞ːe̞ː, 聖域 せい|いき became se̞ːːki. Flattening to one string here
* destroyed the only evidence of where a morpheme ended. A literal-kana RUN stays one segment so genuine
* within-run coalescence still fires (おおさか → o̞ːsäkä). */
/**
Expand All @@ -90,7 +90,7 @@ function longestKeyMatch(
* the split けい|えい is recoverable and provable — the concatenation reproduces the stored reading exactly.
* Returns null when NO alignment exists, which is the conservative and correct outcome for a compound whose
* reading is not the sum of its parts (大人 おとな, 今日 きょう) and for one that genuinely coalesces across the
* boundary (小売 こうり — 売 has no reading うり, so it stays fused and keeps giving koːri). Issue #552.
* boundary (小売 こうり — 売 has no reading うり, so it stays fused and keeps giving koːri).
*/
function alignCompoundReading(
unit: string,
Expand Down Expand Up @@ -296,7 +296,7 @@ export function segmentText(text: string): string {
// とうきょう え). を is already handled in kana.ts; が/を/に pass through unchanged (unambiguous kana). は/へ
// that START a dictionary word (はな, へや) are matched as a ≥2-mora unit above, so single-char は/へ after
// content is the particle. が/を/に keep the stricter isKanji(prev) gate the segmenter already relied on.
// の/と/も/や/で joined the single-particle set for #552's residual: they are the O/E/A-vowel carriers
// の/と/も/や/で joined the single-particle set for the residual: they are the O/E/A-vowel carriers
// whose kana can trigger long-vowel coalescence across the bunsetsu boundary when left fused — 東京のうち
// read のう as [noː] instead of の うち. Safe under the isKanji(prev) gate: no verb okurigana begins with
// の/と/も/や, and the て-form で (飲んで) is preceded by ん (kana), which the gate excludes.
Expand All @@ -314,7 +314,7 @@ export function segmentText(text: string): string {
((unit === "は" || unit === "へ") &&
// ⚠ A DIGIT COUNTS AS A CONTENT WORD HERE. `7は` is the topic particle just as `私は`
// is, but the gate tested only kanji and kana, so the は stayed /ha/ — 「7は3より小さい」
// read *nana ha* instead of *nana wa*. Found by #654's relational rule, which builds
// read *nana ha* instead of *nana wa*. Found by the relational rule, which builds
// exactly that clause, and pre-existing for any text that topic-marks a bare numeral.
// Safe to widen: a counter written with hiragana は immediately after a digit would be
// a ≥2-mora unit and is matched before this branch, so single は after a digit is the
Expand Down
58 changes: 24 additions & 34 deletions src/languages/japanese/normalize.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,33 @@
/**
* Japanese (ja) TEXT NORMALIZATION — the pre-tokenizer pass that rewrites everything which is not already
* readable by the kana engine into kana/kanji the existing pipeline speaks. Pure text→text; no IPA.
* Japanese (ja) text normalization — the pre-tokenizer pass that rewrites everything which is not already
* readable by the kana engine into kana/kanji the pipeline speaks. Pure text→text; no IPA.
*
* Thirteenth language, and the one where the layer's job is DIFFERENT from the previous twelve. Japanese
* already had the strongest number path of any engine here — counters (1本→いっぽん), dates (1999年3月14日),
* ordinals (第3回), 世紀, percent — all correct before this file existed. What it did NOT have was a way to
* keep foreign and symbolic text INSIDE the Japanese phoneme inventory.
* ⚠ THIS LAYER'S JOB IS DIFFERENT FROM ITS COUNTERPARTS IN OTHER LANGUAGES. Japanese already has a strong
* number path — counters (1本→いっぽん), dates, ordinals (第3回), 世紀, percent. What it lacks is a way to keep
* foreign and symbolic text INSIDE the Japanese phoneme inventory.
*
* THE DEFECT THAT DOMINATES: embedded Latin routes to the ENGLISH phonemizer (core/foreign.ts — a good
* default for a Cyrillic or Devanagari engine, which would otherwise DROP it). In a Japanese IPA stream it
* is worse than a drop, because it injects phonemes Japanese does not have: `NASA` → [nˈæsə], `WHO` →
* [dˈʌbəɫjuː ˈeᶦt͡ʃ ˈoᶷ], `SNS` → [ˈɛs ˈɛn ˈɛs]. That is æ, ʌ, ɫ, t͡ʃ, oᶷ inside an utterance whose whole
* inventory is the gojūon. A Japanese speaker reads an initialism as its KATAKANA LETTER NAMES — FBI is
* エフビーアイ — so that is what this file emits, and the ordinary kana engine then speaks it natively.
* ⚠ EMBEDDED LATIN ROUTES TO THE ENGLISH PHONEMIZER, WHICH IS WORSE THAN A DROP HERE. A good default for a
* Cyrillic or Devanagari engine that would otherwise lose the run, it injects phonemes Japanese does not
* have: `NASA` → [nˈæsə], `WHO` → [dˈʌbəɫjuː ˈeᶦt͡ʃ ˈoᶷ]. That is æ, ʌ, ɫ, t͡ʃ, oᶷ inside an utterance whose
* whole inventory is the gojūon. A Japanese speaker reads an initialism as its KATAKANA LETTER NAMES — FBI is
* エフビーアイ — so that is what this file emits, and the kana engine then speaks it natively.
*
* LETTER-SPELLING IS THE DEFAULT, NOT A GUESS. It is always an available Japanese reading for an
* initialism, so an unknown all-caps run is safe to spell. The acronyms read as WORDS (NATO ナトー, UNESCO
* ユネスコ) are lexical facts, and live in a short list of ones that are actually established — not
* invented for the corpus's long tail. This is the same lexical-vs-OOV split core/initialisms.ts settled on
* for the Latin-script languages, with the polarity flipped: there the default is to leave the token alone,
* which here is not an option.
* ⚠ LETTER-SPELLING IS THE DEFAULT, NOT A GUESS: it is always an available Japanese reading, so an unknown
* all-caps run is safe to spell. The acronyms read as WORDS (NATO ナトー, UNESCO ユネスコ) are LEXICAL facts and
* live in a short list of established ones. Same lexical-vs-OOV split core/initialisms.ts uses for the
* Latin-script languages, with the polarity flipped — there the default is to leave the token alone, which
* here is not an option.
*
* MIXED-CASE Latin (Apple ×9, Skype ×3, iPhone ×3, ZMapp ×4) is deliberately NOT converted. Those are
* loanwords whose katakana is lexical and unguessable from spelling (Appleアップル, not アプレ), so they
* keep the English fallback until there is a sourced loanword lexicon. `pH` ×9 is listed because it is an
* initialism that merely happens to carry a lowercase letter.
* MIXED-CASE LATIN IS DELIBERATELY NOT CONVERTED (Apple, Skype, iPhone, ZMapp). Those are loanwords whose
* katakana is lexical and unguessable from spelling Apple is アップル, not アプレso they keep the English
* fallback until there is a sourced loanword lexicon. `pH` is listed only because it is an initialism that
* happens to carry a lowercase letter.
*
* Measured over the ja_jp corpus (3,208 utterances): ・ ×302 (already correct — a bunsetsu space), 年 ×242,
* all-caps Latin ×208 instances / 83 distinct, counters ×169, 月 ×84, 単位 after a digit ×64, 分の ×28 (see
* below), comma-grouped thousands ×56, 約 ×51, 日 ×49, 時 ×40, ranges 〜/~ ×37, percent ×30, 第N ×17,
* decimals ×16, single-letter Latin ×66, clock ×3, ℃ ×2, km² ×1.
*
* THE 分の BUG, found while probing this layer and fixed here. `3分の1` read as [sämpɯᵝnno̞ it͡ɕi] —
* さん*ぷん*の, "three MINUTES of". The counter fusion in japanese.ts sees `3` + 分 and applies the 分 =
* minutes reading, which is right for 3分 and wrong for 3分の1, where 分 is ぶん. Rewriting to katakana
* ブンノ takes the kanji out of the fusion rule's reach, which is the smallest fix that cannot regress the
* genuine minutes counter — but ONLY between two digits. Tabulating the 26 分の in the corpus is what made
* that condition non-negotiable: just 5 are fractions, 12 are 自分の ("one's own"), and one is 7時30分の,
* where ふん is correct. A blanket rewrite corrupted all thirteen.
* ⚠ THE 分の TRAP. `3分の1` reads [sämpɯᵝnno̞ it͡ɕi] — さん*ぷん*の, "three MINUTES of" — because the counter
* fusion in japanese.ts sees `3` + 分 and applies the minutes reading, which is right for 3分 and wrong here,
* where 分 is ぶん. Rewriting to katakana ブンノ takes the kanji out of the fusion rule's reach.
* ⚠ BUT ONLY BETWEEN TWO DIGITS. Most 分の in running text is 自分の ("one's own"), and 7時30分の is a genuine
* ふん — a blanket rewrite corrupts every one of them.
*/
import { MANIFEST } from "./manifest.ts";

Expand Down
7 changes: 3 additions & 4 deletions src/languages/javanese/javanese.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,9 @@ const TOKEN = new RegExp(
"gu",
);
/**
* This language's OWN inventory — the TOKEN word class as it stood before the widening above, lifted verbatim, so
* nothing about the orthography is invented here. A token this REJECTS carries a letter the language does not
* use, i.e. a foreign name. See core/hostWord.ts: this is the INVENTORY question, and it is no longer also
* deciding where the script boundary falls.
* This language's OWN inventory. ⚠ TWO DIFFERENT QUESTIONS, KEPT APART: the TOKEN class above decides where the
* SCRIPT boundary falls (routing), while this one decides whether the g2p has rules for these letters. A token
* this class REJECTS carries a letter the language does not use — i.e. a foreign name. See core/hostWord.ts.
*/
const NATIVE_CLASS = "[a-zA-ZéèêÉÈÊ]";
const nat = makeNativiser(NATIVE_CLASS, "u");
Expand Down
2 changes: 1 addition & 1 deletion src/languages/jin/jin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* (segmental IPA + a superscript pitch-number tone), so the front-end is the shared Han-dict engine
* (hanDictIpa.ts): greedy longest-match segmentation, superscript-tone → Chao contour letters (taking the
* SURFACE tone after a sandhi arrow ⁻), Han numerals. SINGLE authoritative source (Wiktionary/kaikki Taiyuan
* Sinological-IPA), no independent referee → 🔷.
* Sinological-IPA). ⚠ SINGLE-SOURCE: no independent referee, so nothing here is cross-checked.
*/
import type { Phonemizer } from "../../registry.ts";
import { loadManifest } from "../../core/loadManifest.ts";
Expand Down
Loading