Skip to main content

sound_to_patch/
music.rs

1//! The music dictionary for notes: scales, keys, chords, progressions,
2//! rhythm and technique words, each with a checkable note rule, and the
3//! parsers and functions that read them.
4//!
5//! The data lives in `dictionary/music/`, one JSON file per topic. The
6//! binary embeds those files and parses them once ([`dictionary`]); it
7//! reads nothing else from the dictionary. `cargo xtask reference` writes
8//! the reference files under `reference/core/` from the same data.
9//!
10//! - [`entry`]: the entry, its rule and its example fragments.
11//! - [`check`]: the rules every entry must meet, the coverage of the
12//!   tiers and the recomputed shares.
13//! - [`pitch`]: pitch classes, spelling and the three middle-C labels.
14//! - [`key`]: the key parser, Camelot and Open Key.
15//! - [`roman`]: Roman numerals as semitones.
16//! - [`chord`]: the chord-symbol parser.
17//! - [`scale`]: scale lookup and the snap rule.
18//! - [`rhythm`]: Euclidean patterns, swing and tick arithmetic.
19//!
20//! ```
21//! use sound_to_patch::music::{dictionary, key::parse_key};
22//!
23//! let phrygian = dictionary()?.entry("scale.phrygian").unwrap();
24//! assert_eq!(phrygian.word, "Phrygian");
25//! let reading = parse_key("8A")?;
26//! assert_eq!(reading.key.to_string(), "A minor");
27//! # Ok::<(), Box<dyn std::error::Error>>(())
28//! ```
29
30use std::collections::{BTreeMap, HashMap};
31use std::error::Error;
32use std::fmt;
33use std::sync::OnceLock;
34
35use serde::{Deserialize, Serialize};
36
37use crate::music::entry::{Entry, Group, Tag, Tier};
38
39pub mod check;
40pub mod chord;
41pub mod entry;
42pub mod key;
43pub mod pitch;
44pub mod rhythm;
45pub mod roman;
46pub mod scale;
47
48/// One topic file of the dictionary and the groups it may hold.
49#[derive(Clone, Copy, Debug)]
50pub struct Topic {
51    /// The file stem under `dictionary/music/`, and of the reference file.
52    pub name: &'static str,
53    /// A title for people.
54    pub title: &'static str,
55    /// The groups its entries may belong to.
56    pub groups: &'static [Group],
57    data: &'static str,
58}
59
60/// Every topic, in reference order.
61pub const TOPICS: [Topic; 10] = [
62    Topic {
63        name: "notation",
64        title: "Notes, keys and numerals",
65        groups: &[Group::Notation, Group::Key],
66        data: include_str!("../dictionary/music/notation.json"),
67    },
68    Topic {
69        name: "roles",
70        title: "Role pitch ranges",
71        groups: &[Group::Role],
72        data: include_str!("../dictionary/music/roles.json"),
73    },
74    Topic {
75        name: "scales",
76        title: "Scales, moods and outside notes",
77        groups: &[Group::Scale, Group::Mood, Group::Outside],
78        data: include_str!("../dictionary/music/scales.json"),
79    },
80    Topic {
81        name: "chords",
82        title: "Chords and symbols",
83        groups: &[Group::Chord],
84        data: include_str!("../dictionary/music/chords.json"),
85    },
86    Topic {
87        name: "voicing",
88        title: "Voicing and voice leading",
89        groups: &[Group::Voicing, Group::VoiceLeading],
90        data: include_str!("../dictionary/music/voicing.json"),
91    },
92    Topic {
93        name: "progressions",
94        title: "Progressions and harmony",
95        groups: &[Group::Progression, Group::Harmony],
96        data: include_str!("../dictionary/music/progressions.json"),
97    },
98    Topic {
99        name: "rhythm",
100        title: "Rhythm, swing and feel",
101        groups: &[
102            Group::Position,
103            Group::Swing,
104            Group::Feel,
105            Group::Rhythm,
106            Group::Euclid,
107        ],
108        data: include_str!("../dictionary/music/rhythm.json"),
109    },
110    Topic {
111        name: "technique",
112        title: "Articulation and technique",
113        groups: &[
114            Group::Articulation,
115            Group::Bass,
116            Group::Lead,
117            Group::Arp,
118            Group::Pad,
119        ],
120        data: include_str!("../dictionary/music/technique.json"),
121    },
122    Topic {
123        name: "phrase",
124        title: "Phrase structure",
125        groups: &[Group::Phrase],
126        data: include_str!("../dictionary/music/phrase.json"),
127    },
128    Topic {
129        name: "genres",
130        title: "Genre templates",
131        groups: &[Group::Genre],
132        data: include_str!("../dictionary/music/genres.json"),
133    },
134];
135
136const STATS: &str = include_str!("../dictionary/music/stats.json");
137const PLAN: &str = include_str!("../dictionary/music/plan.json");
138
139/// A share the dictionary states.
140#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct Stat {
143    /// Stable id.
144    pub id: String,
145    /// The set of shares that exclude each other, if any.
146    pub set: Option<String>,
147    /// What the share counts.
148    pub claim: String,
149    /// Percent, with the decimals the source gives.
150    pub share: f64,
151    /// The counted items, when the source gives counts.
152    pub count: Option<u64>,
153    /// All items, when the source gives counts.
154    pub total: Option<u64>,
155    /// Source ids.
156    pub source: Vec<String>,
157    /// How the share is known.
158    pub tag: Tag,
159}
160
161/// The parsed dictionary.
162#[derive(Debug)]
163pub struct Dictionary {
164    /// Every entry, topic by topic in file order.
165    pub entries: Vec<Entry>,
166    /// The stated shares.
167    pub stats: Vec<Stat>,
168    /// The planned entry count per group and tier.
169    pub plan: BTreeMap<Group, BTreeMap<Tier, u16>>,
170    topic_of: Vec<&'static str>,
171    by_id: HashMap<String, usize>,
172}
173
174impl Dictionary {
175    /// The entry with this id.
176    #[must_use]
177    pub fn entry(&self, id: &str) -> Option<&Entry> {
178        self.by_id.get(id).map(|&index| &self.entries[index])
179    }
180
181    /// The entries of one group, in file order.
182    pub fn group(&self, group: Group) -> impl Iterator<Item = &Entry> {
183        self.entries
184            .iter()
185            .filter(move |entry| entry.group == group)
186    }
187
188    /// The entries of one topic, in file order.
189    pub fn topic(&self, name: &str) -> impl Iterator<Item = &Entry> {
190        self.entries
191            .iter()
192            .zip(&self.topic_of)
193            .filter(move |(_, topic)| **topic == name)
194            .map(|(entry, _)| entry)
195    }
196
197    /// The stat with this id.
198    #[must_use]
199    pub fn stat(&self, id: &str) -> Option<&Stat> {
200        self.stats.iter().find(|stat| stat.id == id)
201    }
202}
203
204/// The dictionary, parsed once.
205///
206/// # Errors
207///
208/// [`MusicError::BrokenData`] when a data file does not parse or an entry
209/// breaks a rule of [`check::problems`].
210pub fn dictionary() -> Result<&'static Dictionary, MusicError> {
211    static DICTIONARY: OnceLock<Result<Dictionary, String>> = OnceLock::new();
212    DICTIONARY
213        .get_or_init(build)
214        .as_ref()
215        .map_err(|cause| MusicError::BrokenData(cause.clone()))
216}
217
218fn build() -> Result<Dictionary, String> {
219    let mut entries = Vec::new();
220    let mut topic_of = Vec::new();
221    for topic in TOPICS {
222        let parsed: Vec<Entry> = serde_json::from_str(topic.data)
223            .map_err(|error| format!("dictionary/music/{}.json: {error}", topic.name))?;
224        topic_of.extend(std::iter::repeat_n(topic.name, parsed.len()));
225        entries.extend(parsed);
226    }
227    let stats: Vec<Stat> = serde_json::from_str(STATS)
228        .map_err(|error| format!("dictionary/music/stats.json: {error}"))?;
229    let plan = serde_json::from_str(PLAN)
230        .map_err(|error| format!("dictionary/music/plan.json: {error}"))?;
231    let by_id = entries
232        .iter()
233        .enumerate()
234        .map(|(index, entry)| (entry.id.clone(), index))
235        .collect();
236    let dictionary = Dictionary {
237        entries,
238        stats,
239        plan,
240        topic_of,
241        by_id,
242    };
243    let problems = check::problems(&dictionary);
244    if problems.is_empty() {
245        Ok(dictionary)
246    } else {
247        Err(problems.join("; "))
248    }
249}
250
251/// A failure of the music dictionary or of one of its parsers.
252#[derive(Clone, Debug, PartialEq, Eq)]
253#[non_exhaustive]
254pub enum MusicError {
255    /// The embedded data does not parse or breaks a rule.
256    BrokenData(String),
257    /// A key name the grammar does not accept.
258    UnknownKey(String),
259    /// A note name the grammar does not accept, or a pitch outside 0 to
260    /// 127.
261    UnknownNote(String),
262    /// A Roman numeral the grammar does not accept.
263    UnknownNumeral(String),
264    /// A chord symbol outside the dictionary.
265    UnsupportedChord(String),
266    /// A scale id outside the dictionary.
267    UnknownScale(String),
268}
269
270impl fmt::Display for MusicError {
271    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272        match self {
273            Self::BrokenData(cause) => write!(f, "the music dictionary is broken: {cause}"),
274            Self::UnknownKey(text) => write!(
275                f,
276                "`{text}` is not a key name: write a letter with an optional sharp or flat and \
277                 major or minor, such as `A minor` or `Bb`, a Camelot code such as `8A`, or an \
278                 Open Key code such as `1m`; a mode such as dorian is a scale entry"
279            ),
280            Self::UnknownNote(text) => write!(
281                f,
282                "`{text}` is not a note name: write a letter, an optional sharp or flat and an \
283                 octave number, such as `C4` or `F#2`, or a MIDI number from 0 to 127"
284            ),
285            Self::UnknownNumeral(text) => write!(
286                f,
287                "`{text}` is not a Roman numeral: write I to VII, upper case for major and \
288                 lower case for minor, with an optional `b` or `#` before it"
289            ),
290            Self::UnsupportedChord(text) => write!(
291                f,
292                "chord symbol `{text}` is not in the music dictionary: write it with a \
293                 spelling the chord reference lists, such as `Cmaj7` or `Am7b5`"
294            ),
295            Self::UnknownScale(text) => write!(
296                f,
297                "`{text}` is not a scale id: use an id from the scale reference, such as \
298                 `scale.dorian`"
299            ),
300        }
301    }
302}
303
304impl Error for MusicError {}