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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,22 @@ cargo run -p floravox-cli -- g2p --phonetisaurus cmudict-20170708.o8.fst \

The Phonetisaurus decoder is a clean-room Rust implementation of the OpenFst file format plus a shortest-path search. Both layouts load (embedded symbol tables, or `model.fst` plus separate table files), and 16-byte and 20-byte arc encodings are detected automatically.

## P2G: turning phonemes back into words

The G2P tiers are direction-symmetric, so the same tools spell pronunciations — including made-up ones ("moo", "eek"):

- **Tier 1 — inverted lexicon** (`floravox-fst-compile --reverse LEXICON.tsv en-p2g`): exact homophone sets for in-lexicon pronunciations. `/ɹ ˈaɪ t/` → right, write, wright, rite (+ reit — whatever the corpus really contains).
- **Tier 2 — reversed WFST** (`floravox-train-phonetisaurus --reverse LEXICON.tsv en-p2g.fst`): generalizes to unseen phoneme strings through the same segment-alignment statistics. Measured on the published en bundle (124k entries): 39% exact / 41% with homophone credit / CER 40% / 99% coverage — at published Phonetisaurus-P2G baselines. Strong on medium/long words, weak on very short ones.
- **Tier 3 — ByT5 P2G** (optional, `onnx` feature): any byte-level phoneme→orthography checkpoint (e.g. a PolyIPA export) as the final fallback; it knows the whole IPA, so it can spell phonemes the WFST never saw.

```console
# chain all three tiers (first non-empty candidate list wins):
cargo run -p floravox-g2p --bin floravox-p2g -- --lexicon en-lex-p2g --model en-p2g.fst --byt5 polyipa-onnx/ "ʃ ˈi k"
# sheek
```

In Rust, the tiers implement the `P2g` trait (`floravox_g2p::p2g`) and chain with `ChainedP2g`, mirroring the G2P fallback chain. Note the phoneme strings must match the training lexicon's conventions (the en bundle carries gruut-style stress marks: `k ˈæ t`, not `k æ t`).

## How accurate is it?

Two audit tools ship in `python/`:
Expand Down
45 changes: 41 additions & 4 deletions crates/floravox-g2p/src/bin/floravox-fst-compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@
//!
//! Usage:
//! ```text
//! floravox-fst-compile [--format FMT] INPUT OUTPUT_STEM
//! floravox-fst-compile [--format FMT] [--reverse] INPUT OUTPUT_STEM
//! ```
//!
//! `--reverse` inverts the mapping (P2G): the output stem holds
//! phoneme-string keys → candidate words. Homophones are merged under
//! one key (space-separated, first-seen order), so /ɹ aɪ t/ →
//! "right write wright rite". Keys keep their exact case (IPA is
//! case-significant).
//!
//! FMT: auto (default) | cmudict | ipa-tsv | tsv
//!
//! * cmudict — `WORD P HH R AH1 N` (converted ARPABET → IPA)
Expand All @@ -20,12 +26,33 @@ use std::io::Read;
use floravox_g2p::{ingest, SourceFormat};

fn usage_exit() -> ! {
eprintln!("usage: floravox-fst-compile [--format auto|cmudict|ipa-tsv|tsv] INPUT OUTPUT_STEM");
eprintln!(
"usage: floravox-fst-compile [--format auto|cmudict|ipa-tsv|tsv] [--reverse] INPUT OUTPUT_STEM"
);
std::process::exit(2);
}

/// Invert (word, phonemes) rows into (phonemes, homophone words) rows,
/// merging duplicates in first-seen order.
fn reverse_rows(rows: Vec<(String, String)>) -> Vec<(String, String)> {
use std::collections::BTreeMap;
let mut merged: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (word, phones) in rows {
let key = phones.split_whitespace().collect::<Vec<_>>().join(" ");
let entry = merged.entry(key).or_default();
if !entry.contains(&word) {
entry.push(word);
}
}
merged
.into_iter()
.map(|(k, words)| (k, words.join(" ")))
.collect()
}

fn main() {
let mut format_arg: Option<String> = None;
let mut reverse = false;
let mut positional: Vec<String> = Vec::new();
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
Expand All @@ -36,6 +63,8 @@ fn main() {
};
} else if let Some(value) = arg.strip_prefix("--format=") {
format_arg = Some(value.to_string());
} else if arg == "--reverse" {
reverse = true;
} else {
positional.push(arg);
}
Expand Down Expand Up @@ -79,9 +108,17 @@ fn main() {
);
}

match floravox_g2p::LexiconWriter::new(&stem).write(ingested.rows) {
let rows = if reverse {
reverse_rows(ingested.rows)
} else {
ingested.rows
};
match floravox_g2p::LexiconWriter::new(&stem).write(rows) {
Ok(count) => {
println!("wrote {stem}.fst + {stem}.pho ({count} entries, format {format:?})");
let direction = if reverse { "p2g" } else { "g2p" };
println!(
"wrote {stem}.fst + {stem}.pho ({count} entries, format {format:?}, {direction})"
);
}
Err(e) => {
eprintln!("compile failed: {e}");
Expand Down
131 changes: 131 additions & 0 deletions crates/floravox-g2p/src/bin/floravox-p2g.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//! Spell a pronunciation: phoneme-to-grapheme across the floravox tiers.
//!
//! Usage:
//! floravox-p2g [OPTIONS] PHONEMES...
//!
//! PHONEMES are whitespace-separated symbols (`floravox-p2g m uː`) or a
//! single slashed argument (`floravox-p2g /ɹ aɪ t/`).
//!
//! Tiers (first non-empty candidate list wins, exactly like the G2P
//! chain):
//! --lexicon STEM inverted FST lexicon (floravox-fst-compile --reverse)
//! --model STEM reversed WFST (floravox-train-phonetisaurus --reverse)
//! --byt5 DIR ByT5/PolyIPA ONNX export (encoder+decoder)
//!
//! Candidates print one per line (lexicon homophones keep corpus order;
//! model/byt5 tiers contribute their 1-best). Exit code 0 even when
//! nothing matched (empty output); 2 on usage/config errors.
//!
//! ```text
//! $ floravox-p2g --lexicon cmudict-p2g --model en-p2g "ɹ aɪ t"
//! right
//! write
//! wright
//! rite
//! ```

#[cfg(feature = "onnx")]
use floravox_g2p::p2g::ByT5P2g;
use floravox_g2p::p2g::{ChainedP2g, P2g, P2gLexicon};
use floravox_g2p::phonetisaurus::PhonetisaurusP2g;

fn usage_exit() -> ! {
eprintln!(
"usage: floravox-p2g [--lexicon STEM] [--model STEM] [--byt5 DIR] [--lang eng] PHONEMES..."
);
std::process::exit(2);
}

/// Chained tiers are boxed so any subset of tiers can be active.
type DynP2g<'a> = Box<dyn P2g + 'a>;

fn main() {
let mut lexicon: Option<String> = None;
let mut model: Option<String> = None;
let mut byt5: Option<String> = None;
let mut lang: Option<String> = None;
let mut phones: Vec<String> = Vec::new();
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--lexicon" => lexicon = args.next(),
"--model" => model = args.next(),
"--byt5" => byt5 = args.next(),
"--lang" => lang = args.next(),
other if other.starts_with("--") => usage_exit(),
// Whitespace-separated symbols, however many args they came in.
other => phones.extend(other.split_whitespace().map(str::to_owned)),
}
}
if phones.is_empty() || (lexicon.is_none() && model.is_none() && byt5.is_none()) {
usage_exit();
}
// Slashed single-argument form: /ɹ aɪ t/ → ["ɹ", "aɪ", "t"].
if phones.len() == 1 {
let s = phones[0].trim();
if s.len() >= 2 && s.starts_with('/') && s.ends_with('/') {
phones = s[1..s.len() - 1]
.split_whitespace()
.map(str::to_owned)
.collect();
}
}
if phones.iter().any(String::is_empty) {
usage_exit();
}

// Chain: lexicon → WFST → ByT5. Boxes keep the tiers optional while
// the chain type stays concrete at each link.
let mut chain: Option<DynP2g> = None;

if let Some(stem) = &lexicon {
let lex = P2gLexicon::open(stem).unwrap_or_else(|e| {
eprintln!("cannot open lexicon {stem}: {e}");
std::process::exit(2);
});
chain = Some(match chain {
Some(inner) => Box::new(ChainedP2g(lex, inner)),
None => Box::new(lex),
});
}
if let Some(stem) = &model {
let m = PhonetisaurusP2g::open(stem).unwrap_or_else(|e| {
eprintln!("cannot open model {stem}: {e}");
std::process::exit(2);
});
chain = Some(match chain {
Some(inner) => Box::new(ChainedP2g(m, inner)),
None => Box::new(m),
});
}
#[cfg(feature = "onnx")]
if let Some(dir) = &byt5 {
let mut engine = ByT5P2g::load(
std::path::Path::new(dir).join("encoder_model.onnx"),
std::path::Path::new(dir).join("decoder_model.onnx"),
)
.unwrap_or_else(|e| {
eprintln!("cannot load byt5 model from {dir}: {e}");
std::process::exit(2);
});
if let Some(tag) = &lang {
engine.lang.clone_from(tag);
}
chain = Some(match chain {
Some(inner) => Box::new(ChainedP2g(engine, inner)),
None => Box::new(engine),
});
}
#[cfg(not(feature = "onnx"))]
if byt5.is_some() || lang.is_some() {
eprintln!("--byt5/--lang require a build with the onnx feature");
std::process::exit(2);
}

let Some(mut p2g) = chain else {
usage_exit();
};
for candidate in p2g.spell(&phones) {
println!("{candidate}");
}
}
103 changes: 99 additions & 4 deletions crates/floravox-g2p/src/bin/floravox-train-phonetisaurus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@
//! floravox-train-phonetisaurus LEXICON.tsv MODEL.fst
//! [--order 7] [--iters 8] [--gmax 2] [--pmax 2]
//! [--holdout 0.05] [--seed 7] [--metrics metrics.json]
//! [--reverse]
//!
//! `--reverse` trains a P2G model (phonemes on the input side,
//! graphemes on the output side) loadable by `PhonetisaurusP2g`. The
//! M2M alignment is direction-symmetric, so reversal is the ilabel/
//! olabel swap; `--gmax`/`--pmax` keep their grapheme/phoneme meanings
//! whichever way the model points. The holdout evaluation reports
//! word-level exact match and character error rate (spelled vs
//! reference word).

// This is a training CLI: the DP math uses single-letter bindings (g/p),
// count/size casts, and a long orchestrating main by design.
Expand All @@ -37,7 +46,7 @@
clippy::type_complexity
)]

use floravox_g2p::phonetisaurus::write_model;
use floravox_g2p::phonetisaurus::{write_model, PhonetisaurusP2g};
use floravox_g2p::PhonetisaurusG2p;
use std::collections::HashMap;

Expand All @@ -59,8 +68,10 @@ fn main() {
let mut holdout = 0.05f64;
let mut seed: u64 = 7;
let mut metrics_path: Option<String> = None;
let mut reverse = false;
while let Some(a) = args.next() {
match a.as_str() {
"--reverse" => reverse = true,
"--order" => order = parse(&mut args, &a),
"--iters" => iters = parse(&mut args, &a),
"--gmax" => gmax = parse(&mut args, &a),
Expand Down Expand Up @@ -247,12 +258,20 @@ fn main() {
// ---- symbol tables ----
let mut isyms: Vec<(String, i32)> = vec![("<eps>".into(), 0), ("|".into(), 1), ("_".into(), 2)];
let mut osyms = isyms.clone();
// Reversal is exactly the input/output assignment: phoneme
// compounds on the input side, grapheme compounds on the output.
let tok_labels: Vec<(i32, i32)> = tok_pairs
.iter()
.map(|&(g, p)| {
let il = label(g, &glist, &mut isyms, true);
let ol = label(p, &plist, &mut osyms, false);
(il, ol)
if reverse {
let il = label(p, &plist, &mut isyms, false);
let ol = label(g, &glist, &mut osyms, true);
(il, ol)
} else {
let il = label(g, &glist, &mut isyms, true);
let ol = label(p, &plist, &mut osyms, false);
(il, ol)
}
})
.collect();

Expand Down Expand Up @@ -288,6 +307,41 @@ fn main() {
);

// ---- evaluation through the shipped file ----
if reverse {
let model = PhonetisaurusP2g::open(&output).expect("reload model for eval");
// Homophone-aware gold set: a spelling is exact when it matches
// ANY word sharing the pronunciation (right/write/wright/rite).
let mut by_phones: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (w, ph) in &lex {
by_phones.entry(ph.join(" ")).or_default().push(w.clone());
}
let (exact, cer, coverage) = evaluate_p2g(&model, &hold, &by_phones);
eprintln!(
"eval (p2g): exact {:.1}%, CER {:.1}%, coverage {:.1}%",
exact * 100.0,
cer * 100.0,
coverage * 100.0
);
if let Some(path) = metrics_path {
let json = format!(
"{{\n \"direction\": \"p2g\",\n \"train\": {},\n \"holdout\": {},\n \"exact_match\": {:.4},\n \"cer\": {:.4},\n \"coverage\": {:.4},\n \"order\": {},\n \"iters\": {},\n \"gmax\": {},\n \"pmax\": {},\n \"states\": {},\n \"arcs\": {}\n}}\n",
train.len(),
hold.len(),
exact,
cer,
coverage,
order,
iters,
gmax,
pmax,
n_states,
arc_count
);
std::fs::write(path, json).expect("write metrics");
}
return;
}
let model = PhonetisaurusG2p::open(&output).expect("reload model for eval");
let (exact, per, coverage) = evaluate(&model, &hold);
eprintln!(
Expand Down Expand Up @@ -513,6 +567,47 @@ fn align(
path
}

/// P2G holdout metrics: homophone-aware exact match (any word sharing
/// the pronunciation counts), character error rate against the nearest
/// gold homophone, decode coverage.
fn evaluate_p2g(
model: &PhonetisaurusP2g,
hold: &[(String, Vec<String>)],
by_phones: &std::collections::HashMap<String, Vec<String>>,
) -> (f64, f64, f64) {
let mut exact = 0usize;
let mut cer_sum = 0.0f64;
let mut covered = 0usize;
let n = hold.len().max(1);
for (word, ref_phones) in hold {
let Some(spelled) = model.spell(ref_phones) else {
continue;
};
covered += 1;
let gold = by_phones
.get(&ref_phones.join(" "))
.map_or(std::slice::from_ref(word), |v| v.as_slice());
if gold.contains(&spelled) {
exact += 1;
}
// CER against the nearest gold homophone.
let a: Vec<String> = spelled.chars().map(|c| c.to_string()).collect();
let best = gold
.iter()
.map(|g| {
let b: Vec<String> = g.chars().map(|c| c.to_string()).collect();
edit_distance(&a, &b) as f64 / g.chars().count().max(1) as f64
})
.fold(f64::INFINITY, f64::min);
cer_sum += best;
}
(
exact as f64 / n as f64,
cer_sum / covered.max(1) as f64,
covered as f64 / n as f64,
)
}

/// Exact-match rate, mean PER, decode coverage over holdout pairs.
fn evaluate(model: &PhonetisaurusG2p, hold: &[(String, Vec<String>)]) -> (f64, f64, f64) {
let mut exact = 0usize;
Expand Down
Loading
Loading