Skip to main content

sound_to_patch/
words.rs

1//! The word dictionary: what a producer's sound word means.
2//!
3//! An entry says what a word such as "warm" or "plucky" means, how far
4//! listeners agree on it and which analyzer number checks it. The entries
5//! name no synth control; the per-synth references bind them to controls.
6//!
7//! The data lives in `dictionary/words/`: one file per axis, the listener
8//! numbers of `agreement.json` (written by `cargo xtask agreement`), the
9//! intensity ladder and the style and technique lists. The binary embeds
10//! them and parses them once ([`dictionary`]).
11//!
12//! A word with several senses has one entry per sense and one default.
13//! [`Words::pick`] takes the sense a named role selects, else the default.
14//! [`Words::question`] is the one short question that picks a sense.
15//!
16//! ```
17//! use sound_to_patch::words::dictionary;
18//!
19//! let words = dictionary()?;
20//! let dark = words.pick("dark", None).unwrap();
21//! assert_eq!(dark.agreement.n, 8);
22//! # Ok::<(), Box<dyn std::error::Error>>(())
23//! ```
24
25use std::collections::BTreeMap;
26use std::error::Error;
27use std::fmt;
28use std::sync::OnceLock;
29
30use serde::{Deserialize, Serialize};
31
32pub mod check;
33
34/// One axis file of the dictionary.
35#[derive(Clone, Copy, Debug)]
36pub struct AxisFile {
37    /// The axis.
38    pub axis: Axis,
39    data: &'static str,
40}
41
42/// Every axis file, in reference order.
43pub const AXES: [AxisFile; 8] = [
44    AxisFile {
45        axis: Axis::Tone,
46        data: include_str!("../dictionary/words/tone.json"),
47    },
48    AxisFile {
49        axis: Axis::Body,
50        data: include_str!("../dictionary/words/body.json"),
51    },
52    AxisFile {
53        axis: Axis::Texture,
54        data: include_str!("../dictionary/words/texture.json"),
55    },
56    AxisFile {
57        axis: Axis::Structure,
58        data: include_str!("../dictionary/words/structure.json"),
59    },
60    AxisFile {
61        axis: Axis::Space,
62        data: include_str!("../dictionary/words/space.json"),
63    },
64    AxisFile {
65        axis: Axis::Movement,
66        data: include_str!("../dictionary/words/movement.json"),
67    },
68    AxisFile {
69        axis: Axis::Envelope,
70        data: include_str!("../dictionary/words/envelope.json"),
71    },
72    AxisFile {
73        axis: Axis::Mood,
74        data: include_str!("../dictionary/words/mood.json"),
75    },
76];
77
78const AGREEMENT: &str = include_str!("../dictionary/words/agreement.json");
79const LADDER: &str = include_str!("../dictionary/words/ladder.json");
80const STYLE: &str = include_str!("../dictionary/words/style.json");
81const TECHNIQUE: &str = include_str!("../dictionary/words/technique.json");
82
83/// The kind of sound change a word names.
84#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
85#[serde(rename_all = "lowercase")]
86pub enum Axis {
87    /// Brightness and spectral balance.
88    Tone,
89    /// Weight and low end.
90    Body,
91    /// Grit, noise and distortion.
92    Texture,
93    /// Harmonic structure and resonance.
94    Structure,
95    /// Width, room and distance.
96    Space,
97    /// Change over time.
98    Movement,
99    /// Attack, decay and dynamics.
100    Envelope,
101    /// Mood and character, with no control.
102    Mood,
103}
104
105impl Axis {
106    /// The file stem under `dictionary/words/`.
107    #[must_use]
108    pub fn as_str(self) -> &'static str {
109        match self {
110            Self::Tone => "tone",
111            Self::Body => "body",
112            Self::Texture => "texture",
113            Self::Structure => "structure",
114            Self::Space => "space",
115            Self::Movement => "movement",
116            Self::Envelope => "envelope",
117            Self::Mood => "mood",
118        }
119    }
120}
121
122/// The agreement class of a word. Our thresholds: high from 0.45, medium
123/// from 0.20, else low; known only from [`KNOWN_FROM`] curves.
124#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "lowercase")]
126pub enum Class {
127    /// Agreement of 0.45 or more.
128    High,
129    /// Agreement from 0.20 to under 0.45.
130    Medium,
131    /// Agreement under 0.20.
132    Low,
133    /// Listener curves exist, but fewer than [`KNOWN_FROM`].
134    Unknown,
135    /// No listener curve holds the word.
136    #[serde(rename = "no data")]
137    NoData,
138}
139
140/// The fewest curves for a known class; fewer set the few-sample flag.
141pub const KNOWN_FROM: u32 = 8;
142
143impl Class {
144    /// The class of an agreement value, rounded to two decimals, over `n`
145    /// curves.
146    #[must_use]
147    pub fn of(value: Option<f64>, n: u32) -> Self {
148        match value {
149            _ if n == 0 => Self::NoData,
150            Some(value) if n >= KNOWN_FROM => {
151                if value >= 0.45 {
152                    Self::High
153                } else if value >= 0.20 {
154                    Self::Medium
155                } else {
156                    Self::Low
157                }
158            }
159            _ => Self::Unknown,
160        }
161    }
162}
163
164/// How far listeners agree on a word's EQ curve.
165#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
166#[serde(deny_unknown_fields)]
167pub struct Agreement {
168    /// The mean pairwise correlation of the word's curves, two decimals.
169    pub value: Option<f64>,
170    /// The number of curves.
171    pub n: u32,
172    /// The class of the value.
173    pub class: Class,
174}
175
176/// A word's effect on the five reverb parameters, in global standard
177/// deviations from the global mean.
178#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
179#[serde(deny_unknown_fields)]
180#[allow(missing_docs)]
181pub struct Effects {
182    pub delay_time: f64,
183    pub decay: f64,
184    pub stereo_spread: f64,
185    pub cutoff_freq: f64,
186    pub wet_gain: f64,
187}
188
189/// The listener reverb numbers of a word.
190#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct Reverb {
193    /// The number of reverb settings the word labels.
194    pub n: u32,
195    /// The word's variance over the global variance, averaged over the
196    /// parameters: lower is more shared, 1.0 is none.
197    pub spread_ratio: f64,
198    /// The mean effect per parameter.
199    pub effects: Effects,
200}
201
202/// The analyzer number that checks a sense.
203#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct Measure {
206    /// The field of the layer report, such as `centroid_over_f0`.
207    pub key: String,
208    /// The band of a `band_share` measure.
209    pub band: Option<String>,
210    /// `up` or `down`.
211    pub direction: String,
212}
213
214/// How many sources back a number or a meaning.
215#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
216pub enum Tag {
217    /// Two or more sources state it.
218    #[serde(rename = "source states")]
219    SourceStates,
220    /// One source states it.
221    #[serde(rename = "single source")]
222    SingleSource,
223    /// Only search excerpts back it.
224    #[serde(rename = "snippet")]
225    Snippet,
226    /// The listener data alone backs it.
227    #[serde(rename = "crowd data")]
228    CrowdData,
229    /// No source states it.
230    #[serde(rename = "inferred")]
231    Inferred,
232}
233
234/// A number of an entry with its sources.
235#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct Value {
238    /// What the number is.
239    pub what: String,
240    /// The low end, or the number.
241    pub low: f64,
242    /// The high end; equal to `low` for one number.
243    pub high: f64,
244    /// The unit, such as `Hz` or `cents`.
245    pub unit: String,
246    /// Source ids that state it.
247    pub sources: Vec<String>,
248    /// How many of them back it.
249    pub tag: Tag,
250}
251
252/// One sense of a producer word.
253#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
254#[serde(deny_unknown_fields)]
255pub struct Entry {
256    /// `<word>.<sense>`.
257    pub id: String,
258    /// The lead word of the cluster.
259    pub word: String,
260    /// Synonyms the entry covers.
261    pub aliases: Vec<String>,
262    /// The axis.
263    pub axis: Axis,
264    /// The sense id within the word.
265    pub sense: String,
266    /// The option text of this sense in the word's question.
267    pub label: Option<String>,
268    /// The plain meaning, with no number.
269    pub meaning: String,
270    /// The roles the sense applies to; empty for all.
271    pub roles: Vec<String>,
272    /// Senses this one cannot share a sound with.
273    pub conflicts: Vec<String>,
274    /// The sense taken when nothing else decides.
275    pub default: bool,
276    /// The listener agreement of the word.
277    pub agreement: Agreement,
278    /// Listener curves exist, but too few for a class.
279    pub few_sample: bool,
280    /// The listener reverb numbers of the word.
281    pub reverb: Option<Reverb>,
282    /// The analyzer number that checks the sense.
283    pub measure: Option<Measure>,
284    /// The numbers, each with its sources.
285    pub values: Vec<Value>,
286    /// `no control` for mood and character words.
287    pub control: Option<String>,
288    /// The card the word hints at, or `unmapped`.
289    pub card_hint: Option<String>,
290    /// Music dictionary entries the word points to.
291    #[serde(default)]
292    pub music: Vec<String>,
293    /// What backs the meaning.
294    pub tag: Tag,
295    /// The sources of the meaning.
296    pub sources: Vec<String>,
297}
298
299/// The listener numbers of one EQ word.
300#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct EqWord {
303    /// The lowercase word.
304    pub word: String,
305    /// The number of curves.
306    pub n: u32,
307    /// The agreement value, two decimals; none below two curves.
308    pub value: Option<f64>,
309    /// The class of the value.
310    pub class: Class,
311    /// Fewer curves than [`KNOWN_FROM`].
312    pub few_sample: bool,
313    /// The mean curve per band, from five curves.
314    pub bands: Option<BTreeMap<String, f64>>,
315    /// The band centre of the mean curve's peak, from five curves.
316    pub peak_hz: Option<u32>,
317}
318
319/// The listener reverb numbers of one word.
320#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
321#[serde(deny_unknown_fields)]
322pub struct ReverbWord {
323    /// The lowercase word.
324    pub word: String,
325    /// The number of settings, at least five.
326    pub n: u32,
327    /// See [`Reverb::spread_ratio`].
328    pub spread_ratio: f64,
329    /// See [`Reverb::effects`].
330    pub effects: Effects,
331}
332
333/// The derived listener numbers of `agreement.json`.
334#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
335#[serde(deny_unknown_fields)]
336pub struct Listeners {
337    /// The source id.
338    pub source: String,
339    /// The pinned revision of the listener data.
340    pub revision: String,
341    /// The tool versions that computed the numbers.
342    pub tools: BTreeMap<String, String>,
343    /// EQ curves read.
344    pub eq_curves: u32,
345    /// Reverb settings read.
346    pub reverb_rows: u32,
347    /// Distinct reverb parameter sets among them.
348    pub reverb_distinct_settings: u32,
349    /// Every EQ word.
350    pub eq: Vec<EqWord>,
351    /// Every reverb word with at least five settings.
352    pub reverb: Vec<ReverbWord>,
353}
354
355/// A row of the style or technique list.
356#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
357#[serde(deny_unknown_fields)]
358pub struct Ranked {
359    /// The rank in the published list.
360    pub rank: u32,
361    /// The lead word.
362    pub word: String,
363    /// The other words of the group.
364    pub aliases: Vec<String>,
365    /// Style words: `pending`, a card or `no hint`.
366    pub hint: Option<String>,
367    /// Technique words: the neutral control group, or none when unmapped.
368    pub group: Option<String>,
369}
370
371/// A modifier group of the intensity ladder.
372#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(deny_unknown_fields)]
374pub struct Modifier {
375    /// The modifier words, such as `slightly`.
376    pub words: Vec<String>,
377    /// `mild`, `medium`, `strong`, or `against` for one step back.
378    pub step: String,
379    /// Add a warning about harshness, muddiness or mono loss.
380    #[serde(default)]
381    pub warn: bool,
382}
383
384/// A sourced number that anchors one step of one kind of control.
385#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
386#[serde(deny_unknown_fields)]
387pub struct Anchor {
388    /// The kind of control, in neutral words, such as `eq gain`.
389    pub control: String,
390    /// `mild`, `medium`, `strong`, or `edge` where a source names a defect.
391    pub step: String,
392    /// The number and its sources.
393    pub value: Value,
394}
395
396/// The intensity ladder: which step a modifier picks, and the sourced
397/// anchors per kind of control.
398#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
399#[serde(deny_unknown_fields)]
400pub struct Ladder {
401    /// How the steps are known: no source quantifies the modifiers.
402    pub tag: Tag,
403    /// The rule in plain words.
404    pub meaning: String,
405    /// The modifier groups.
406    pub modifiers: Vec<Modifier>,
407    /// The anchors.
408    pub anchors: Vec<Anchor>,
409}
410
411/// The parsed word dictionary.
412#[derive(Debug)]
413pub struct Words {
414    /// Every entry, axis by axis in file order.
415    pub entries: Vec<Entry>,
416    /// The listener numbers.
417    pub listeners: Listeners,
418    /// The intensity ladder.
419    pub ladder: Ladder,
420    /// The style words in rank order.
421    pub style: Vec<Ranked>,
422    /// The technique words in rank order.
423    pub technique: Vec<Ranked>,
424}
425
426impl Words {
427    /// The entry with this id.
428    #[must_use]
429    pub fn entry(&self, id: &str) -> Option<&Entry> {
430        self.entries.iter().find(|entry| entry.id == id)
431    }
432
433    /// The senses of a word, found by its lead word or an alias.
434    #[must_use]
435    pub fn senses(&self, word: &str) -> Vec<&Entry> {
436        let word = word.to_lowercase();
437        let lead = self
438            .entries
439            .iter()
440            .find(|entry| entry.word == word || entry.aliases.contains(&word))
441            .map(|entry| entry.word.as_str());
442        self.entries
443            .iter()
444            .filter(|entry| Some(entry.word.as_str()) == lead)
445            .collect()
446    }
447
448    /// The sense to take: the one sense that lists `role`, else the
449    /// default.
450    #[must_use]
451    pub fn pick(&self, word: &str, role: Option<&str>) -> Option<&Entry> {
452        let senses = self.senses(word);
453        if let Some(role) = role {
454            let named: Vec<&&Entry> = senses
455                .iter()
456                .filter(|entry| entry.roles.iter().any(|r| r == role))
457                .collect();
458            if let [only] = named.as_slice() {
459                return Some(only);
460            }
461        }
462        senses.into_iter().find(|entry| entry.default)
463    }
464
465    /// The question that picks a sense, such as "Warm: more low-mid
466    /// weight (default), duller highs or both?". None for a word with one
467    /// sense.
468    #[must_use]
469    pub fn question(&self, word: &str) -> Option<String> {
470        let senses = self.senses(word);
471        if senses.len() < 2 {
472            return None;
473        }
474        let mut options: Vec<String> = Vec::new();
475        for entry in senses.iter().filter(|entry| entry.default) {
476            options.extend(entry.label.iter().map(|label| format!("{label} (default)")));
477        }
478        for entry in senses.iter().filter(|entry| !entry.default) {
479            options.extend(entry.label.iter().cloned());
480        }
481        let last = options.pop()?;
482        let mut lead = senses[0].word.clone();
483        if let Some(first) = lead.get_mut(..1) {
484            first.make_ascii_uppercase();
485        }
486        Some(format!("{lead}: {} or {last}?", options.join(", ")))
487    }
488
489    /// The listener numbers of an EQ word.
490    #[must_use]
491    pub fn eq(&self, word: &str) -> Option<&EqWord> {
492        self.listeners.eq.iter().find(|row| row.word == word)
493    }
494
495    /// The listener reverb numbers of a word.
496    #[must_use]
497    pub fn reverb(&self, word: &str) -> Option<&ReverbWord> {
498        self.listeners.reverb.iter().find(|row| row.word == word)
499    }
500}
501
502/// The word dictionary, parsed once.
503///
504/// # Errors
505///
506/// [`WordsError`] when a data file does not parse or an entry breaks a
507/// rule of [`check::problems`].
508pub fn dictionary() -> Result<&'static Words, WordsError> {
509    static WORDS: OnceLock<Result<Words, String>> = OnceLock::new();
510    WORDS
511        .get_or_init(build)
512        .as_ref()
513        .map_err(|cause| WordsError(cause.clone()))
514}
515
516fn build() -> Result<Words, String> {
517    let words = parse()?;
518    let problems = check::problems(&words);
519    if problems.is_empty() {
520        Ok(words)
521    } else {
522        Err(problems.join("; "))
523    }
524}
525
526/// Parses the embedded files without checking them.
527pub(crate) fn parse() -> Result<Words, String> {
528    fn read<T: serde::de::DeserializeOwned>(name: &str, data: &str) -> Result<T, String> {
529        serde_json::from_str(data).map_err(|error| format!("dictionary/words/{name}: {error}"))
530    }
531    let mut entries = Vec::new();
532    for file in AXES {
533        entries.extend(read::<Vec<Entry>>(
534            &format!("{}.json", file.axis.as_str()),
535            file.data,
536        )?);
537    }
538    let ladder = read("ladder.json", LADDER)?;
539    let listeners = read("agreement.json", AGREEMENT)?;
540    let style = read("style.json", STYLE)?;
541    let technique = read("technique.json", TECHNIQUE)?;
542    Ok(Words {
543        entries,
544        listeners,
545        ladder,
546        style,
547        technique,
548    })
549}
550
551/// The embedded word data does not parse or breaks a rule.
552#[derive(Clone, Debug, PartialEq, Eq)]
553pub struct WordsError(pub String);
554
555impl fmt::Display for WordsError {
556    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
557        write!(f, "the word dictionary is broken: {}", self.0)
558    }
559}
560
561impl Error for WordsError {}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    #[test]
568    fn the_class_follows_the_thresholds_and_the_curve_count() {
569        assert_eq!(Class::of(Some(0.45), 8), Class::High);
570        assert_eq!(Class::of(Some(0.44), 8), Class::Medium);
571        assert_eq!(Class::of(Some(0.20), 29), Class::Medium);
572        assert_eq!(Class::of(Some(0.19), 9), Class::Low);
573        assert_eq!(Class::of(Some(-0.03), 15), Class::Low);
574        assert_eq!(Class::of(Some(0.82), 7), Class::Unknown);
575        assert_eq!(Class::of(None, 1), Class::Unknown);
576        assert_eq!(Class::of(None, 0), Class::NoData);
577    }
578}