development
rs 703 lines 25.1 KB
Raw
1 //! Music theory tools: thin wrappers that format the deterministic engine in `crate::theory`.
2 //! All are read-only and run inline, so they never prompt for permission.
3
4 use anyhow::{Result, anyhow, bail};
5 use serde::Deserialize;
6 use serde_json::{Value, json};
7
8 use super::{ToolOutcome, function_def};
9 use crate::theory::chord::{self, Chord};
10 use crate::theory::interval::Interval;
11 use crate::theory::key::{self, Key, Kind};
12 use crate::theory::pitch::{Note, Pitch, cents_between, hz_to_midi, midi_to_hz, spell_pc};
13 use crate::theory::scale;
14 use crate::theory::tempo::{self, Feel};
15
16 pub fn definitions() -> Vec<Value> {
17 vec![
18 function_def(
19 "theory_scale",
20 "Spell a scale or mode from a root note, with intervals, key signature and the diatonic chords. Exact; use instead of computing from memory. Omit `scale` to list the supported scales.",
21 json!({
22 "type": "object",
23 "properties": {
24 "root": { "type": "string", "description": "Root note, e.g. C#, Bb" },
25 "scale": { "type": "string", "description": "e.g. major, dorian, harmonic minor, minor pentatonic, blues, altered" }
26 },
27 "required": ["root"]
28 }),
29 ),
30 function_def(
31 "theory_chord",
32 "Parse a chord symbol (F#m7b5, Bb13#11/D, C6/9) into spelled notes, formula, quality and inversion, OR identify chords from notes (first note is treated as the bass). Exact.",
33 json!({
34 "type": "object",
35 "properties": {
36 "symbol": { "type": "string", "description": "Chord symbol to spell" },
37 "notes": { "type": "array", "items": { "type": "string" }, "description": "Note names to identify, bass first" }
38 }
39 }),
40 ),
41 function_def(
42 "theory_transpose",
43 "Transpose chord symbols or note names with correct enharmonic spelling, by semitones or from one key to another.",
44 json!({
45 "type": "object",
46 "properties": {
47 "items": { "type": "string", "description": "Chord symbols or notes separated by spaces or bars, e.g. 'Dm7 G7 Cmaj7'" },
48 "semitones": { "type": "integer", "description": "Positive up, negative down" },
49 "from_key": { "type": "string", "description": "e.g. C major (use with to_key)" },
50 "to_key": { "type": "string", "description": "e.g. Db major" },
51 "prefer": { "type": "string", "enum": ["sharps", "flats"], "description": "Force accidental style when using semitones" }
52 },
53 "required": ["items"]
54 }),
55 ),
56 function_def(
57 "theory_progression",
58 "Analyse a chord progression (Roman numerals, function, secondary dominants, borrowed chords, ranked key candidates) or realize Roman numerals in a key as chord symbols.",
59 json!({
60 "type": "object",
61 "properties": {
62 "chords": { "type": "string", "description": "Chord symbols separated by spaces, bars or commas" },
63 "key": { "type": "string", "description": "Key to analyse in (optional for chords; required for numerals), e.g. C major, F# minor" },
64 "numerals": { "type": "string", "description": "Roman numerals to realize, e.g. 'ii7 V7 Imaj7' or 'V7/V'" }
65 }
66 }),
67 ),
68 function_def(
69 "theory_tempo",
70 "Tempo maths: note-value durations (straight, dotted, triplet) in ms and Hz for delays and LFOs, bar length for a time signature, sample counts, or the bpm of a loop from its length.",
71 json!({
72 "type": "object",
73 "properties": {
74 "bpm": { "type": "number" },
75 "time_signature": { "type": "string", "description": "e.g. 4/4, 6/8, 7/8 (default 4/4)" },
76 "sample_rate": { "type": "number", "description": "Adds sample counts, e.g. 44100 or 48000" },
77 "bars": { "type": "number", "description": "Loop length in bars (with duration_seconds, to derive bpm)" },
78 "duration_seconds": { "type": "number" },
79 "quarter_note_ms": { "type": "number", "description": "Derive bpm from a quarter-note time in ms" },
80 "note_value": { "type": "string", "description": "A single duration to answer, e.g. 1/8., 1/4t, 1/16 (. dotted, t triplet)" }
81 }
82 }),
83 ),
84 function_def(
85 "theory_pitch",
86 "Convert between note names, MIDI numbers and frequencies (A4 reference adjustable). For a frequency, returns the nearest note and cents offset.",
87 json!({
88 "type": "object",
89 "properties": {
90 "name": { "type": "string", "description": "e.g. A4, C#3, Bb2" },
91 "midi": { "type": "number" },
92 "hz": { "type": "number" },
93 "a4": { "type": "number", "description": "Reference tuning, default 440" }
94 }
95 }),
96 ),
97 ]
98 }
99
100 pub fn title(name: &str, args: &Value) -> String {
101 let s = |k: &str| args.get(k).and_then(Value::as_str);
102 match name {
103 "theory_scale" => format!(
104 "Scale: {} {}",
105 s("root").unwrap_or("?"),
106 s("scale").unwrap_or("")
107 )
108 .trim()
109 .to_string(),
110 "theory_chord" => match s("symbol") {
111 Some(c) => format!("Chord: {c}"),
112 None => "Identify chord".into(),
113 },
114 "theory_transpose" => format!("Transpose {}", s("items").unwrap_or("")),
115 "theory_progression" => match s("numerals") {
116 Some(n) => format!("Realize {n}"),
117 None => format!("Analyse {}", s("chords").unwrap_or("progression")),
118 },
119 "theory_tempo" => match args.get("bpm").and_then(Value::as_f64) {
120 Some(b) => format!("Tempo: {b} bpm"),
121 None => "Tempo".into(),
122 },
123 "theory_pitch" => "Pitch conversion".into(),
124 _ => name.to_string(),
125 }
126 }
127
128 pub fn execute(name: &str, args: Value) -> Result<ToolOutcome> {
129 let text = match name {
130 "theory_scale" => scale_tool(serde_json::from_value(args)?)?,
131 "theory_chord" => chord_tool(serde_json::from_value(args)?)?,
132 "theory_transpose" => transpose_tool(serde_json::from_value(args)?)?,
133 "theory_progression" => progression_tool(serde_json::from_value(args)?)?,
134 "theory_tempo" => tempo_tool(serde_json::from_value(args)?)?,
135 "theory_pitch" => pitch_tool(serde_json::from_value(args)?)?,
136 other => bail!("unknown tool `{other}`"),
137 };
138 Ok(ToolOutcome::ok(text))
139 }
140
141 fn join_names(notes: &[Note]) -> String {
142 notes.iter().map(|n| n.name()).collect::<Vec<_>>().join(" ")
143 }
144
145 #[derive(Deserialize)]
146 struct ScaleArgs {
147 root: String,
148 scale: Option<String>,
149 }
150
151 fn scale_tool(a: ScaleArgs) -> Result<String> {
152 let root = Note::parse(&a.root)?;
153 let Some(name) = a.scale else {
154 return Ok(format!("Supported scales: {}", scale::names().join(", ")));
155 };
156 let def = scale::parse_scale(&name)?;
157 let notes = def.spell(root);
158 let intervals = def.intervals();
159 let mut out = format!(
160 "{} {}\nNotes: {}\nFormula: {}\nIntervals: {}",
161 root.name(),
162 def.name,
163 join_names(&notes),
164 def.formula,
165 intervals
166 .iter()
167 .map(|i| i.name())
168 .collect::<Vec<_>>()
169 .join(" "),
170 );
171 if !def.aliases.is_empty() {
172 out.push_str(&format!("\nAlso called: {}", def.aliases.join(", ")));
173 }
174 if matches!(def.name, "major" | "natural minor") {
175 out.push_str(&format!(
176 "\nKey signature: {}",
177 scale::signature_text(scale::signature(&notes))
178 ));
179 }
180 if notes.len() == 7 {
181 let stack = |n: usize| -> Vec<String> {
182 (0..7)
183 .map(|i| {
184 let tones: Vec<Note> = (0..n).map(|k| notes[(i + 2 * k) % 7]).collect();
185 chord::identify(&tones)
186 .into_iter()
187 .find(|c| c.chord.bass.is_none() && c.chord.root.pc() == tones[0].pc())
188 .map_or_else(|| "n/a".into(), |c| c.chord.name())
189 })
190 .collect()
191 };
192 out.push_str(&format!("\nDiatonic triads: {}", stack(3).join(" ")));
193 out.push_str(&format!("\nDiatonic sevenths: {}", stack(4).join(" ")));
194 }
195 Ok(out)
196 }
197
198 #[derive(Deserialize)]
199 struct ChordArgs {
200 symbol: Option<String>,
201 notes: Option<Vec<String>>,
202 }
203
204 fn chord_report(c: &Chord) -> String {
205 let notes = c.notes();
206 let mut out = format!(
207 "{}\nNotes: {}\nFormula: {}\nQuality: {}",
208 c.name(),
209 join_names(&notes),
210 c.formula(),
211 c.quality(),
212 );
213 if let Some(b) = c.bass {
214 out.push_str(&format!(
215 "\nBass: {} ({})",
216 b.name(),
217 c.bass_description().unwrap_or_default()
218 ));
219 }
220 out
221 }
222
223 fn chord_tool(a: ChordArgs) -> Result<String> {
224 match (a.symbol, a.notes) {
225 (Some(sym), _) => Ok(chord_report(&Chord::parse(&sym)?)),
226 (None, Some(notes)) if !notes.is_empty() => {
227 let parsed = notes
228 .iter()
229 .map(|n| Note::parse(n))
230 .collect::<Result<Vec<_>>>()?;
231 let found = chord::identify(&parsed);
232 if found.is_empty() {
233 return Ok(format!(
234 "No common chord matches {}. It may be a cluster, a polychord or an incomplete voicing.",
235 join_names(&parsed)
236 ));
237 }
238 let lines: Vec<String> = found
239 .iter()
240 .take(6)
241 .enumerate()
242 .map(|(i, c)| format!("{}. {} ({})", i + 1, c.chord.name(), c.chord.quality()))
243 .collect();
244 Ok(format!(
245 "Notes: {} (bass {})\nCandidates, most likely first:\n{}",
246 join_names(&parsed),
247 parsed[0].name(),
248 lines.join("\n")
249 ))
250 }
251 _ => bail!("give either `symbol` or a non-empty `notes` list"),
252 }
253 }
254
255 #[derive(Deserialize)]
256 struct TransposeArgs {
257 items: String,
258 semitones: Option<i32>,
259 from_key: Option<String>,
260 to_key: Option<String>,
261 prefer: Option<String>,
262 }
263
264 /// Default spelling of an upward interval of `n` semitones (0..12): minor seconds, thirds,
265 /// sixths and sevenths, a perfect fourth/fifth, and a diminished fifth for the tritone.
266 fn default_interval(n: i32) -> (usize, i32) {
267 const TABLE: [(usize, i32); 12] = [
268 (0, 0),
269 (1, 1),
270 (1, 2),
271 (2, 3),
272 (2, 4),
273 (3, 5),
274 (4, 6),
275 (4, 7),
276 (5, 8),
277 (5, 9),
278 (6, 10),
279 (6, 11),
280 ];
281 TABLE[n.rem_euclid(12) as usize]
282 }
283
284 fn transpose_note(n: Note, steps: usize, semis: i32, prefer_flats: Option<bool>) -> Note {
285 if let Some(flats) = prefer_flats {
286 return spell_pc(n.pc() + semis, flats);
287 }
288 let t = n.up(steps, semis);
289 if t.acc.abs() > 1 {
290 spell_pc(t.pc(), n.acc < 0)
291 } else {
292 t
293 }
294 }
295
296 fn transpose_tool(a: TransposeArgs) -> Result<String> {
297 let (steps, semis, to_key) = match (&a.from_key, &a.to_key, a.semitones) {
298 (Some(f), Some(t), _) => {
299 let (from, to) = (Key::parse(f)?, Key::parse(t)?);
300 let steps = (to.tonic.letter as usize + 7 - from.tonic.letter as usize) % 7;
301 (steps, from.tonic.semis_to(to.tonic), Some(to))
302 }
303 (None, None, Some(n)) => {
304 let (steps, semis) = default_interval(n);
305 (steps, semis, None)
306 }
307 _ => bail!("give `semitones`, or both `from_key` and `to_key`"),
308 };
309 let prefer_flats = match a.prefer.as_deref() {
310 Some("flats") => Some(true),
311 Some("sharps") => Some(false),
312 None => None,
313 Some(other) => bail!("prefer must be sharps or flats, not `{other}`"),
314 };
315 let tokens = key::split_progression(&a.items);
316 if tokens.is_empty() {
317 bail!("`items` is empty");
318 }
319 let prefer = if to_key.is_some() { None } else { prefer_flats };
320 let out: Vec<String> = tokens
321 .iter()
322 .map(|t| {
323 let c = Chord::parse(t)?;
324 let root = transpose_note(c.root, steps, semis, prefer);
325 let bass = c.bass.map(|b| transpose_note(b, steps, semis, prefer));
326 Ok(c.with_root(root, bass).name())
327 })
328 .collect::<Result<_>>()?;
329 let interval = Interval::new(steps, semis).name();
330 let mut text = format!(
331 "Transposed up a {interval} ({semis} semitones):\n{}",
332 out.join(" ")
333 );
334 if let Some(k) = to_key {
335 text.push_str(&format!(
336 "\n{} key signature: {}",
337 k.name(),
338 scale::signature_text(k.signature())
339 ));
340 }
341 Ok(text)
342 }
343
344 #[derive(Deserialize)]
345 struct ProgressionArgs {
346 chords: Option<String>,
347 key: Option<String>,
348 numerals: Option<String>,
349 }
350
351 fn kind_note(k: Kind) -> &'static str {
352 match k {
353 Kind::Diatonic | Kind::DiatonicHarmonic => "",
354 _ => " *",
355 }
356 }
357
358 fn progression_tool(a: ProgressionArgs) -> Result<String> {
359 if let Some(numerals) = a.numerals {
360 let key = Key::parse(
361 a.key
362 .as_deref()
363 .ok_or_else(|| anyhow!("`key` is required with `numerals`"))?,
364 )?;
365 let tokens = key::split_progression(&numerals);
366 let rows: Vec<String> = tokens
367 .iter()
368 .map(|n| {
369 let c = key::realize(&key, n)?;
370 Ok(format!(
371 "| {n} | {} | {} |",
372 c.name(),
373 join_names(&c.notes())
374 ))
375 })
376 .collect::<Result<_>>()?;
377 return Ok(format!(
378 "In {} ({}):\n| Numeral | Chord | Notes |\n|---|---|---|\n{}",
379 key.name(),
380 scale::signature_text(key.signature()),
381 rows.join("\n")
382 ));
383 }
384 let Some(chords) = a.chords else {
385 bail!("give `chords` to analyse or `numerals` (with `key`) to realize");
386 };
387 let parsed: Vec<Chord> = key::split_progression(&chords)
388 .iter()
389 .map(|c| Chord::parse(c))
390 .collect::<Result<_>>()?;
391 if parsed.is_empty() {
392 bail!("`chords` is empty");
393 }
394 let guesses = key::detect(&parsed);
395 let mut out = String::new();
396 let used = match a.key {
397 Some(k) => {
398 let k = Key::parse(&k)?;
399 out.push_str(&format!("Analysing in {} (as requested).\n", k.name()));
400 k
401 }
402 None => {
403 let top = &guesses[0];
404 out.push_str(&format!(
405 "Most likely key: {} (confidence {:.0}%, {}/{} chords diatonic).\n",
406 top.key.name(),
407 top.confidence * 100.0,
408 top.diatonic,
409 parsed.len()
410 ));
411 let alts: Vec<String> = guesses[1..]
412 .iter()
413 .take(3)
414 .filter(|g| g.confidence > top.confidence - 0.35)
415 .map(|g| format!("{} ({:.0}%)", g.key.name(), g.confidence * 100.0))
416 .collect();
417 if !alts.is_empty() {
418 out.push_str(&format!("Alternatives: {}.\n", alts.join(", ")));
419 }
420 if guesses[1].key == top.key.relative() && top.confidence - guesses[1].confidence < 0.2
421 {
422 out.push_str(&format!(
423 "Note: {} and {} share the same notes; the choice rests on where the progression resolves.\n",
424 top.key.name(),
425 guesses[1].key.name()
426 ));
427 }
428 top.key
429 }
430 };
431 out.push_str("| Chord | Numeral | Function |\n|---|---|---|\n");
432 let mut any_outside = false;
433 for c in &parsed {
434 let an = key::analyze(&used, c);
435 any_outside |= !kind_note(an.kind).is_empty();
436 out.push_str(&format!(
437 "| {} | {} | {}{} |\n",
438 c.name(),
439 an.numeral,
440 an.role,
441 kind_note(an.kind)
442 ));
443 }
444 if any_outside {
445 out.push_str("\\* outside the plain diatonic scale of the key.\n");
446 }
447 Ok(out.trim_end().to_string())
448 }
449
450 #[derive(Deserialize)]
451 struct TempoArgs {
452 bpm: Option<f64>,
453 time_signature: Option<String>,
454 sample_rate: Option<f64>,
455 bars: Option<f64>,
456 duration_seconds: Option<f64>,
457 quarter_note_ms: Option<f64>,
458 note_value: Option<String>,
459 }
460
461 fn positive(name: &str, v: f64) -> Result<f64> {
462 if v.is_finite() && v > 0.0 {
463 Ok(v)
464 } else {
465 bail!("{name} must be a positive number")
466 }
467 }
468
469 fn tempo_tool(a: TempoArgs) -> Result<String> {
470 let (num, den) = tempo::parse_time_signature(a.time_signature.as_deref().unwrap_or("4/4"))?;
471 let mut derived = None;
472 let bpm = match (a.bpm, a.bars, a.duration_seconds, a.quarter_note_ms) {
473 (Some(b), ..) => positive("bpm", b)?,
474 (None, Some(bars), Some(secs), _) => {
475 let b = tempo::bpm_from_duration(
476 positive("bars", bars)?,
477 num,
478 den,
479 positive("duration_seconds", secs)?,
480 );
481 derived = Some(format!(
482 "{bars} bars of {num}/{den} in {secs} s is {b:.3} bpm (quarter-note beats)."
483 ));
484 b
485 }
486 (None, _, _, Some(ms)) => {
487 let b = 60_000.0 / positive("quarter_note_ms", ms)?;
488 derived = Some(format!("A {ms} ms quarter note is {b:.3} bpm."));
489 b
490 }
491 _ => bail!("give `bpm`, or `bars` with `duration_seconds`, or `quarter_note_ms`"),
492 };
493 let mut out = String::new();
494 if let Some(d) = derived {
495 out.push_str(&d);
496 out.push('\n');
497 }
498 let bar = tempo::bar_ms(bpm, num, den);
499 out.push_str(&format!(
500 "{bpm:.3} bpm in {num}/{den}: one bar = {bar:.2} ms ({:.3} s)",
501 bar / 1000.0
502 ));
503 if let Some(sr) = a.sample_rate {
504 let sr = positive("sample_rate", sr)?;
505 out.push_str(&format!(
506 " = {:.0} samples at {sr:.0} Hz",
507 tempo::samples(bar, sr)
508 ));
509 }
510 if let Some(nv) = &a.note_value {
511 let (den, feel) = tempo::parse_note_value(nv)?;
512 let ms = tempo::note_ms(bpm, den, feel);
513 out.push_str(&format!(
514 "\n{nv} at {bpm:.3} bpm = {ms:.2} ms ({:.3} Hz)",
515 tempo::hz_from_ms(ms)
516 ));
517 if let Some(sr) = a.sample_rate {
518 out.push_str(&format!(
519 " = {:.0} samples at {sr:.0} Hz",
520 tempo::samples(ms, sr)
521 ));
522 }
523 }
524 out.push_str("\n| Note | Straight | Dotted | Triplet |\n|---|---|---|---|\n");
525 for (label, den) in tempo::NOTE_VALUES {
526 let cell = |feel: Feel| {
527 let ms = tempo::note_ms(bpm, den, feel);
528 let mut s = format!("{ms:.2} ms ({:.3} Hz)", tempo::hz_from_ms(ms));
529 if let Some(sr) = a.sample_rate {
530 s.push_str(&format!(", {:.0} smp", tempo::samples(ms, sr)));
531 }
532 s
533 };
534 out.push_str(&format!(
535 "| {label} | {} | {} | {} |\n",
536 cell(Feel::Straight),
537 cell(Feel::Dotted),
538 cell(Feel::Triplet)
539 ));
540 }
541 Ok(out.trim_end().to_string())
542 }
543
544 #[derive(Deserialize)]
545 struct PitchArgs {
546 name: Option<String>,
547 midi: Option<f64>,
548 hz: Option<f64>,
549 a4: Option<f64>,
550 }
551
552 fn pitch_tool(a: PitchArgs) -> Result<String> {
553 let a4 = positive("a4", a.a4.unwrap_or(440.0))?;
554 let (midi, exact) = match (&a.name, a.midi, a.hz) {
555 (Some(n), ..) => (Pitch::parse(n)?.midi() as f64, true),
556 (None, Some(m), _) if m.is_finite() => (m, m.fract() == 0.0),
557 (None, None, Some(h)) => (hz_to_midi(positive("hz", h)?, a4), false),
558 _ => bail!("give one of `name`, `midi` or `hz`"),
559 };
560 let nearest = midi.round() as i32;
561 let hz = midi_to_hz(midi, a4);
562 let sharp = Pitch::from_midi(nearest, false);
563 let flat = Pitch::from_midi(nearest, true);
564 let names = if sharp.name() == flat.name() {
565 sharp.name()
566 } else {
567 format!("{} / {}", sharp.name(), flat.name())
568 };
569 let mut out = format!(
570 "MIDI {nearest} = {names}, {:.3} Hz (A4 = {a4} Hz)",
571 midi_to_hz(nearest as f64, a4)
572 );
573 if !exact {
574 let cents = cents_between(midi_to_hz(nearest as f64, a4), hz);
575 out =
576 format!("{hz:.3} Hz is nearest to {names} (MIDI {nearest}), {cents:+.1} cents. {out}");
577 }
578 Ok(out)
579 }
580
581 #[cfg(test)]
582 mod tests {
583 use super::*;
584
585 fn run(name: &str, args: Value) -> String {
586 execute(name, args).unwrap().text
587 }
588
589 #[test]
590 fn scale_output() {
591 let t = run("theory_scale", json!({"root": "C#", "scale": "major"}));
592 assert!(t.contains("Notes: C# D# E# F# G# A# B#"), "{t}");
593 assert!(t.contains("7 sharps"), "{t}");
594 assert!(t.contains("Diatonic triads: C# D#m E#m F# G# A#m"), "{t}");
595 assert!(
596 t.contains("Diatonic sevenths: C#maj7 D#m7 E#m7 F#maj7 G#7 A#m7"),
597 "{t}"
598 );
599 assert!(run("theory_scale", json!({"root": "C"})).contains("dorian"));
600 assert!(execute("theory_scale", json!({"root": "C", "scale": "nope"})).is_err());
601 }
602
603 #[test]
604 fn chord_output() {
605 let t = run("theory_chord", json!({"symbol": "Bb13#11/D"}));
606 assert!(t.contains("Notes: Bb D F Ab C E G"), "{t}");
607 assert!(t.contains("Formula: 1 3 5 b7 9 #11 13"), "{t}");
608 assert!(t.contains("first inversion"), "{t}");
609 let t = run("theory_chord", json!({"notes": ["C", "E", "G", "Bb"]}));
610 assert!(t.contains("1. C7"), "{t}");
611 assert!(execute("theory_chord", json!({})).is_err());
612 }
613
614 #[test]
615 fn transpose_by_key_keeps_spelling() {
616 let t = run(
617 "theory_transpose",
618 json!({"items": "Dm7 G7 Cmaj7", "from_key": "C major", "to_key": "Db major"}),
619 );
620 assert!(t.contains("Ebm7 Ab7 Dbmaj7"), "{t}");
621 assert!(t.contains("5 flats"), "{t}");
622 let t = run(
623 "theory_transpose",
624 json!({"items": "C F G7/B", "semitones": 2}),
625 );
626 assert!(t.contains("D G A7/C#"), "{t}");
627 let t = run(
628 "theory_transpose",
629 json!({"items": "Am F", "semitones": -3}),
630 );
631 assert!(t.contains("F#m D"), "{t}");
632 let t = run(
633 "theory_transpose",
634 json!({"items": "C", "semitones": 1, "prefer": "sharps"}),
635 );
636 assert!(t.contains("C#"), "{t}");
637 assert!(execute("theory_transpose", json!({"items": "C"})).is_err());
638 }
639
640 #[test]
641 fn progression_analysis_and_realization() {
642 let t = run("theory_progression", json!({"chords": "Dm7 G7 Cmaj7"}));
643 assert!(t.contains("Most likely key: C major"), "{t}");
644 assert!(t.contains("| Dm7 | ii7 |"), "{t}");
645 assert!(t.contains("| G7 | V7 | dominant |"), "{t}");
646 let t = run(
647 "theory_progression",
648 json!({"chords": "C A7 Dm G7", "key": "C"}),
649 );
650 assert!(t.contains("V7/ii"), "{t}");
651 let t = run(
652 "theory_progression",
653 json!({"numerals": "ii7 V7 Imaj7", "key": "Eb major"}),
654 );
655 assert!(
656 t.contains("Fm7") && t.contains("Bb7") && t.contains("Ebmaj7"),
657 "{t}"
658 );
659 assert!(execute("theory_progression", json!({"numerals": "V"})).is_err());
660 }
661
662 #[test]
663 fn tempo_output() {
664 let t = run("theory_tempo", json!({"bpm": 120, "sample_rate": 48000}));
665 assert!(t.contains("one bar = 2000.00 ms"), "{t}");
666 assert!(
667 t.contains("| 1/8 | 250.00 ms (4.000 Hz), 12000 smp | 375.00 ms"),
668 "{t}"
669 );
670 let t = run("theory_tempo", json!({"bars": 4, "duration_seconds": 8}));
671 assert!(t.contains("120.000 bpm"), "{t}");
672 let t = run("theory_tempo", json!({"bpm": 120, "note_value": "1/8."}));
673 assert!(t.contains("1/8. at 120.000 bpm = 375.00 ms"), "{t}");
674 assert!(execute("theory_tempo", json!({"bpm": 120, "note_value": "1/3"})).is_err());
675 assert!(execute("theory_tempo", json!({})).is_err());
676 assert!(execute("theory_tempo", json!({"bpm": -4})).is_err());
677 }
678
679 #[test]
680 fn pitch_output() {
681 let t = run("theory_pitch", json!({"name": "A4"}));
682 assert!(t.contains("MIDI 69 = A4, 440.000 Hz"), "{t}");
683 let t = run("theory_pitch", json!({"hz": 450}));
684 assert!(t.contains("A4") && t.contains("+38.9 cents"), "{t}");
685 let t = run("theory_pitch", json!({"midi": 61}));
686 assert!(t.contains("C#4 / Db4"), "{t}");
687 let t = run("theory_pitch", json!({"name": "A4", "a4": 432}));
688 assert!(t.contains("432.000 Hz"), "{t}");
689 assert!(execute("theory_pitch", json!({})).is_err());
690 }
691
692 #[test]
693 fn titles() {
694 assert_eq!(
695 title("theory_chord", &json!({"symbol": "Cm7"})),
696 "Chord: Cm7"
697 );
698 assert_eq!(
699 title("theory_scale", &json!({"root": "D", "scale": "dorian"})),
700 "Scale: D dorian"
701 );
702 }
703 }