Skip to content

Commit dfba348

Browse files
authored
Merge pull request #2 from AACTools/feature/ipaconvert
feat: X-SAMPA / Praat / SIL / Branner → IPA phonetic-alphabet modifiers
2 parents e4c257f + f1a032d commit dfba348

5 files changed

Lines changed: 132 additions & 13 deletions

File tree

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ quick-xml = "0.37"
1515
serde = { version = "1.0", features = ["derive"] }
1616
serde_json = "1.0"
1717
thiserror = "1.0"
18+
ipa-translate = { version = "0.2", optional = true }
19+
20+
[features]
21+
default = ["phonetic-translation"]
22+
phonetic-translation = ["dep:ipa-translate"]
1823

1924
[dev-dependencies]
2025
# Removed problematic benchmark dependencies for now

src/formatters/ssml/base.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,14 @@ impl SsmlFormatterBase {
600600
}
601601
attrs
602602
})),
603+
"xsampa" | "praat" | "sil" | "branner" => {
604+
let translated = translate_to_ipa(&key.to_lowercase(), value)?;
605+
let mut attrs = vec![("alphabet".to_string(), "ipa".to_string())];
606+
if !translated.is_empty() {
607+
attrs.push(("ph".to_string(), translated));
608+
}
609+
Some(("phoneme".to_string(), attrs))
610+
}
603611
"sub" => {
604612
if !value.is_empty() {
605613
attributes.push(("alias".to_string(), value.to_string()));
@@ -735,6 +743,33 @@ impl SsmlFormatterBase {
735743
}
736744
}
737745

746+
/// Translate a phonetic-alphabet value to IPA.
747+
///
748+
/// Recognized keys (lowercase): `xsampa`, `praat`, `sil`, `branner`. Returns
749+
/// `None` for any unrecognized key, or for every key when the
750+
/// `phonetic-translation` feature is disabled — callers treat `None` as
751+
/// "drop this modifier."
752+
///
753+
/// Conversion is infallible (garbage in, garbage out) per the upstream crate.
754+
#[cfg(feature = "phonetic-translation")]
755+
pub fn translate_to_ipa(key: &str, value: &str) -> Option<String> {
756+
if value.is_empty() {
757+
return Some(String::new());
758+
}
759+
match key {
760+
"xsampa" => Some(ipa_translate::xsampa_to_ipa(value)),
761+
"praat" => Some(ipa_translate::praat_to_ipa(value)),
762+
"sil" => Some(ipa_translate::sil_to_ipa(value)),
763+
"branner" => Some(ipa_translate::branner_to_ipa(value)),
764+
_ => None,
765+
}
766+
}
767+
768+
#[cfg(not(feature = "phonetic-translation"))]
769+
pub fn translate_to_ipa(_key: &str, _value: &str) -> Option<String> {
770+
None
771+
}
772+
738773
pub fn format_attr_string_ordered(tag_name: &str, attributes: &TagAttrs) -> String {
739774
let fixed_order: Vec<&str> = match tag_name {
740775
"say-as" => vec!["interpret-as", "format"],
@@ -789,3 +824,64 @@ impl Formatter for SsmlFormatterBase {
789824
self.format_node_with_tags(node)
790825
}
791826
}
827+
828+
#[cfg(test)]
829+
mod phonetic_alphabet_tests {
830+
use super::*;
831+
use crate::formatters::base::FormatterOptions;
832+
833+
fn fmt() -> SsmlFormatterBase {
834+
SsmlFormatterBase::new(FormatterOptions::default())
835+
}
836+
837+
#[cfg(feature = "phonetic-translation")]
838+
#[test]
839+
fn xsampa_value_becomes_ipa_phoneme_tag() {
840+
let (tag, attrs) = fmt().attribute_to_tag("xsampa", "spitS").unwrap();
841+
assert_eq!(tag, "phoneme");
842+
assert_eq!(attrs_get(&attrs, "alphabet"), Some("ipa"));
843+
assert_eq!(attrs_get(&attrs, "ph"), Some("spitʃ"));
844+
}
845+
846+
#[cfg(feature = "phonetic-translation")]
847+
#[test]
848+
fn praat_sil_branner_all_emit_ipa_phoneme() {
849+
let cases = [
850+
("praat", r"p\rta\:ft\^h", "pɹaːtʰ"),
851+
("sil", "si=l", "sɪl"),
852+
("branner", "br&ae):nE&r^", "bɹæːnɜ˞"),
853+
];
854+
for (key, src, expected_ipa) in cases {
855+
let (tag, attrs) = fmt().attribute_to_tag(key, src).unwrap();
856+
assert_eq!(tag, "phoneme", "key {}", key);
857+
assert_eq!(attrs_get(&attrs, "alphabet"), Some("ipa"), "key {}", key);
858+
assert_eq!(
859+
attrs_get(&attrs, "ph"),
860+
Some(expected_ipa),
861+
"key {}",
862+
key
863+
);
864+
}
865+
}
866+
867+
#[cfg(not(feature = "phonetic-translation"))]
868+
#[test]
869+
fn phonetic_keys_dropped_when_feature_disabled() {
870+
for key in ["xsampa", "praat", "sil", "branner"] {
871+
assert!(
872+
fmt().attribute_to_tag(key, "anything").is_none(),
873+
"{} should be dropped without phonetic-translation feature",
874+
key
875+
);
876+
}
877+
}
878+
879+
#[cfg(feature = "phonetic-translation")]
880+
#[test]
881+
fn empty_value_emits_phoneme_without_ph_attr() {
882+
let (tag, attrs) = fmt().attribute_to_tag("xsampa", "").unwrap();
883+
assert_eq!(tag, "phoneme");
884+
assert_eq!(attrs_get(&attrs, "alphabet"), Some("ipa"));
885+
assert_eq!(attrs_get(&attrs, "ph"), None);
886+
}
887+
}

src/formatters/ssml/google_assistant.rs

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ impl GoogleAssistantSsmlFormatter {
2626
}
2727
"excited" | "disappointed" => None,
2828
"voice" | "lang" => None,
29-
"ipa" => None,
29+
"ipa" | "xsampa" | "praat" | "sil" | "branner" => None,
3030
"style" => {
3131
if !value.is_empty() {
3232
attributes.push(("name".to_string(), value.to_string()));
@@ -40,19 +40,24 @@ impl GoogleAssistantSsmlFormatter {
4040
fn format_google_text_modifier(&self, node: &AstNode) -> Result<String> {
4141
let mut tags: Vec<TagInfo> = Vec::new();
4242
let mut last_say_as: Option<TagInfo> = None;
43-
let mut has_ipa = false;
44-
let mut non_ipa_count = 0;
43+
let mut phonetic_key: Option<String> = None;
44+
let mut non_phonetic_count = 0;
4545

4646
for key in &node.attribute_keys {
4747
let value = match node.attributes.get(key) {
4848
Some(v) => v,
4949
None => continue,
5050
};
5151

52-
if key.to_lowercase() == "ipa" {
53-
has_ipa = true;
52+
if matches!(
53+
key.to_lowercase().as_str(),
54+
"ipa" | "xsampa" | "praat" | "sil" | "branner"
55+
) {
56+
if phonetic_key.is_none() {
57+
phonetic_key = Some(key.clone());
58+
}
5459
} else {
55-
non_ipa_count += 1;
60+
non_phonetic_count += 1;
5661
}
5762

5863
if let Some(tag_info) = self.google_attribute_to_tag(key, value) {
@@ -71,15 +76,13 @@ impl GoogleAssistantSsmlFormatter {
7176
}
7277
}
7378

74-
if has_ipa && non_ipa_count == 0 {
79+
if phonetic_key.is_some() && non_phonetic_count == 0 {
7580
return Ok(node.text.clone());
7681
}
7782

78-
if has_ipa {
79-
if let Some(ipa_tag) = self
80-
.base
81-
.attribute_to_tag("ipa", node.attributes.get("ipa").unwrap_or(&String::new()))
82-
{
83+
if let Some(key) = phonetic_key {
84+
let value = node.attributes.get(&key).cloned().unwrap_or_default();
85+
if let Some(ipa_tag) = self.base.attribute_to_tag(&key, &value) {
8386
tags.push(ipa_tag);
8487
}
8588
}

tests/integration_test.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,21 @@ fn test_all_test_cases() {
3131
.and_then(|n| n.to_str())
3232
.unwrap_or("unknown");
3333

34+
// Fixtures that exercise the phonetic-translation feature; only
35+
// run them when that feature is on.
36+
#[cfg(not(feature = "phonetic-translation"))]
37+
if matches!(
38+
test_name,
39+
"xsampa-standard"
40+
| "xsampa-stress"
41+
| "praat-standard"
42+
| "sil-standard"
43+
| "branner-standard"
44+
| "combo-xsampa-emphasis"
45+
) {
46+
continue;
47+
}
48+
3449
// Read the .smd input file
3550
let smd_file = test_dir.join(format!("{}.smd", test_name));
3651
if !smd_file.exists() {

tests/test-data

Submodule test-data updated 30 files

0 commit comments

Comments
 (0)