From a5c6392b4e928c675d8a4a7b50d68eecf1fb2660 Mon Sep 17 00:00:00 2001 From: Weksa Date: Sun, 8 Nov 2026 01:02:20 +0000 Subject: [PATCH 1/3] Fixture: the phones line for Nibu's name (n, i primary, b, u) The expected `weksa lower --phones` output for the proof packet, committed before the code that must produce it. Co-Authored-By: Claude Sonnet 5.5 --- data/phonology/fixtures/nibu-en-US.phones.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 data/phonology/fixtures/nibu-en-US.phones.json diff --git a/data/phonology/fixtures/nibu-en-US.phones.json b/data/phonology/fixtures/nibu-en-US.phones.json new file mode 100644 index 0000000..45e2e11 --- /dev/null +++ b/data/phonology/fixtures/nibu-en-US.phones.json @@ -0,0 +1 @@ +{"language":"en-US","source":"ˈnibu","phones":[{"ipa":"n","stress":"none"},{"ipa":"i","stress":"primary"},{"ipa":"b","stress":"none"},{"ipa":"u","stress":"none"}]} -- 2.47.3 From 5508000d213b29988f6930c67b1e606571bfbb69 Mon Sep 17 00:00:00 2001 From: Weksa Date: Sun, 8 Nov 2026 01:04:03 +0000 Subject: [PATCH 2/3] Phone inventory, segmenter and `weksa lower --phones` data/phonology/en-US.phones.tsv lists the phones (tense/lax a column); the segmenter splits ruled IPA into {ipa, stress} by longest inventory match and refuses a symbol it does not list (LowerError::UnknownPhone) or a stress mark with no vowel (DanglingStress). No durations, pitch or acoustics. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/lib.rs | 4 +- crates/weksa/src/lower.rs | 53 ++++++++++- crates/weksa/src/main.rs | 13 ++- crates/weksa/src/phones.rs | 152 ++++++++++++++++++++++++++++++++ crates/weksa/tests/cli.rs | 12 +++ crates/weksa/tests/lower.rs | 139 ++++++++++++++++++++++++++++- data/phonology/en-US.phones.tsv | 46 ++++++++++ 7 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 crates/weksa/src/phones.rs create mode 100644 data/phonology/en-US.phones.tsv diff --git a/crates/weksa/src/lib.rs b/crates/weksa/src/lib.rs index 3f6b43c..a0ef6c6 100644 --- a/crates/weksa/src/lib.rs +++ b/crates/weksa/src/lib.rs @@ -9,6 +9,7 @@ pub mod form; pub mod inventory; pub mod lower; pub mod packet; +pub mod phones; pub mod schema; pub mod score; pub mod split; @@ -22,7 +23,8 @@ pub use inventory::{ Change, Family, InvId, Inventory, InventoryEntry, InventoryRevision, OptionLabel, OptionSet, Slot, ValueKind, }; -pub use lower::{EntityForms, LowerError, TARGET_LANGUAGE, lower, pronounce}; +pub use lower::{EntityForms, LowerError, PhoneLine, TARGET_LANGUAGE, lower, phones, pronounce}; +pub use phones::{Phone, PhoneInventory, SegmentError, Stress}; pub use packet::{ EncoderId, EntityId, Grade, NodeId, Packet, PacketId, PacketKind, SCHEMA_VERSION, Stance, }; diff --git a/crates/weksa/src/lower.rs b/crates/weksa/src/lower.rs index 43a8752..51f5a1f 100644 --- a/crates/weksa/src/lower.rs +++ b/crates/weksa/src/lower.rs @@ -23,9 +23,12 @@ use std::fmt; +use serde::Serialize; + use crate::entity::Entity; use crate::inventory::{Family, InvId, Inventory}; use crate::packet::{EntityId, NodeId, Predication}; +use crate::phones::{Phone, PhoneInventory, SegmentError}; use crate::store::EntityStore; use crate::validate::LoweringInput; @@ -95,6 +98,13 @@ pub enum LowerError { NoPronunciation { entity: EntityId }, /// IPA was requested for a packet that cites no entity. NoEntityReferent, + /// Phones were requested for an entity whose ruled IPA holds a symbol the + /// language's phone inventory does not list (or the language has no + /// inventory). The lowerer does not guess a phone. + UnknownPhone { entity: EntityId, at: usize, text: String }, + /// Phones were requested for an entity whose ruled IPA has a stress mark + /// with no vowel after it. + DanglingStress { entity: EntityId, at: usize }, /// The entity port failed to read. EntityRead { entity: EntityId, why: String }, } @@ -194,13 +204,54 @@ pub fn pronounce( input: &LoweringInput, entities: &dyn EntityForms, ) -> Result, LowerError> { + Ok(ruled(input, entities)?.into_iter().map(|(_, ipa)| ipa).collect()) +} + +/// One entity's ruled pronunciation as the phones line a sound consumer reads: +/// the language tag, the stored IPA it came from, and the segmented phones. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PhoneLine { + pub language: &'static str, + pub source: String, + pub phones: Vec, +} + +/// The phones line of every entity a packet cites, in [`pronounce`] order, each +/// segmented by [`TARGET_LANGUAGE`]'s phone inventory. A symbol the inventory +/// does not list refuses the whole call. +pub fn phones( + input: &LoweringInput, + entities: &dyn EntityForms, +) -> Result, LowerError> { + let inventory = PhoneInventory::for_language(TARGET_LANGUAGE); + ruled(input, entities)? + .into_iter() + .map(|(entity, source)| { + let unknown = |at, text| LowerError::UnknownPhone { entity: entity.clone(), at, text }; + let inventory = inventory.ok_or_else(|| unknown(0, source.clone()))?; + let phones = inventory.segment(&source).map_err(|e| match e { + SegmentError::UnknownPhone { at, text } => unknown(at, text), + SegmentError::DanglingStress { at } => { + LowerError::DanglingStress { entity: entity.clone(), at } + } + })?; + Ok(PhoneLine { language: TARGET_LANGUAGE, source, phones }) + }) + .collect() +} + +/// `(entity, ruled IPA)` for each entity referent, in referent order. +fn ruled( + input: &LoweringInput, + entities: &dyn EntityForms, +) -> Result, LowerError> { let mut out = Vec::new(); for id in input.referents().iter().filter_map(|r| r.entity.as_ref()) { let entity = entity(entities, id)?; let p = entity .pronunciation_in(TARGET_LANGUAGE) .ok_or_else(|| LowerError::NoPronunciation { entity: id.clone() })?; - out.push(p.ipa.clone()); + out.push((id.clone(), p.ipa.clone())); } if out.is_empty() { return Err(LowerError::NoEntityReferent); diff --git a/crates/weksa/src/main.rs b/crates/weksa/src/main.rs index 5518823..bc61c7e 100644 --- a/crates/weksa/src/main.rs +++ b/crates/weksa/src/main.rs @@ -53,8 +53,13 @@ enum Command { key: String, /// Print the ruled IPA of each entity the packet cites, one per line, /// instead of the text; refused for an entity with no ruled pronunciation. - #[arg(long)] + #[arg(long, conflicts_with = "phones")] ipa: bool, + /// Print the phones line of each entity the packet cites, one JSON + /// object per line ({language, source, phones: [{ipa, stress}]}), + /// instead of the text; refused for a phone the inventory lacks. + #[arg(long)] + phones: bool, }, /// Score output text (stdin, or --output) against corpus items, one embed call. Score(ScoreArgs), @@ -527,7 +532,7 @@ fn main() -> Result<()> { .map_err(refused)?; println!("admitted {key}"); } - Command::Lower { store, key, ipa } => { + Command::Lower { store, key, ipa, phones } => { let inventory_store = InventoryStore::open(&cli.inventory)?; let inventory = inventory_store.inventory()?; let mut packets = PacketStore::open(&store)?; @@ -549,6 +554,10 @@ fn main() -> Result<()> { for line in pronounce(&view, &entities)? { println!("{line}"); } + } else if phones { + for line in weksa::phones(&view, &entities)? { + println!("{}", serde_json::to_string(&line)?); + } } else { println!("{}", lower(&view, &inventory, &entities)?); } diff --git a/crates/weksa/src/phones.rs b/crates/weksa/src/phones.rs new file mode 100644 index 0000000..1477812 --- /dev/null +++ b/crates/weksa/src/phones.rs @@ -0,0 +1,152 @@ +//! The phone inventory and the segmenter: a ruled IPA string to an ordered list of +//! phones with stress, for the consumer that turns phones into sound. The +//! inventory is data (`data/phonology/.phones.tsv`); this module reads +//! it and owns one rule, that every symbol of the string is a phone the inventory +//! lists. A symbol it does not list is refused: no nearest phone, no spelling +//! rule, no guess. Tense/lax is a column of the inventory, so /i/ and /ɪ/ are two +//! phones here, not one phone and a flag on an entity. +//! +//! Stress marks are not phones. `ˈ` (primary) and `ˌ` (secondary) put their +//! stress on the next vowel phone, the syllable's nucleus; every other phone is +//! `none`. A mark with no vowel after it is refused. Durations, pitch and +//! acoustics are the consumer's, never read or written here. + +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use serde::Serialize; + +const EN_US: &str = include_str!("../../../data/phonology/en-US.phones.tsv"); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Stress { + None, + Primary, + Secondary, +} + +/// One phone of a segmented string: its IPA symbol as the inventory spells it +/// and its stress. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Phone { + pub ipa: String, + pub stress: Stress, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tenseness { + Tense, + Lax, + Reduced, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Class { + Consonant, + Vowel(Tenseness), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SegmentError { + /// The symbol (or the text from it on) matches no inventory phone. + UnknownPhone { at: usize, text: String }, + /// A stress mark (`at` is its byte offset) that finds no vowel before the + /// string ends or another mark comes. + DanglingStress { at: usize }, +} + +pub struct PhoneInventory { + phones: BTreeMap, + longest: usize, +} + +impl PhoneInventory { + /// The inventory for a language tag, if one is committed. + pub fn for_language(language: &str) -> Option<&'static PhoneInventory> { + static EN_US_INV: OnceLock = OnceLock::new(); + match language { + "en-US" => Some(EN_US_INV.get_or_init(|| { + PhoneInventory::parse(EN_US).expect("the committed en-US phone inventory parses") + })), + _ => None, + } + } + + /// Parse the tab-separated inventory. Malformed lines are errors, as are + /// repeated symbols. + pub fn parse(text: &str) -> Result { + let mut phones = BTreeMap::new(); + for (n, line) in text.lines().enumerate() { + if line.is_empty() || line.starts_with('#') { + continue; + } + let cols: Vec<&str> = line.split('\t').collect(); + let [ipa, class, tenseness, gloss] = cols[..] else { + return Err(format!("line {}: want 4 tab-separated columns", n + 1)); + }; + let class = match (class, tenseness) { + ("consonant", "-") => Class::Consonant, + ("vowel", "tense") => Class::Vowel(Tenseness::Tense), + ("vowel", "lax") => Class::Vowel(Tenseness::Lax), + ("vowel", "reduced") => Class::Vowel(Tenseness::Reduced), + _ => return Err(format!("line {}: bad class/tenseness {class}/{tenseness}", n + 1)), + }; + if ipa.is_empty() || gloss.is_empty() || ipa.chars().any(|c| matches!(c, 'ˈ' | 'ˌ')) { + return Err(format!("line {}: empty or stress-marked entry", n + 1)); + } + if phones.insert(ipa.to_string(), class).is_some() { + return Err(format!("line {}: {ipa} repeats", n + 1)); + } + } + let longest = phones.keys().map(|k| k.chars().count()).max().unwrap_or(0); + Ok(Self { phones, longest }) + } + + pub fn class(&self, ipa: &str) -> Option<&Class> { + self.phones.get(ipa) + } + + /// Segment `ipa` into phones: longest inventory match at each position. + pub fn segment(&self, ipa: &str) -> Result, SegmentError> { + let chars: Vec<(usize, char)> = ipa.char_indices().collect(); + let mut out: Vec = Vec::new(); + let mut pending: Option<(Stress, usize)> = None; + let mut i = 0; + while i < chars.len() { + let at = chars[i].0; + let mark = match chars[i].1 { + 'ˈ' => Some(Stress::Primary), + 'ˌ' => Some(Stress::Secondary), + _ => None, + }; + if let Some(m) = mark { + if let Some((_, was)) = pending { + return Err(SegmentError::DanglingStress { at: was }); + } + pending = Some((m, at)); + i += 1; + continue; + } + let hit = (1..=self.longest.min(chars.len() - i)).rev().find_map(|len| { + let end = chars.get(i + len).map_or(ipa.len(), |c| c.0); + let s = &ipa[at..end]; + self.phones.get(s).map(|c| (len, s, c)) + }); + let Some((len, s, class)) = hit else { + return Err(SegmentError::UnknownPhone { at, text: ipa[at..].to_string() }); + }; + let stress = if matches!(class, Class::Vowel(_)) { + pending.take().map_or(Stress::None, |(m, _)| m) + } else { + Stress::None + }; + out.push(Phone { ipa: s.to_string(), stress }); + i += len; + } + if let Some((_, at)) = pending { + return Err(SegmentError::DanglingStress { at }); + } + Ok(out) + } +} diff --git a/crates/weksa/tests/cli.rs b/crates/weksa/tests/cli.rs index 72ad140..b2e5b58 100644 --- a/crates/weksa/tests/cli.rs +++ b/crates/weksa/tests/cli.rs @@ -876,6 +876,18 @@ fn proof_packet_lowers_and_the_lowering_scores_against_items() { let ipa = weksa(d.path(), &args); assert!(ipa.status.success(), "{}", out(&ipa)); assert_eq!(String::from_utf8(ipa.stdout).unwrap(), "ˈnibu\n"); + // The phones line is the committed fixture, byte for byte, and is a different + // output from --ipa, so the two flags cannot be combined. + let phones = weksa(d.path(), &["lower", "./p.cc", "packet:6e6962752d303031", "--phones"]); + assert!(phones.status.success(), "{}", out(&phones)); + let fixture = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../data/phonology/fixtures/nibu-en-US.phones.json"), + ) + .unwrap(); + assert_eq!(String::from_utf8(phones.stdout).unwrap(), fixture); + let both = weksa(d.path(), &["lower", "./p.cc", "packet:6e6962752d303031", "--phones", "--ipa"]); + assert!(!both.status.success(), "{}", out(&both)); let again = weksa(d.path(), &["lower", "./p.cc", "packet:6e6962752d303031"]); assert_eq!(String::from_utf8(again.stdout).unwrap(), text); diff --git a/crates/weksa/tests/lower.rs b/crates/weksa/tests/lower.rs index 4f1691c..342b25d 100644 --- a/crates/weksa/tests/lower.rs +++ b/crates/weksa/tests/lower.rs @@ -11,7 +11,7 @@ use weksa::inventory::proof_revision; use weksa::packet::{EntityId, FeatValue, FeatureValue, Packet, proof_packet}; use weksa::{ CorpusStore, EntityForms, EntityStore, Inventory, InventoryStore, LowerError, LoweringInput, - PacketStore, Pronunciation, lower, pronounce, validate, + PacketStore, PhoneInventory, PhoneLine, Pronunciation, Stress, lower, pronounce, validate, }; const PROOF: &str = "Warning: you may endanger yourself by touching the control. \ @@ -434,3 +434,140 @@ fn ipa_needs_an_entity_the_port_holds_and_a_packet_that_cites_one() { Err(LowerError::NoEntityReferent) ); } + +// ---- the phones line: the phone inventory and the segmenter ---- + +fn phones_of(ipa: &str) -> Result, LowerError> { + let mut n = nibu(&[("en-US", &["Nibu"])]); + n.forms[0].pronunciation = Some(ruled(ipa, &[])); + let mut e = Entities::default(); + e.held.push(n); + let (inv, _dir) = committed(); + weksa::phones(&view(&proof_packet(), &inv), &e) +} + +fn seg(ipa: &str) -> Vec<(String, Stress)> { + let inv = PhoneInventory::for_language("en-US").unwrap(); + inv.segment(ipa) + .unwrap() + .into_iter() + .map(|p| (p.ipa, p.stress)) + .collect() +} + +fn pairs(v: &[(&str, Stress)]) -> Vec<(String, Stress)> { + v.iter().map(|(i, s)| ((*i).to_string(), *s)).collect() +} + +#[test] +fn nibus_committed_ruling_lowers_to_the_committed_phones_fixture() { + let (inv, _dir) = committed(); + let lines = weksa::phones(&view(&proof_packet(), &inv), &committed_entities()).unwrap(); + let fixture = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../data/phonology/fixtures/nibu-en-US.phones.json"), + ) + .unwrap(); + assert_eq!(lines.len(), 1); + assert_eq!(serde_json::to_string(&lines[0]).unwrap(), fixture.trim_end()); +} + +#[test] +fn tense_i_is_a_phone_and_the_rejected_lax_form_is_a_different_one() { + use weksa::phones::{Class, Tenseness}; + let inv = PhoneInventory::for_language("en-US").unwrap(); + assert_eq!(inv.class("i"), Some(&Class::Vowel(Tenseness::Tense))); + assert_eq!(inv.class("ɪ"), Some(&Class::Vowel(Tenseness::Lax))); + assert_eq!(inv.class("u"), Some(&Class::Vowel(Tenseness::Tense))); + let ruled_form = seg("ˈnibu"); + let lax = seg("ˈnɪbu"); + assert_ne!(ruled_form, lax); + assert_eq!(lax[1], ("ɪ".to_string(), Stress::Primary)); + // The committed seed rejects the lax form, and it is not what the seed emits. + let seed = committed_entities(); + let nibu = seed.get(&EntityId::parse("entity:nibu").unwrap()).unwrap().unwrap(); + let p = nibu.pronunciation_in("en-US").unwrap(); + assert_eq!(p.rejected, ["ˈnɪbu"]); + assert_eq!(seg(&p.ipa), ruled_form); + assert!(p.rejected.iter().all(|r| seg(r) != seg(&p.ipa))); +} + +#[test] +fn stress_lands_on_the_vowel_after_the_mark_and_nowhere_else() { + use Stress::{None as N, Primary as P, Secondary as S}; + assert_eq!(seg("ˈnibu"), pairs(&[("n", N), ("i", P), ("b", N), ("u", N)])); + assert_eq!(seg("nˈibu"), seg("ˈnibu")); + assert_eq!(seg("bˈunˌi"), pairs(&[("b", N), ("u", P), ("n", N), ("i", S)])); + assert_eq!(seg("nibu"), pairs(&[("n", N), ("i", N), ("b", N), ("u", N)])); +} + +#[test] +fn multi_character_phones_are_one_phone_by_longest_match() { + use Stress::{None as N, Primary as P}; + assert_eq!(seg("ˈtʃeɪ"), pairs(&[("tʃ", N), ("eɪ", P)])); + assert_eq!(seg("dʒaʊ"), pairs(&[("dʒ", N), ("aʊ", N)])); + // /t/ then /ʃ/ is not what a lone "t" and "ʃ" mean when the affricate is listed, + // but a symbol sequence the inventory cannot join still splits. + assert_eq!(seg("ɔɪt"), pairs(&[("ɔɪ", N), ("t", N)])); +} + +#[test] +fn a_phone_the_inventory_lacks_is_refused_with_where_and_never_guessed() { + let id = EntityId::parse("entity:nibu").unwrap(); + for (ipa, at, text) in [ + ("ˈnixu", "ˈni".len(), "xu"), + ("ˈniːbu", "ˈni".len(), "ːbu"), + ("ˈnibu.", "ˈnibu".len(), "."), + ("ˈni bu", "ˈni".len(), " bu"), + ] { + assert_eq!( + phones_of(ipa), + Err(LowerError::UnknownPhone { entity: id.clone(), at, text: text.into() }), + "{ipa}" + ); + } +} + +#[test] +fn a_stress_mark_with_no_vowel_to_land_on_is_refused() { + let id = EntityId::parse("entity:nibu").unwrap(); + for (ipa, at) in [("nibuˈ", "nibu".len()), ("ˈˈnibu", 0), ("ˈnˈibu", 0), ("ˈnb", 0)] { + assert_eq!( + phones_of(ipa), + Err(LowerError::DanglingStress { entity: id.clone(), at }), + "{ipa}" + ); + } +} + +#[test] +fn phones_need_a_ruling_and_an_entity_exactly_as_ipa_does() { + assert_eq!( + weksa::phones(&view(&proof_packet(), &committed().0), &Entities::default()), + Err(LowerError::UnknownEntity { entity: EntityId::parse("entity:nibu").unwrap() }) + ); + let mut e = Entities::default(); + e.held.push(nibu(&[("en-US", &["Nibu"])])); + assert_eq!( + weksa::phones(&view(&proof_packet(), &committed().0), &e), + Err(LowerError::NoPronunciation { entity: EntityId::parse("entity:nibu").unwrap() }) + ); +} + +#[test] +fn the_inventory_parser_refuses_what_it_cannot_read() { + for bad in [ + "i\tvowel\ttense", + "i\tvowel\t-\tx", + "n\tconsonant\ttense\tx", + "i\tvowel\tmid\tx", + "ˈi\tvowel\ttense\tx", + "\tvowel\ttense\tx", + "i\tvowel\ttense\t", + "i\tvowel\ttense\ta\ni\tvowel\tlax\tb", + ] { + assert!(PhoneInventory::parse(bad).is_err(), "{bad:?}"); + } + assert!(PhoneInventory::parse("# c\n\ni\tvowel\ttense\ta\n").is_ok()); + assert!(PhoneInventory::for_language("sa-Latn").is_none()); +} diff --git a/data/phonology/en-US.phones.tsv b/data/phonology/en-US.phones.tsv new file mode 100644 index 0000000..b607e28 --- /dev/null +++ b/data/phonology/en-US.phones.tsv @@ -0,0 +1,46 @@ +# Weksa phone inventory, en-US (General American, broad). One phone per line, tab separated: +# ipa class tenseness gloss +# class: consonant | vowel. tenseness: tense | lax | reduced for a vowel, `-` for a consonant. A multi-character +# phone (affricate, diphthong) is one entry and one phone. The segmenter reads only this file: an IPA symbol with no +# line here is refused, never guessed. Tense/lax is the textbook classification; it is what separates /i/ (nee) from +# /ɪ/ (nih). Stress is not a phone: ˈ and ˌ are read by the segmenter and put on the vowel that follows. +p consonant - voiceless bilabial stop +b consonant - voiced bilabial stop +t consonant - voiceless alveolar stop +d consonant - voiced alveolar stop +k consonant - voiceless velar stop +g consonant - voiced velar stop +f consonant - voiceless labiodental fricative +v consonant - voiced labiodental fricative +θ consonant - voiceless dental fricative +ð consonant - voiced dental fricative +s consonant - voiceless alveolar fricative +z consonant - voiced alveolar fricative +ʃ consonant - voiceless postalveolar fricative +ʒ consonant - voiced postalveolar fricative +h consonant - voiceless glottal fricative +tʃ consonant - voiceless postalveolar affricate +dʒ consonant - voiced postalveolar affricate +m consonant - bilabial nasal +n consonant - alveolar nasal +ŋ consonant - velar nasal +l consonant - alveolar lateral approximant +ɹ consonant - alveolar approximant +j consonant - palatal approximant +w consonant - labial-velar approximant +i vowel tense close front unrounded (fleece, nee) +ɪ vowel lax near-close front unrounded (kit, nih) +ɛ vowel lax open-mid front unrounded (dress) +æ vowel lax near-open front unrounded (trap) +ɑ vowel tense open back unrounded (palm) +ɔ vowel tense open-mid back rounded (thought) +ʊ vowel lax near-close back rounded (foot) +u vowel tense close back rounded (goose, boo) +ʌ vowel lax open-mid back unrounded (strut) +ə vowel reduced schwa +ɚ vowel reduced r-coloured schwa +eɪ vowel tense face diphthong +oʊ vowel tense goat diphthong +aɪ vowel tense price diphthong +aʊ vowel tense mouth diphthong +ɔɪ vowel tense choice diphthong -- 2.47.3 From a7bc2478e53623ce572ac6d997ba058aa7707e44 Mon Sep 17 00:00:00 2001 From: Weksa Date: Sun, 8 Nov 2026 01:10:09 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Phone=20inventory:=20name=20/i/=20tense,=20?= =?UTF-8?q?/=C9=AA/=20lax=20and=20cite=20Nibu's=202026-11-07=20ruling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- data/phonology/en-US.phones.tsv | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/data/phonology/en-US.phones.tsv b/data/phonology/en-US.phones.tsv index b607e28..0730256 100644 --- a/data/phonology/en-US.phones.tsv +++ b/data/phonology/en-US.phones.tsv @@ -3,7 +3,11 @@ # class: consonant | vowel. tenseness: tense | lax | reduced for a vowel, `-` for a consonant. A multi-character # phone (affricate, diphthong) is one entry and one phone. The segmenter reads only this file: an IPA symbol with no # line here is refused, never guessed. Tense/lax is the textbook classification; it is what separates /i/ (nee) from -# /ɪ/ (nih). Stress is not a phone: ˈ and ˌ are read by the segmenter and put on the vowel that follows. +# /ɪ/ (nih). +# Provenance of the i/ɪ rows: /i/ is tense and /ɪ/ is lax. Nibu's own ruling of 2026-11-07 (source +# weksa-body:follow_up:chat-s3981-3983, #minds: tense vowel, two syllables, stress on the first) chose ˈnibu and rejected +# ˈnɪbu; the entity entry records it, this file is what makes the two forms two different phones. +# Stress is not a phone: ˈ and ˌ are read by the segmenter and put on the vowel that follows. p consonant - voiceless bilabial stop b consonant - voiced bilabial stop t consonant - voiceless alveolar stop -- 2.47.3