music21/features/
language.rs1use std::collections::HashMap;
14#[cfg(feature = "language-detection")]
15use std::sync::OnceLock;
16
17use crate::defaults::FloatType;
18#[cfg(feature = "language-detection")]
19use crate::error::{Error, Result};
20
21pub const LANGUAGE_CODES: [&str; 7] = ["en", "fr", "it", "de", "cn", "la", "nl"];
24
25pub fn language_name(code: &str) -> Option<&'static str> {
27 Some(match code {
28 "en" => "English",
29 "fr" => "French",
30 "it" => "Italian",
31 "de" => "German",
32 "cn" => "Chinese",
33 "la" => "Latin",
34 "nl" => "Dutch",
35 _ => return None,
36 })
37}
38
39#[cfg(feature = "language-detection")]
40fn excerpt(code: &str) -> &'static str {
41 match code {
42 "en" => include_str!("language/en.txt"),
43 "fr" => include_str!("language/fr.txt"),
44 "it" => include_str!("language/it.txt"),
45 "de" => include_str!("language/de.txt"),
46 "cn" => include_str!("language/cn.txt"),
47 "la" => include_str!("language/la.txt"),
48 _ => include_str!("language/nl.txt"),
49 }
50}
51
52#[derive(Clone, Debug, Default, PartialEq)]
55pub struct Trigram {
56 counts: HashMap<(char, char), HashMap<char, u64>>,
57 length: FloatType,
58}
59
60impl Trigram {
61 pub fn from_text(text: &str) -> Self {
63 Self::counted(text.chars())
64 }
65
66 pub fn from_words<'a>(words: impl IntoIterator<Item = &'a str>) -> Self {
69 Self::counted(
70 words
71 .into_iter()
72 .flat_map(|word| word.trim().chars().chain(std::iter::once(' '))),
73 )
74 }
75
76 fn counted(letters: impl Iterator<Item = char>) -> Self {
77 let mut trigram = Self::default();
78 trigram.count(letters);
79 trigram
80 }
81
82 pub fn add_text(&mut self, text: &str) {
85 self.count(text.chars());
86 }
87
88 pub fn add_words<'a>(&mut self, words: impl IntoIterator<Item = &'a str>) {
91 self.count(
92 words
93 .into_iter()
94 .flat_map(|word| word.trim().chars().chain(std::iter::once(' '))),
95 );
96 }
97
98 fn count(&mut self, letters: impl Iterator<Item = char>) {
99 let mut pair = (' ', ' ');
100 for letter in letters {
101 *self
102 .counts
103 .entry(pair)
104 .or_default()
105 .entry(letter)
106 .or_insert(0) += 1;
107 pair = (pair.1, letter);
108 }
109 let total: u64 = self
110 .counts
111 .values()
112 .flat_map(HashMap::values)
113 .map(|count| count * count)
114 .sum();
115 self.length = (total as FloatType).powf(0.5);
117 }
118
119 pub fn length(&self) -> FloatType {
122 self.length
123 }
124
125 pub fn similarity(&self, other: &Trigram) -> FloatType {
128 let mut total: u64 = 0;
129 for (pair, ours) in &self.counts {
130 if let Some(theirs) = other.counts.get(pair) {
131 for (letter, count) in ours {
132 if let Some(their) = theirs.get(letter) {
133 total += count * their;
134 }
135 }
136 }
137 }
138 total as FloatType / (self.length * other.length)
139 }
140
141 pub fn distance(&self, other: &Trigram) -> FloatType {
143 1.0 - self.similarity(other)
144 }
145}
146
147#[cfg(feature = "language-detection")]
149fn known() -> &'static [Trigram; 7] {
150 static KNOWN: OnceLock<[Trigram; 7]> = OnceLock::new();
151 KNOWN.get_or_init(|| {
152 LANGUAGE_CODES.map(|code| Trigram::from_words(excerpt(code).split_whitespace()))
153 })
154}
155
156#[cfg(feature = "language-detection")]
168pub fn known_trigram(code: &str) -> Option<&'static Trigram> {
169 let at = LANGUAGE_CODES.iter().position(|known| *known == code)?;
170 Some(&known()[at])
171}
172
173#[cfg(feature = "language-detection")]
186pub fn most_likely_language(text: &str) -> Option<&'static str> {
187 if text.is_empty() {
188 return None;
189 }
190 let trigram = Trigram::from_text(text);
191 let mut best = None;
192 let mut least = 1.0;
193 for (code, known) in LANGUAGE_CODES.iter().zip(known()) {
194 let distance = known.distance(&trigram);
195 if distance < least {
196 best = Some(*code);
197 least = distance;
198 }
199 }
200 best
201}
202
203#[cfg(feature = "language-detection")]
211pub fn most_likely_language_numeric(text: &str) -> Result<usize> {
212 if text.is_empty() {
213 return Ok(0);
214 }
215 let code = most_likely_language(text).ok_or_else(|| {
216 Error::Text("got a language that was not in the codes; should not happen".to_string())
217 })?;
218 Ok(LANGUAGE_CODES
219 .iter()
220 .position(|known| *known == code)
221 .map_or(0, |at| at + 1))
222}
223
224#[cfg(test)]
225mod tests {
226 use super::Trigram;
227
228 #[test]
231 fn a_text_counted_twice_counts_double() {
232 let once = Trigram::from_text("abc");
233 let mut twice = Trigram::from_text("abc");
234 twice.add_text("abc");
235 assert_eq!(twice.length(), 2.0 * once.length());
236 assert!((twice.similarity(&once) - 1.0).abs() < 1e-12);
237
238 let mut words = Trigram::default();
239 words.add_words([" ab ", "c"]);
240 assert_eq!(words, Trigram::from_words(["ab", "c"]));
241 }
242}