phonemize
Version:
Fast phonemizer with rule-based G2P prediction. Pure JavaScript implementation.
212 lines (211 loc) • 9.49 kB
TypeScript
/**
* Per-language Processor abstraction.
*
* A `LanguageProcessor` bundles the work needed for one language:
* text normalization (`preProcess`) and grapheme-to-phoneme prediction
* (`predict`). The registry below dispatches by BCP 47 language tag with
* script-based fallback.
*
* The exported `languageRegistry` and free functions wrap a default global
* registry to preserve the simple `phonemize("hello")` API. To create
* isolated registrations (so multiple language sets can coexist without
* stepping on each other) use `new LanguageRegistry()` directly or the
* higher-level `createPhonemizer()` factory in `core.ts`.
*/
/**
* A language-bound processor: text normalization plus grapheme-to-phoneme
* prediction. Each implementation handles a single language's full
* preProcess → predict pipeline; the registry dispatches per-segment.
*/
export interface LanguageProcessor {
/**
* Unique identifier for this processor
*/
readonly id: string;
/**
* Human-readable name for this processor
*/
readonly name: string;
/**
* Languages this processor can handle. Use BCP 47-style tags; the
* registry treats `en` as a parent of `en-US`/`en-GB` etc., so a
* processor that lists `["en"]` will be selected for `en-GB` requests
* unless a more specific processor is also registered.
*/
readonly supportedLanguages: string[];
/**
* Optional language-specific text normalization. Run before tokenization
* on a slice of text identified as this processor's language; should
* expand numbers, abbreviations, currency, dates, etc. into spoken form.
*
* Processors without language-specific normalization can leave this
* undefined — the tokenizer treats the absence as a no-op.
*/
preProcess?(text: string): string;
/**
* Optional per-token POS tag (mostly for homograph disambiguation).
* The tokenizer routes each token to the matching processor's
* `tagWord` (if any), then passes the returned `pos` into `predict`.
* Context is the (optional) immediately neighboring tokens in original
* surface form so taggers that need local cues — preceding determiner,
* following particle, etc. — have access to them.
*
* Implementations that don't model POS should leave this undefined;
* `predict` will then receive `undefined` for the `pos` argument.
*/
tagWord?(word: string, context?: {
prev?: string;
next?: string;
}): {
pos: string;
confidence: number;
} | null;
/**
* Predict phonemes for a given word
*
* @param word - Word to convert to phonemes
* @param language - Language code (optional, for disambiguation)
* @param pos - Part of speech (optional, for homograph disambiguation)
* @returns Phoneme string in IPA format, or null if cannot process
*/
predict(word: string, language?: string, pos?: string): string | null;
/**
* Add a custom pronunciation for a word
*
* @param word - Word to add pronunciation for
* @param pronunciation - IPA pronunciation string
*/
addPronunciation(word: string, pronunciation: string): void;
}
/**
* Return the primary language subtag — `en-GB` → `en`, `zh-Hant-TW` → `zh`.
*/
export declare function primaryLang(tag: string): string;
/**
* Normalize a BCP 47 tag for comparison. The spec defines tag matching
* as case-insensitive, so we lowercase before any equality check.
*/
export declare function normalizeTag(tag: string): string;
export declare class LanguageRegistry {
private processors;
/** Insertion order for stable "first registered wins" semantics. */
private order;
register(processor: LanguageProcessor): void;
unregister(id: string): boolean;
getProcessor(id: string): LanguageProcessor | undefined;
getProcessorsForLanguage(language: string): LanguageProcessor[];
getAllProcessors(): LanguageProcessor[];
/**
* Find the best processor for a given word and language. When a
* language is provided, dialect-exact processors are tried first,
* then parent-tag processors. With no language we fall back to the
* first registered processor.
*/
findBestProcessor(_word: string, language?: string): LanguageProcessor | null;
predictPhonemes(word: string, language?: string, pos?: string): string | null;
clear(): void;
getSupportedLanguages(): string[];
}
export declare const languageRegistry: LanguageRegistry;
/**
* Detect the language of the given text based on Unicode character ranges
*
* @param text - Text to detect language for
* @returns Language code or null if not detected
*/
export declare function detectLanguage(text: string): string | null;
/**
* Whole-text analysis: identifies the dominant language by character share
* and resolves the CJK Han ambiguity (`zh` vs `ja`).
*
* Han routing heuristic — only **hiragana cluster count** drives the
* zh/ja decision:
*
* `hanIsJa` is true when the text has **two or more** separate
* hiragana clusters — i.e. multiple grammatical particles or verb
* inflections (は・を・が・で・に・ます・です・ている・…) interleaved
* with non-hiragana chars, which is the structural signature of
* Japanese prose.
*
* Why hiragana cluster *count* rather than length or katakana:
*
* - Taiwan-Chinese text routinely embeds Japanese *loanwords* —
* usually katakana (ラーメン, コーヒー, ドラマ) but sometimes
* hiragana for food (うどん, おでん, やきとり). These appear as
* one isolated kana cluster surrounded by Han, so length-based
* rules misfire ("max contiguous hira ≥ 2" would flip `我超愛うどん`
* to ja). Counting *separate* clusters skips this class entirely:
* a single loanword contributes one cluster.
*
* - Decorative single hiragana — overwhelmingly `の`, which academic
* surveys (Karen S. Chung, *Some Returned Loans*) treat as
* functionally identical to 之/的 in Taiwan Mandarin — also contribute
* one cluster, so they stay below the threshold.
*
* - Real Japanese sentences almost always contain two or more
* separate hiragana clusters (subject-marker は + verb ending ます,
* or any combination of particles). Even short ones like 田中さんは
* trip the rule via `さん` + `は`.
*
* Trade-offs:
* - Single-particle Japanese fragments (`今日は`, `頑張って`) and
* single-word Japanese (`私の本`) won't trip the flag — accepted
* as out-of-context ambiguity that needs `options.language: "ja"`
* to disambiguate.
* - Pure-katakana brand names (`スターバックス`) and kana-only
* words (`ねこ`, `こんにちは`) don't need hanIsJa anyway: they
* carry no Han to mis-dispatch, and the character-share count
* below still puts `ja` on top so neutral runs route correctly.
*
* Primary language is the bucket with the most characters after han→ja
* reassignment, or undefined when the text has no non-neutral chars at all.
*/
export interface TextAnalysis {
/** Dominant language by character share; undefined for pure-neutral text. */
primary?: string;
/** When true, Han chars in this text should be routed as Japanese. */
hanIsJa: boolean;
}
export declare function analyzeText(text: string): TextAnalysis;
export interface ScriptRun {
text: string;
/** Detected language code; empty string when the run contains only neutrals. */
lang: string;
}
/**
* Split text into script-based runs. Neutral characters (digits, whitespace,
* punctuation) attach to the surrounding non-neutral run instead of starting
* a new one, so e.g. `"我有 3 本书"` stays one Chinese run rather than
* fragmenting into `[zh, en-digit, zh]`.
*
* Pass `hanIsJa: true` (typically from a prior `analyzeText` call) to route
* Han chars through the Japanese path instead of the default Chinese path —
* essential for Japanese prose, which mixes kanji with kana.
*/
export declare function splitByScript(text: string, options?: {
hanIsJa?: boolean;
}): ScriptRun[];
/**
* Run each script-run through its language processor's `preProcess`.
*
* Resolution order for each run's effective language:
* 1. The run's own detected script (zh/ja/ko/…)
* 2. The caller-supplied `defaultLang` (e.g. `options.language`)
* 3. The document's primary language inferred by `analyzeText`
*
* Step 3 ensures that pure-neutral inputs like `"123"` get the right
* expansion when the surrounding context (or another part of the same
* call) is non-English.
*/
export declare function preProcessByScript(text: string, registry: LanguageRegistry, defaultLang?: string): string;
/**
* Register a language processor on the default global registry.
*
* For multi-instance setups, prefer `createPhonemizer()` from `./core`.
*/
export declare function useProcessor(processor: LanguageProcessor): void;
export declare function findProcessor(word: string, language?: string): LanguageProcessor | null;
export declare function predictPhonemes(word: string, language?: string, pos?: string): string | null;
export declare function getRegisteredProcessorIds(): string[];
export declare function getProcessorsForLanguage(language: string): LanguageProcessor[];
export declare function getSupportedLanguages(): string[];