g2p2 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
g2p2-0.1.0/Cargo.toml ADDED
@@ -0,0 +1,6 @@
1
+ [workspace]
2
+ members = ["g2p", "xtask"]
3
+ # The Python binding pulls in pyo3; keep it out of the workspace so plain
4
+ # `cargo build/test --workspace` and the zero-dep runtime stay clean.
5
+ exclude = ["bindings/python"]
6
+ resolver = "2"
g2p2-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: g2p2
3
+ Version: 0.1.0
4
+ Classifier: Programming Language :: Rust
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: Topic :: Text Processing :: Linguistic
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Summary: Zero-dependency grapheme-to-phoneme for the 100 Whisper languages (Rust core)
9
+ Keywords: g2p,grapheme-to-phoneme,phonemizer,ipa,whisper,phonetics
10
+ Author: jqueguiner
11
+ License: MIT OR Apache-2.0
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
14
+ Project-URL: Repository, https://github.com/jqueguiner/g2p2
15
+
16
+ # g2p2
17
+
18
+ Zero-dependency grapheme-to-phoneme for the **100 Whisper languages**, with a
19
+ Rust core exposed to Python.
20
+
21
+ ```bash
22
+ pip install g2p2
23
+ ```
24
+
25
+ ```python
26
+ from g2p2 import Model
27
+
28
+ m = Model.load("fr.g2p")
29
+ print(m.phonemize("bonjour")) # bɔ̃ʒuʁ
30
+ print(m.phonemize_many(["chat", "eau"])) # ['ʃa', 'o']
31
+ ```
32
+
33
+ Model blobs (`*.g2p`) are built from WikiPron / epitran / LLM data — see the
34
+ [main repo](https://github.com/jqueguiner/g2p2). Download a language's `.g2p`
35
+ from the GitHub releases or build with `cargo run -p xtask -- build-all`.
36
+
37
+ ## API
38
+
39
+ - `Model.load(path) -> Model` — load a `.g2p` file
40
+ - `Model.from_bytes(bytes) -> Model` — load from raw bytes
41
+ - `Model.phonemize(word) -> str` — IPA for one word
42
+ - `Model.phonemize_many(words) -> list[str]` — IPA for many
43
+
44
+ License: MIT OR Apache-2.0.
45
+
g2p2-0.1.0/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # g2p2
2
+
3
+ Zero-dependency grapheme-to-phoneme for the **100 Whisper languages**, with a
4
+ Rust core exposed to Python.
5
+
6
+ ```bash
7
+ pip install g2p2
8
+ ```
9
+
10
+ ```python
11
+ from g2p2 import Model
12
+
13
+ m = Model.load("fr.g2p")
14
+ print(m.phonemize("bonjour")) # bɔ̃ʒuʁ
15
+ print(m.phonemize_many(["chat", "eau"])) # ['ʃa', 'o']
16
+ ```
17
+
18
+ Model blobs (`*.g2p`) are built from WikiPron / epitran / LLM data — see the
19
+ [main repo](https://github.com/jqueguiner/g2p2). Download a language's `.g2p`
20
+ from the GitHub releases or build with `cargo run -p xtask -- build-all`.
21
+
22
+ ## API
23
+
24
+ - `Model.load(path) -> Model` — load a `.g2p` file
25
+ - `Model.from_bytes(bytes) -> Model` — load from raw bytes
26
+ - `Model.phonemize(word) -> str` — IPA for one word
27
+ - `Model.phonemize_many(words) -> list[str]` — IPA for many
28
+
29
+ License: MIT OR Apache-2.0.
@@ -0,0 +1,185 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "autocfg"
7
+ version = "1.5.1"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
10
+
11
+ [[package]]
12
+ name = "cfg-if"
13
+ version = "1.0.4"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
16
+
17
+ [[package]]
18
+ name = "g2p"
19
+ version = "0.1.0"
20
+
21
+ [[package]]
22
+ name = "g2p2"
23
+ version = "0.1.0"
24
+ dependencies = [
25
+ "g2p",
26
+ "pyo3",
27
+ ]
28
+
29
+ [[package]]
30
+ name = "heck"
31
+ version = "0.5.0"
32
+ source = "registry+https://github.com/rust-lang/crates.io-index"
33
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
34
+
35
+ [[package]]
36
+ name = "indoc"
37
+ version = "2.0.7"
38
+ source = "registry+https://github.com/rust-lang/crates.io-index"
39
+ checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
40
+ dependencies = [
41
+ "rustversion",
42
+ ]
43
+
44
+ [[package]]
45
+ name = "libc"
46
+ version = "0.2.186"
47
+ source = "registry+https://github.com/rust-lang/crates.io-index"
48
+ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
49
+
50
+ [[package]]
51
+ name = "memoffset"
52
+ version = "0.9.1"
53
+ source = "registry+https://github.com/rust-lang/crates.io-index"
54
+ checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
55
+ dependencies = [
56
+ "autocfg",
57
+ ]
58
+
59
+ [[package]]
60
+ name = "once_cell"
61
+ version = "1.21.4"
62
+ source = "registry+https://github.com/rust-lang/crates.io-index"
63
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
64
+
65
+ [[package]]
66
+ name = "portable-atomic"
67
+ version = "1.13.1"
68
+ source = "registry+https://github.com/rust-lang/crates.io-index"
69
+ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
70
+
71
+ [[package]]
72
+ name = "proc-macro2"
73
+ version = "1.0.106"
74
+ source = "registry+https://github.com/rust-lang/crates.io-index"
75
+ checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
76
+ dependencies = [
77
+ "unicode-ident",
78
+ ]
79
+
80
+ [[package]]
81
+ name = "pyo3"
82
+ version = "0.22.6"
83
+ source = "registry+https://github.com/rust-lang/crates.io-index"
84
+ checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
85
+ dependencies = [
86
+ "cfg-if",
87
+ "indoc",
88
+ "libc",
89
+ "memoffset",
90
+ "once_cell",
91
+ "portable-atomic",
92
+ "pyo3-build-config",
93
+ "pyo3-ffi",
94
+ "pyo3-macros",
95
+ "unindent",
96
+ ]
97
+
98
+ [[package]]
99
+ name = "pyo3-build-config"
100
+ version = "0.22.6"
101
+ source = "registry+https://github.com/rust-lang/crates.io-index"
102
+ checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
103
+ dependencies = [
104
+ "once_cell",
105
+ "target-lexicon",
106
+ ]
107
+
108
+ [[package]]
109
+ name = "pyo3-ffi"
110
+ version = "0.22.6"
111
+ source = "registry+https://github.com/rust-lang/crates.io-index"
112
+ checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
113
+ dependencies = [
114
+ "libc",
115
+ "pyo3-build-config",
116
+ ]
117
+
118
+ [[package]]
119
+ name = "pyo3-macros"
120
+ version = "0.22.6"
121
+ source = "registry+https://github.com/rust-lang/crates.io-index"
122
+ checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
123
+ dependencies = [
124
+ "proc-macro2",
125
+ "pyo3-macros-backend",
126
+ "quote",
127
+ "syn",
128
+ ]
129
+
130
+ [[package]]
131
+ name = "pyo3-macros-backend"
132
+ version = "0.22.6"
133
+ source = "registry+https://github.com/rust-lang/crates.io-index"
134
+ checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
135
+ dependencies = [
136
+ "heck",
137
+ "proc-macro2",
138
+ "pyo3-build-config",
139
+ "quote",
140
+ "syn",
141
+ ]
142
+
143
+ [[package]]
144
+ name = "quote"
145
+ version = "1.0.46"
146
+ source = "registry+https://github.com/rust-lang/crates.io-index"
147
+ checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
148
+ dependencies = [
149
+ "proc-macro2",
150
+ ]
151
+
152
+ [[package]]
153
+ name = "rustversion"
154
+ version = "1.0.23"
155
+ source = "registry+https://github.com/rust-lang/crates.io-index"
156
+ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
157
+
158
+ [[package]]
159
+ name = "syn"
160
+ version = "2.0.119"
161
+ source = "registry+https://github.com/rust-lang/crates.io-index"
162
+ checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
163
+ dependencies = [
164
+ "proc-macro2",
165
+ "quote",
166
+ "unicode-ident",
167
+ ]
168
+
169
+ [[package]]
170
+ name = "target-lexicon"
171
+ version = "0.12.16"
172
+ source = "registry+https://github.com/rust-lang/crates.io-index"
173
+ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
174
+
175
+ [[package]]
176
+ name = "unicode-ident"
177
+ version = "1.0.24"
178
+ source = "registry+https://github.com/rust-lang/crates.io-index"
179
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
180
+
181
+ [[package]]
182
+ name = "unindent"
183
+ version = "0.2.4"
184
+ source = "registry+https://github.com/rust-lang/crates.io-index"
185
+ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
@@ -0,0 +1,15 @@
1
+ [package]
2
+ name = "g2p2"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "Python bindings for g2p — zero-dependency grapheme-to-phoneme for the 100 Whisper languages"
6
+ license = "MIT OR Apache-2.0"
7
+ readme = "README.md"
8
+
9
+ [lib]
10
+ name = "g2p2"
11
+ crate-type = ["cdylib"]
12
+
13
+ [dependencies]
14
+ g2p = { path = "../../g2p" }
15
+ pyo3 = { version = "0.22", features = ["extension-module", "abi3-py38"] }
@@ -0,0 +1,29 @@
1
+ # g2p2
2
+
3
+ Zero-dependency grapheme-to-phoneme for the **100 Whisper languages**, with a
4
+ Rust core exposed to Python.
5
+
6
+ ```bash
7
+ pip install g2p2
8
+ ```
9
+
10
+ ```python
11
+ from g2p2 import Model
12
+
13
+ m = Model.load("fr.g2p")
14
+ print(m.phonemize("bonjour")) # bɔ̃ʒuʁ
15
+ print(m.phonemize_many(["chat", "eau"])) # ['ʃa', 'o']
16
+ ```
17
+
18
+ Model blobs (`*.g2p`) are built from WikiPron / epitran / LLM data — see the
19
+ [main repo](https://github.com/jqueguiner/g2p2). Download a language's `.g2p`
20
+ from the GitHub releases or build with `cargo run -p xtask -- build-all`.
21
+
22
+ ## API
23
+
24
+ - `Model.load(path) -> Model` — load a `.g2p` file
25
+ - `Model.from_bytes(bytes) -> Model` — load from raw bytes
26
+ - `Model.phonemize(word) -> str` — IPA for one word
27
+ - `Model.phonemize_many(words) -> list[str]` — IPA for many
28
+
29
+ License: MIT OR Apache-2.0.
@@ -0,0 +1,58 @@
1
+ //! Python bindings for the `g2p` engine (PyO3 + maturin, abi3).
2
+ //!
3
+ //! ```python
4
+ //! from g2p2 import Model
5
+ //! m = Model.load("fr.g2p")
6
+ //! m.phonemize("bonjour") # 'bɔ̃ʒuʁ'
7
+ //! m.phonemize_many(["a", "b"]) # ['...', '...']
8
+ //! ```
9
+
10
+ use pyo3::exceptions::PyIOError;
11
+ use pyo3::prelude::*;
12
+
13
+ /// A loaded G2P model. Construct with `Model.load(path)` or `Model.from_bytes(b)`.
14
+ #[pyclass]
15
+ struct Model {
16
+ inner: g2p::Model,
17
+ }
18
+
19
+ #[pymethods]
20
+ impl Model {
21
+ /// Load a compiled `.g2p` blob from disk.
22
+ #[staticmethod]
23
+ fn load(path: &str) -> PyResult<Self> {
24
+ let bytes = std::fs::read(path).map_err(|e| PyIOError::new_err(e.to_string()))?;
25
+ Ok(Self {
26
+ inner: g2p::Model::from_bytes(&bytes),
27
+ })
28
+ }
29
+
30
+ /// Load a model from raw `.g2p` bytes.
31
+ #[staticmethod]
32
+ fn from_bytes(bytes: &[u8]) -> Self {
33
+ Self {
34
+ inner: g2p::Model::from_bytes(bytes),
35
+ }
36
+ }
37
+
38
+ /// Phonemize one word -> IPA string.
39
+ fn phonemize(&self, word: &str) -> String {
40
+ g2p::phonemize(&self.inner, word)
41
+ }
42
+
43
+ /// Phonemize many words -> list of IPA strings.
44
+ fn phonemize_many(&self, words: Vec<String>) -> Vec<String> {
45
+ words.iter().map(|w| g2p::phonemize(&self.inner, w)).collect()
46
+ }
47
+
48
+ fn __repr__(&self) -> String {
49
+ format!("<g2p2.Model {} tokens>", self.inner.tokens.len())
50
+ }
51
+ }
52
+
53
+ #[pymodule]
54
+ fn g2p2(m: &Bound<'_, PyModule>) -> PyResult<()> {
55
+ m.add("__version__", env!("CARGO_PKG_VERSION"))?;
56
+ m.add_class::<Model>()?;
57
+ Ok(())
58
+ }
@@ -0,0 +1,7 @@
1
+ [package]
2
+ name = "g2p"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "Zero-dependency grapheme-to-phoneme for the 101 Whisper languages"
6
+
7
+ [dependencies]
@@ -0,0 +1,39 @@
1
+ //! `g2p` — command-line grapheme-to-phoneme.
2
+ //!
3
+ //! g2p <model.g2p> <word>... phonemize each word
4
+ //! echo "words" | g2p <model.g2p> read whitespace-separated words from stdin
5
+ //!
6
+ //! Models are the `.g2p` blobs produced by the build tool. Zero dependencies.
7
+
8
+ use std::io::Read;
9
+ use std::process::ExitCode;
10
+
11
+ fn main() -> ExitCode {
12
+ let args: Vec<String> = std::env::args().skip(1).collect();
13
+ let Some(model_path) = args.first() else {
14
+ eprintln!("usage: g2p <model.g2p> [word ...] (words also read from stdin)");
15
+ return ExitCode::from(2);
16
+ };
17
+
18
+ let bytes = match std::fs::read(model_path) {
19
+ Ok(b) => b,
20
+ Err(e) => {
21
+ eprintln!("g2p: cannot read {model_path}: {e}");
22
+ return ExitCode::from(1);
23
+ }
24
+ };
25
+ let model = g2p::Model::from_bytes(&bytes);
26
+
27
+ let mut words: Vec<String> = args[1..].to_vec();
28
+ if words.is_empty() {
29
+ let mut buf = String::new();
30
+ if std::io::stdin().read_to_string(&mut buf).is_ok() {
31
+ words = buf.split_whitespace().map(String::from).collect();
32
+ }
33
+ }
34
+
35
+ for w in words {
36
+ println!("{}", g2p::phonemize(&model, &w));
37
+ }
38
+ ExitCode::SUCCESS
39
+ }
@@ -0,0 +1,279 @@
1
+ //! Beam-search decoder over the joint n-gram model.
2
+ //!
3
+ //! Walk the input grapheme by grapheme; at each position try every grapheme
4
+ //! chunk of length `1..=max_chunk`, expand each candidate joint token, score
5
+ //! with `logp`, keep the top-`BEAM` hypotheses. Close with an EOS transition.
6
+
7
+ use crate::model::{Model, EOS};
8
+ use crate::normalize::{graphemes, lower};
9
+
10
+ const BEAM: usize = 8;
11
+ /// Penalty for skipping an unknown grapheme (no chunk matched at that position).
12
+ const SKIP_PEN: f32 = -20.0;
13
+
14
+ struct Hyp {
15
+ pos: usize,
16
+ hist: Vec<u32>,
17
+ out: Vec<u32>,
18
+ score: f32,
19
+ }
20
+
21
+ impl Hyp {
22
+ fn dup(&self) -> Hyp {
23
+ Hyp {
24
+ pos: self.pos,
25
+ hist: self.hist.clone(),
26
+ out: self.out.clone(),
27
+ score: self.score,
28
+ }
29
+ }
30
+ }
31
+
32
+ /// Phonemize a single word.
33
+ ///
34
+ /// Tiers: exact lexicon (word, then lowercased) -> n-gram beam ->
35
+ /// per-character lexicon fallback (logographic OOV).
36
+ pub fn phonemize(m: &Model, word: &str) -> String {
37
+ // 1. exact lexicon (logographic words / irregulars)
38
+ if let Some(ipa) = m.lexicon.get(word) {
39
+ return ipa.to_string();
40
+ }
41
+ let lw = lower(word);
42
+ if lw != word {
43
+ if let Some(ipa) = m.lexicon.get(lw.as_str()) {
44
+ return ipa.to_string();
45
+ }
46
+ }
47
+ // Logographic: per-character lexicon before the (unreliable) n-gram.
48
+ // Alphabetic: n-gram before the char fallback.
49
+ if m.logo {
50
+ let r = char_fallback(m, word);
51
+ if !r.is_empty() {
52
+ return r;
53
+ }
54
+ if !m.ngram.is_empty() {
55
+ return beam(m, &lw);
56
+ }
57
+ String::new()
58
+ } else {
59
+ if !m.ngram.is_empty() {
60
+ let r = beam(m, &lw);
61
+ if !r.is_empty() {
62
+ return r;
63
+ }
64
+ }
65
+ char_fallback(m, word)
66
+ }
67
+ }
68
+
69
+ /// Concatenate per-grapheme lexicon readings (logographic OOV). Empty if none hit.
70
+ fn char_fallback(m: &Model, word: &str) -> String {
71
+ let mut out = String::new();
72
+ let mut any = false;
73
+ for ch in graphemes(word) {
74
+ if let Some(ipa) = m.lexicon.get(&*ch) {
75
+ out.push_str(ipa);
76
+ any = true;
77
+ }
78
+ }
79
+ if any {
80
+ out
81
+ } else {
82
+ String::new()
83
+ }
84
+ }
85
+
86
+ fn beam(m: &Model, lw: &str) -> String {
87
+ let g = graphemes(lw);
88
+ let n = g.len();
89
+ if n == 0 {
90
+ return String::new();
91
+ }
92
+ let ord = (m.order as usize).max(1);
93
+
94
+ let mut beam = vec![Hyp {
95
+ pos: 0,
96
+ hist: Vec::new(),
97
+ out: Vec::new(),
98
+ score: 0.0,
99
+ }];
100
+
101
+ while !beam.iter().all(|h| h.pos == n) {
102
+ let mut next: Vec<Hyp> = Vec::new();
103
+ for h in &beam {
104
+ if h.pos == n {
105
+ next.push(h.dup());
106
+ continue;
107
+ }
108
+ let maxk = m.max_chunk.min(n - h.pos);
109
+ let mut matched = false;
110
+ for k in 1..=maxk {
111
+ let chunk: String = g[h.pos..h.pos + k].concat();
112
+ if let Some(ids) = m.by_graph.get(chunk.as_str()) {
113
+ matched = true;
114
+ for &tok in ids {
115
+ let lp = m.logp(&h.hist, tok);
116
+ let mut hist = h.hist.clone();
117
+ hist.push(tok);
118
+ if ord > 1 && hist.len() > ord - 1 {
119
+ let d = hist.len() - (ord - 1);
120
+ hist.drain(0..d);
121
+ } else if ord == 1 {
122
+ hist.clear();
123
+ }
124
+ let mut out = h.out.clone();
125
+ out.push(tok);
126
+ next.push(Hyp {
127
+ pos: h.pos + k,
128
+ hist,
129
+ out,
130
+ score: h.score + lp,
131
+ });
132
+ }
133
+ }
134
+ }
135
+ if !matched {
136
+ // Unknown grapheme: skip one unit with a penalty, emit nothing.
137
+ next.push(Hyp {
138
+ pos: h.pos + 1,
139
+ hist: h.hist.clone(),
140
+ out: h.out.clone(),
141
+ score: h.score + SKIP_PEN,
142
+ });
143
+ }
144
+ }
145
+ if next.is_empty() {
146
+ break;
147
+ }
148
+ next.sort_by(|a, b| b.score.total_cmp(&a.score));
149
+ next.truncate(BEAM);
150
+ beam = next;
151
+ }
152
+
153
+ // Close each finished hypothesis with an EOS transition, pick the best.
154
+ let best = beam
155
+ .iter()
156
+ .filter(|h| h.pos == n)
157
+ .map(|h| (h.score + m.logp(&h.hist, EOS), h))
158
+ .max_by(|a, b| a.0.total_cmp(&b.0));
159
+
160
+ match best {
161
+ Some((_, h)) => h.out.iter().map(|&t| &*m.tokens[t as usize].1).collect(),
162
+ None => String::new(),
163
+ }
164
+ }
165
+
166
+ #[cfg(test)]
167
+ mod tests {
168
+ use super::*;
169
+ use std::collections::HashMap;
170
+
171
+ /// Toy model: graphemes a->ɑ, b->b, with a unigram n-gram so the beam runs.
172
+ fn toy(logo: bool) -> Model {
173
+ let mut m = Model {
174
+ tokens: vec![
175
+ (String::new().into(), String::new().into()), // EOS
176
+ ("a".into(), "ɑ".into()),
177
+ ("b".into(), "b".into()),
178
+ ],
179
+ order: 2,
180
+ logo,
181
+ max_chunk: 0,
182
+ by_graph: HashMap::new(),
183
+ ngram: HashMap::from([
184
+ (Box::from(&[0u32][..]), -0.5f32),
185
+ (Box::from(&[1u32][..]), -0.5f32),
186
+ (Box::from(&[2u32][..]), -0.5f32),
187
+ ]),
188
+ backoff: HashMap::new(),
189
+ unk: -5.0,
190
+ lexicon: HashMap::new(),
191
+ };
192
+ m.index();
193
+ m
194
+ }
195
+
196
+ #[test]
197
+ fn empty_word_empty_output() {
198
+ assert_eq!(phonemize(&toy(false), ""), "");
199
+ }
200
+
201
+ #[test]
202
+ fn beam_decodes_known_graphemes() {
203
+ assert_eq!(phonemize(&toy(false), "ab"), "ɑb");
204
+ }
205
+
206
+ #[test]
207
+ fn uppercase_lowercased_before_decode() {
208
+ assert_eq!(phonemize(&toy(false), "AB"), "ɑb");
209
+ }
210
+
211
+ #[test]
212
+ fn unknown_grapheme_skipped_to_empty() {
213
+ // 'z' matches nothing -> skipped -> no output
214
+ assert_eq!(phonemize(&toy(false), "z"), "");
215
+ }
216
+
217
+ #[test]
218
+ fn exact_lexicon_wins() {
219
+ let mut m = toy(false);
220
+ m.lexicon.insert("ab".into(), "EXACT".into());
221
+ assert_eq!(phonemize(&m, "ab"), "EXACT");
222
+ }
223
+
224
+ #[test]
225
+ fn lexicon_lowercased_lookup() {
226
+ let mut m = toy(false);
227
+ m.lexicon.insert("cat".into(), "kat".into());
228
+ assert_eq!(phonemize(&m, "CAT"), "kat");
229
+ }
230
+
231
+ #[test]
232
+ fn logographic_char_fallback() {
233
+ let mut m = toy(true);
234
+ m.lexicon.insert("中".into(), "tʂʊŋ".into());
235
+ m.lexicon.insert("国".into(), "kwɔ".into());
236
+ // whole word absent -> per-char fallback concatenates
237
+ assert_eq!(phonemize(&m, "中国"), "tʂʊŋkwɔ");
238
+ }
239
+
240
+ #[test]
241
+ fn logographic_falls_through_to_ngram() {
242
+ // logo model, char fallback empty (a/b not in lexicon) -> n-gram beam
243
+ assert_eq!(phonemize(&toy(true), "ab"), "ɑb");
244
+ }
245
+
246
+ #[test]
247
+ fn no_ngram_no_lexicon_is_empty() {
248
+ let mut m = toy(false);
249
+ m.ngram.clear();
250
+ assert_eq!(phonemize(&m, "ab"), "");
251
+ }
252
+
253
+ #[test]
254
+ fn logo_all_miss_is_empty() {
255
+ let mut m = toy(true);
256
+ m.ngram.clear();
257
+ assert_eq!(phonemize(&m, "z"), "");
258
+ }
259
+
260
+ #[test]
261
+ fn multi_grapheme_token_and_dup() {
262
+ // add a 2-grapheme token so one hypothesis finishes before another,
263
+ // exercising the carry-forward (dup) path.
264
+ let mut m = toy(false);
265
+ m.tokens.push(("ab".into(), "AB".into()));
266
+ m.ngram.insert(Box::from(&[3u32][..]), -0.4);
267
+ m.index();
268
+ // both "ɑb" (a+b) and "AB" (ab) are valid; decoder returns one of them
269
+ let out = phonemize(&m, "ab");
270
+ assert!(out == "AB" || out == "ɑb");
271
+ }
272
+
273
+ #[test]
274
+ fn order_one_model_clears_history() {
275
+ let mut m = toy(false);
276
+ m.order = 1;
277
+ assert_eq!(phonemize(&m, "ab"), "ɑb");
278
+ }
279
+ }
@@ -0,0 +1,163 @@
1
+ //! The 100 Whisper languages mapped to their WikiPron source.
2
+ //!
3
+ //! `pat` is an extra substring the WikiPron filename must contain, used to
4
+ //! disambiguate multi-script / multi-dialect languages (e.g. pick Latin pinyin
5
+ //! for Chinese, Cyrillic for Serbian, US English). Empty `pat` = take the
6
+ //! best-ranked file for the ISO code.
7
+ //!
8
+ //! `logo` marks logographic languages whose grapheme->phoneme mapping is not an
9
+ //! n-gram problem (kanji/hanzi need a word->reading lexicon); they are trained
10
+ //! for completeness but should be served from the lexicon tier.
11
+
12
+ pub struct Lang {
13
+ pub whisper: &'static str,
14
+ pub iso: &'static str,
15
+ pub pat: &'static str,
16
+ pub logo: bool,
17
+ }
18
+
19
+ const fn l(whisper: &'static str, iso: &'static str, pat: &'static str, logo: bool) -> Lang {
20
+ Lang {
21
+ whisper,
22
+ iso,
23
+ pat,
24
+ logo,
25
+ }
26
+ }
27
+
28
+ /// Whisper large-v3 language set (100). Five have no WikiPron data
29
+ /// (sn/so/tt/ln/su) — resolved via epitran silver.
30
+ pub const LANGS: &[Lang] = &[
31
+ l("en", "eng", "us", false),
32
+ l("zh", "cmn", "hani", true),
33
+ l("de", "deu", "", false),
34
+ l("es", "spa", "", false),
35
+ l("ru", "rus", "", false),
36
+ l("ko", "kor", "", false),
37
+ l("fr", "fra", "", false),
38
+ l("ja", "jpn", "", true),
39
+ l("pt", "por", "", false),
40
+ l("tr", "tur", "", false),
41
+ l("pl", "pol", "", false),
42
+ l("ca", "cat", "", false),
43
+ l("nl", "nld", "", false),
44
+ l("ar", "ara", "", false),
45
+ l("sv", "swe", "", false),
46
+ l("it", "ita", "", false),
47
+ l("id", "ind", "", false),
48
+ l("hi", "hin", "", false),
49
+ l("fi", "fin", "", false),
50
+ l("vi", "vie", "", false),
51
+ l("he", "heb", "", false),
52
+ l("uk", "ukr", "", false),
53
+ l("el", "ell", "", false),
54
+ l("ms", "msa", "", false),
55
+ l("cs", "ces", "", false),
56
+ l("ro", "ron", "", false),
57
+ l("da", "dan", "", false),
58
+ l("hu", "hun", "", false),
59
+ l("ta", "tam", "", false),
60
+ l("no", "nor", "", false),
61
+ l("th", "tha", "", false),
62
+ l("ur", "urd", "arab", false),
63
+ l("hr", "hbs", "latn", false),
64
+ l("bg", "bul", "", false),
65
+ l("lt", "lit", "", false),
66
+ l("la", "lat", "", false),
67
+ l("mi", "mri", "", false),
68
+ l("ml", "mal", "", false),
69
+ l("cy", "cym", "", false),
70
+ l("sk", "slk", "", false),
71
+ l("te", "tel", "", false),
72
+ l("fa", "fas", "", false),
73
+ l("lv", "lav", "", false),
74
+ l("bn", "ben", "", false),
75
+ l("sr", "hbs", "cyrl", false),
76
+ l("az", "aze", "latn", false),
77
+ l("sl", "slv", "", false),
78
+ l("kn", "kan", "", false),
79
+ l("et", "est", "", false),
80
+ l("mk", "mkd", "", false),
81
+ l("br", "bre", "", false),
82
+ l("eu", "eus", "", false),
83
+ l("is", "isl", "", false),
84
+ l("hy", "hye", "", false),
85
+ l("ne", "nep", "", false),
86
+ l("mn", "mon", "cyrl", false),
87
+ l("bs", "hbs", "latn", false),
88
+ l("kk", "kaz", "cyrl", false),
89
+ l("sq", "sqi", "", false),
90
+ l("sw", "swa", "", false),
91
+ l("gl", "glg", "", false),
92
+ l("mr", "mar", "", false),
93
+ l("pa", "pan", "guru", false),
94
+ l("si", "sin", "", false),
95
+ l("km", "khm", "", false),
96
+ l("sn", "sna", "", false), // no wikipron -> silver
97
+ l("yo", "yor", "", false),
98
+ l("so", "som", "", false), // no wikipron -> silver
99
+ l("af", "afr", "", false),
100
+ l("oc", "oci", "", false),
101
+ l("ka", "kat", "", false),
102
+ l("be", "bel", "", false),
103
+ l("tg", "tgk", "cyrl", false),
104
+ l("sd", "snd", "arab", false),
105
+ l("gu", "guj", "", false),
106
+ l("am", "amh", "", false),
107
+ l("yi", "yid", "", false),
108
+ l("lo", "lao", "", false),
109
+ l("uz", "uzb", "latn", false),
110
+ l("fo", "fao", "", false),
111
+ l("ht", "hat", "", false),
112
+ l("ps", "pus", "", false),
113
+ l("tk", "tuk", "latn", false),
114
+ l("nn", "nno", "", false),
115
+ l("mt", "mlt", "", false),
116
+ l("sa", "san", "", false),
117
+ l("lb", "ltz", "", false),
118
+ l("my", "mya", "", false),
119
+ l("bo", "bod", "", false),
120
+ l("tl", "tgl", "", false),
121
+ l("mg", "mlg", "", false),
122
+ l("as", "asm", "", false),
123
+ l("tt", "tat", "", false), // no wikipron -> silver
124
+ l("haw", "haw", "", false),
125
+ l("ln", "lin", "", false), // no wikipron -> silver
126
+ l("ha", "hau", "", false),
127
+ l("ba", "bak", "", false),
128
+ l("jw", "jav", "", false),
129
+ l("su", "sun", "", false), // no wikipron -> silver
130
+ l("yue", "yue", "", true),
131
+ ];
132
+
133
+ /// Look up a language entry by Whisper code.
134
+ pub fn by_whisper(code: &str) -> Option<&'static Lang> {
135
+ LANGS.iter().find(|l| l.whisper == code)
136
+ }
137
+
138
+ #[cfg(test)]
139
+ mod tests {
140
+ use super::*;
141
+
142
+ #[test]
143
+ fn covers_100_whisper_langs() {
144
+ assert_eq!(LANGS.len(), 100);
145
+ }
146
+
147
+ #[test]
148
+ fn lookup_hit_and_miss() {
149
+ let fr = by_whisper("fr").expect("fr present");
150
+ assert_eq!(fr.iso, "fra");
151
+ assert!(!fr.logo);
152
+ assert!(by_whisper("zh").unwrap().logo); // logographic flag set
153
+ assert!(by_whisper("nonsense").is_none());
154
+ }
155
+
156
+ #[test]
157
+ fn whisper_codes_unique() {
158
+ let mut seen = std::collections::HashSet::new();
159
+ for l in LANGS {
160
+ assert!(seen.insert(l.whisper), "duplicate {}", l.whisper);
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,56 @@
1
+ //! Exact-match lexicon tier. Checked before the n-gram decoder for irregular
2
+ //! words, and used as the primary engine for logographic languages
3
+ //! (zh/ja/ko/...) where grapheme->phoneme n-grams do not apply.
4
+
5
+ use std::collections::HashMap;
6
+
7
+ #[derive(Default)]
8
+ pub struct Lexicon {
9
+ map: HashMap<Box<str>, Box<str>>,
10
+ }
11
+
12
+ impl Lexicon {
13
+ pub fn new() -> Self {
14
+ Self::default()
15
+ }
16
+
17
+ pub fn insert(&mut self, word: &str, ipa: &str) {
18
+ self.map.insert(word.into(), ipa.into());
19
+ }
20
+
21
+ /// Exact lookup. Returns `None` on miss (caller falls through to n-gram).
22
+ pub fn get(&self, word: &str) -> Option<&str> {
23
+ self.map.get(word).map(|b| &**b)
24
+ }
25
+
26
+ pub fn len(&self) -> usize {
27
+ self.map.len()
28
+ }
29
+
30
+ pub fn is_empty(&self) -> bool {
31
+ self.map.is_empty()
32
+ }
33
+ }
34
+
35
+ #[cfg(test)]
36
+ mod tests {
37
+ use super::*;
38
+
39
+ #[test]
40
+ fn insert_get_miss() {
41
+ let mut lex = Lexicon::new();
42
+ assert!(lex.is_empty());
43
+ lex.insert("cat", "k a t");
44
+ assert_eq!(lex.len(), 1);
45
+ assert!(!lex.is_empty());
46
+ assert_eq!(lex.get("cat"), Some("k a t"));
47
+ assert_eq!(lex.get("dog"), None);
48
+ }
49
+
50
+ #[test]
51
+ fn default_is_empty() {
52
+ let lex = Lexicon::default();
53
+ assert!(lex.is_empty());
54
+ assert_eq!(lex.len(), 0);
55
+ }
56
+ }
@@ -0,0 +1,14 @@
1
+ //! Zero-dependency grapheme-to-phoneme (G2P) for the 101 Whisper languages.
2
+ //!
3
+ //! Runtime engine: pair joint n-gram + beam decode. Pure `std`, CPU.
4
+ //! Models are compiled offline by `xtask` into `.g2p` blobs and loaded via
5
+ //! [`Model::from_bytes`].
6
+
7
+ pub mod decode;
8
+ pub mod lang;
9
+ pub mod lexicon;
10
+ pub mod model;
11
+ pub mod normalize;
12
+
13
+ pub use decode::phonemize;
14
+ pub use model::Model;
@@ -0,0 +1,315 @@
1
+ //! The n-gram G2P model + its binary `.g2p` format.
2
+ //!
3
+ //! One source of truth for serialization: `xtask` writes via [`Model::to_bytes`],
4
+ //! the runtime reads via [`Model::from_bytes`]. Same struct both sides -> the
5
+ //! blob format cannot drift.
6
+ //!
7
+ //! N-gram probabilities are stored **fully interpolated** (Witten-Bell) per seen
8
+ //! gram, so runtime lookup is a clean backoff recursion with no interpolation
9
+ //! math: exact gram hit returns the stored logprob (lower orders already folded
10
+ //! in); a miss multiplies by the history backoff weight and drops the oldest
11
+ //! token.
12
+
13
+ use std::collections::HashMap;
14
+
15
+ use crate::normalize::graphemes;
16
+
17
+ /// A joint alignment token: (grapheme side, phoneme side).
18
+ /// Token id 0 is reserved for [`EOS`] and has empty sides.
19
+ pub type Tok = (Box<str>, Box<str>);
20
+
21
+ /// End-of-sequence token id. Appended during training so the model learns
22
+ /// word-final phonotactics; the decoder must take it to close a word.
23
+ pub const EOS: u32 = 0;
24
+
25
+ const MAGIC: &[u8; 4] = b"G2P2";
26
+ /// Fixed-point scale for quantized ln-probabilities (i16). 0.001 resolution is
27
+ /// far finer than argmax decoding needs.
28
+ const QSCALE: f32 = 1000.0;
29
+
30
+ pub struct Model {
31
+ /// id -> token. index 0 = EOS ("", "").
32
+ pub tokens: Vec<Tok>,
33
+ /// n-gram order (e.g. 6).
34
+ pub order: u8,
35
+ /// logographic language: prefer the exact/per-char lexicon over the n-gram,
36
+ /// which is unreliable on ideographs.
37
+ pub logo: bool,
38
+ /// max grapheme-cluster length of any token's grapheme side (derived).
39
+ pub max_chunk: usize,
40
+ /// grapheme chunk -> candidate token ids (derived, not serialized).
41
+ pub by_graph: HashMap<Box<str>, Vec<u32>>,
42
+ /// exact gram (history ++ token) -> ln p, fully interpolated.
43
+ pub ngram: HashMap<Box<[u32]>, f32>,
44
+ /// history -> ln backoff weight (1 - lambda).
45
+ pub backoff: HashMap<Box<[u32]>, f32>,
46
+ /// ln floor for an unseen unigram.
47
+ pub unk: f32,
48
+ /// exact word -> IPA (continuous). Primary tier for logographic languages
49
+ /// (zh/ja/yue) and irregulars; checked before the n-gram in `phonemize`.
50
+ /// Single-character entries double as the per-character OOV fallback.
51
+ pub lexicon: HashMap<Box<str>, Box<str>>,
52
+ }
53
+
54
+ impl Model {
55
+ /// Rebuild the derived `by_graph` index and `max_chunk` from `tokens`.
56
+ /// Empty-grapheme specials (EOS) are skipped — they are never decode
57
+ /// candidates mid-word.
58
+ pub fn index(&mut self) {
59
+ self.by_graph.clear();
60
+ self.max_chunk = 1;
61
+ for (id, (g, _)) in self.tokens.iter().enumerate() {
62
+ if g.is_empty() {
63
+ continue;
64
+ }
65
+ self.max_chunk = self.max_chunk.max(graphemes(g).len());
66
+ self.by_graph.entry(g.clone()).or_default().push(id as u32);
67
+ }
68
+ }
69
+
70
+ /// `ln p(t | hist)`. `hist` is the last `order-1` token ids, most-recent
71
+ /// last. Interpolation is baked into stored grams, so: exact gram hit
72
+ /// returns it; otherwise back off by the history weight and drop the oldest
73
+ /// token.
74
+ pub fn logp(&self, hist: &[u32], t: u32) -> f32 {
75
+ let mut key: Vec<u32> = Vec::with_capacity(hist.len() + 1);
76
+ key.extend_from_slice(hist);
77
+ key.push(t);
78
+ if let Some(&lp) = self.ngram.get(key.as_slice()) {
79
+ return lp;
80
+ }
81
+ if hist.is_empty() {
82
+ return self.unk; // unseen unigram
83
+ }
84
+ let bow = self.backoff.get(hist).copied().unwrap_or(0.0); // ln(1) if history unseen
85
+ bow + self.logp(&hist[1..], t)
86
+ }
87
+
88
+ // ---- binary format ----
89
+
90
+ pub fn to_bytes(&self) -> Vec<u8> {
91
+ let mut o = Vec::new();
92
+ o.extend_from_slice(MAGIC);
93
+ o.push(self.order);
94
+ o.push(self.logo as u8);
95
+ o.extend_from_slice(&[0, 0]); // pad
96
+ put_u32(&mut o, self.tokens.len() as u32);
97
+ for (g, p) in &self.tokens {
98
+ put_str(&mut o, g);
99
+ put_str(&mut o, p);
100
+ }
101
+ put_map(&mut o, &self.ngram);
102
+ put_map(&mut o, &self.backoff);
103
+ o.extend_from_slice(&self.unk.to_le_bytes());
104
+ put_u32(&mut o, self.lexicon.len() as u32);
105
+ for (k, v) in &self.lexicon {
106
+ put_str(&mut o, k);
107
+ put_str(&mut o, v);
108
+ }
109
+ o
110
+ }
111
+
112
+ pub fn from_bytes(b: &[u8]) -> Model {
113
+ let mut c = Cur { b, i: 0 };
114
+ assert_eq!(c.take(4), MAGIC, "bad magic");
115
+ let order = c.u8();
116
+ let logo = c.u8() != 0;
117
+ c.take(2); // pad
118
+ let nt = c.u32() as usize;
119
+ let tokens: Vec<Tok> = (0..nt).map(|_| (c.str(), c.str())).collect();
120
+ let ngram = c.map();
121
+ let backoff = c.map();
122
+ let unk = c.f32();
123
+ let nlex = c.u32() as usize;
124
+ let mut lexicon = HashMap::with_capacity(nlex);
125
+ for _ in 0..nlex {
126
+ lexicon.insert(c.str(), c.str());
127
+ }
128
+ let mut m = Model {
129
+ tokens,
130
+ order,
131
+ logo,
132
+ max_chunk: 0,
133
+ by_graph: HashMap::new(),
134
+ ngram,
135
+ backoff,
136
+ unk,
137
+ lexicon,
138
+ };
139
+ m.index();
140
+ m
141
+ }
142
+ }
143
+
144
+ // ---- little-endian helpers, no deps ----
145
+
146
+ fn put_u16(o: &mut Vec<u8>, v: u16) {
147
+ o.extend_from_slice(&v.to_le_bytes());
148
+ }
149
+ fn put_u32(o: &mut Vec<u8>, v: u32) {
150
+ o.extend_from_slice(&v.to_le_bytes());
151
+ }
152
+ fn put_str(o: &mut Vec<u8>, s: &str) {
153
+ put_u16(o, s.len() as u16);
154
+ o.extend_from_slice(s.as_bytes());
155
+ }
156
+ /// LEB128 unsigned varint — token ids are small, so most take 1-2 bytes.
157
+ fn put_varint(o: &mut Vec<u8>, mut v: u32) {
158
+ loop {
159
+ let b = (v & 0x7f) as u8;
160
+ v >>= 7;
161
+ if v != 0 {
162
+ o.push(b | 0x80);
163
+ } else {
164
+ o.push(b);
165
+ break;
166
+ }
167
+ }
168
+ }
169
+ fn put_map(o: &mut Vec<u8>, m: &HashMap<Box<[u32]>, f32>) {
170
+ put_u32(o, m.len() as u32);
171
+ for (k, v) in m {
172
+ o.push(k.len() as u8);
173
+ for &id in k.iter() {
174
+ put_varint(o, id);
175
+ }
176
+ let q = (v * QSCALE).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
177
+ o.extend_from_slice(&q.to_le_bytes());
178
+ }
179
+ }
180
+
181
+ struct Cur<'a> {
182
+ b: &'a [u8],
183
+ i: usize,
184
+ }
185
+ impl<'a> Cur<'a> {
186
+ fn take(&mut self, n: usize) -> &'a [u8] {
187
+ let s = &self.b[self.i..self.i + n];
188
+ self.i += n;
189
+ s
190
+ }
191
+ fn u8(&mut self) -> u8 {
192
+ let v = self.b[self.i];
193
+ self.i += 1;
194
+ v
195
+ }
196
+ fn u16(&mut self) -> u16 {
197
+ u16::from_le_bytes(self.take(2).try_into().unwrap())
198
+ }
199
+ fn u32(&mut self) -> u32 {
200
+ u32::from_le_bytes(self.take(4).try_into().unwrap())
201
+ }
202
+ fn f32(&mut self) -> f32 {
203
+ f32::from_le_bytes(self.take(4).try_into().unwrap())
204
+ }
205
+ fn i16(&mut self) -> i16 {
206
+ i16::from_le_bytes(self.take(2).try_into().unwrap())
207
+ }
208
+ fn varint(&mut self) -> u32 {
209
+ let mut v = 0u32;
210
+ let mut s = 0;
211
+ loop {
212
+ let b = self.u8();
213
+ v |= ((b & 0x7f) as u32) << s;
214
+ if b & 0x80 == 0 {
215
+ break;
216
+ }
217
+ s += 7;
218
+ }
219
+ v
220
+ }
221
+ fn str(&mut self) -> Box<str> {
222
+ let n = self.u16() as usize;
223
+ String::from_utf8(self.take(n).to_vec()).unwrap().into()
224
+ }
225
+ fn map(&mut self) -> HashMap<Box<[u32]>, f32> {
226
+ let n = self.u32() as usize;
227
+ let mut m = HashMap::with_capacity(n);
228
+ for _ in 0..n {
229
+ let l = self.u8() as usize;
230
+ let k: Box<[u32]> = (0..l).map(|_| self.varint()).collect();
231
+ m.insert(k, self.i16() as f32 / QSCALE);
232
+ }
233
+ m
234
+ }
235
+ }
236
+
237
+ #[cfg(test)]
238
+ mod tests {
239
+ use super::*;
240
+
241
+ #[test]
242
+ fn roundtrip_preserves_lexicon_and_logo() {
243
+ let mut m = Model {
244
+ tokens: vec![
245
+ (String::new().into(), String::new().into()),
246
+ ("a".into(), "ɑ".into()),
247
+ ],
248
+ order: 6,
249
+ logo: true,
250
+ max_chunk: 0,
251
+ by_graph: HashMap::new(),
252
+ ngram: HashMap::from([(Box::from(&[1u32][..]), -1.5f32)]),
253
+ backoff: HashMap::from([(Box::from(&[][..]), -0.3f32)]),
254
+ unk: -9.0,
255
+ lexicon: HashMap::from([("中".into(), "ʈʂʊŋ".into())]),
256
+ };
257
+ m.index();
258
+ let back = Model::from_bytes(&m.to_bytes());
259
+ assert!(back.logo);
260
+ assert_eq!(back.order, 6);
261
+ assert_eq!(back.lexicon.get("中").map(|s| &**s), Some("ʈʂʊŋ"));
262
+ assert_eq!(back.ngram.get(&[1u32][..]).copied(), Some(-1.5));
263
+ assert_eq!(back.by_graph.get("a").map(|s| s.len()), Some(1));
264
+ // quantized logprob round-trips within i16 resolution
265
+ assert!((back.unk - (-9.0)).abs() < 0.01);
266
+ }
267
+
268
+ #[test]
269
+ fn logp_backoff_and_unk() {
270
+ let m = Model {
271
+ tokens: vec![
272
+ (String::new().into(), String::new().into()),
273
+ ("a".into(), "ɑ".into()),
274
+ ],
275
+ order: 3,
276
+ logo: false,
277
+ max_chunk: 0,
278
+ by_graph: HashMap::new(),
279
+ ngram: HashMap::from([(Box::from(&[1u32][..]), -1.5f32)]), // only unigram [1]
280
+ backoff: HashMap::from([(Box::from(&[2u32][..]), -0.7f32)]),
281
+ unk: -8.0,
282
+ lexicon: HashMap::new(),
283
+ };
284
+ // exact unigram hit
285
+ assert!((m.logp(&[], 1) - (-1.5)).abs() < 1e-6);
286
+ // bigram [2,1] missing -> backoff(hist=[2]) + logp([],1) = -0.7 + -1.5
287
+ assert!((m.logp(&[2], 1) - (-2.2)).abs() < 1e-6);
288
+ // unseen unigram -> unk
289
+ assert!((m.logp(&[], 99) - (-8.0)).abs() < 1e-6);
290
+ // missing history backoff weight treated as ln(1)=0
291
+ assert!((m.logp(&[7], 1) - (-1.5)).abs() < 1e-6);
292
+ }
293
+
294
+ #[test]
295
+ fn varint_multibyte_roundtrip() {
296
+ // token id > 127 forces a multi-byte LEB128 varint on both encode/decode
297
+ let mut tokens: Vec<Tok> = (0..300)
298
+ .map(|i| (i.to_string().into(), format!("p{i}").into()))
299
+ .collect();
300
+ tokens[0] = (String::new().into(), String::new().into());
301
+ let m = Model {
302
+ tokens,
303
+ order: 2,
304
+ logo: false,
305
+ max_chunk: 0,
306
+ by_graph: HashMap::new(),
307
+ ngram: HashMap::from([(Box::from(&[257u32][..]), -2.0f32)]),
308
+ backoff: HashMap::new(),
309
+ unk: -7.0,
310
+ lexicon: HashMap::new(),
311
+ };
312
+ let back = Model::from_bytes(&m.to_bytes());
313
+ assert!((back.ngram.get(&[257u32][..]).copied().unwrap() - (-2.0)).abs() < 0.01);
314
+ }
315
+ }
@@ -0,0 +1,82 @@
1
+ //! Grapheme splitting + casing. Pure `std`, no Unicode-table crates.
2
+ //!
3
+ //! `graphemes` clusters a base char with its trailing combining marks so that
4
+ //! e.g. a base + combining tilde stays one unit. The combining-mark ranges
5
+ //! below cover Latin/Cyrillic/Greek/Arabic/Hebrew diacritics and common
6
+ //! symbol/Indic combining blocks. It is an approximation of full UAX #29
7
+ //! grapheme clustering — good enough for word-level G2P; extend the ranges if a
8
+ //! target language needs finer clustering.
9
+
10
+ /// Split a string into grapheme clusters (base char + trailing combining marks).
11
+ pub fn graphemes(s: &str) -> Vec<Box<str>> {
12
+ let mut out: Vec<Box<str>> = Vec::new();
13
+ let mut cur = String::new();
14
+ for ch in s.chars() {
15
+ if is_combining(ch) && !cur.is_empty() {
16
+ cur.push(ch); // attach to current base
17
+ } else {
18
+ if !cur.is_empty() {
19
+ out.push(cur.as_str().into());
20
+ cur.clear();
21
+ }
22
+ cur.push(ch);
23
+ }
24
+ }
25
+ if !cur.is_empty() {
26
+ out.push(cur.as_str().into());
27
+ }
28
+ out
29
+ }
30
+
31
+ /// Unicode-aware lowercasing (`std`). Note: Turkish dotless-i and a few
32
+ /// language-specific casings are not special-cased here.
33
+ pub fn lower(s: &str) -> String {
34
+ s.to_lowercase()
35
+ }
36
+
37
+ #[inline]
38
+ fn is_combining(c: char) -> bool {
39
+ matches!(c as u32,
40
+ 0x0300..=0x036F | // combining diacritical marks
41
+ 0x0483..=0x0489 | // Cyrillic
42
+ 0x0591..=0x05BD | 0x05BF | 0x05C1..=0x05C2 | 0x05C4..=0x05C5 | 0x05C7 | // Hebrew
43
+ 0x0610..=0x061A | 0x064B..=0x065F | 0x0670 | 0x06D6..=0x06DC | 0x06DF..=0x06E4 | // Arabic
44
+ 0x0900..=0x0903 | 0x093A..=0x094F | 0x0951..=0x0957 | // Devanagari (approx)
45
+ 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F)
46
+ }
47
+
48
+ #[cfg(test)]
49
+ mod tests {
50
+ use super::*;
51
+
52
+ #[test]
53
+ fn ascii_splits_per_char() {
54
+ let g = graphemes("chat");
55
+ assert_eq!(g.len(), 4);
56
+ }
57
+
58
+ #[test]
59
+ fn combining_mark_attaches() {
60
+ // "a" + U+0303 combining tilde -> one cluster
61
+ let g = graphemes("a\u{0303}bc");
62
+ assert_eq!(g.len(), 3);
63
+ assert_eq!(&*g[0], "a\u{0303}");
64
+ }
65
+
66
+ #[test]
67
+ fn empty_string_no_clusters() {
68
+ assert!(graphemes("").is_empty());
69
+ }
70
+
71
+ #[test]
72
+ fn leading_combining_stands_alone() {
73
+ // combining mark with no preceding base starts its own cluster
74
+ let g = graphemes("\u{0303}a");
75
+ assert_eq!(g.len(), 2);
76
+ }
77
+
78
+ #[test]
79
+ fn lower_is_unicode_aware() {
80
+ assert_eq!(lower("ÀÉ"), "àé");
81
+ }
82
+ }
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["maturin>=1.5,<2.0"]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "g2p2"
7
+ version = "0.1.0"
8
+ description = "Zero-dependency grapheme-to-phoneme for the 100 Whisper languages (Rust core)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT OR Apache-2.0" }
12
+ authors = [{ name = "jqueguiner" }]
13
+ keywords = ["g2p", "grapheme-to-phoneme", "phonemizer", "ipa", "whisper", "phonetics"]
14
+ classifiers = [
15
+ "Programming Language :: Rust",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Text Processing :: Linguistic",
18
+ "License :: OSI Approved :: MIT License",
19
+ ]
20
+
21
+ [project.urls]
22
+ Repository = "https://github.com/jqueguiner/g2p2"
23
+
24
+ [tool.maturin]
25
+ module-name = "g2p2"
26
+ features = ["pyo3/extension-module"]
27
+ manifest-path = "bindings/python/Cargo.toml"