///|
fn parse_cli_int(text : String) -> Int? {
if text.length() == 0 {
return None
}
let mut value = 0
for ch in text {
let digit = ch.to_int() - '0'.to_int()
if digit < 0 || digit > 9 {
return None
}
value = value * 10 + digit
}
Some(value)
}
///|
fn cli_key(root_text : String, type_text : String) -> @theory.Key? {
let root_name = if root_text.length() > 0 {
let last = root_text[root_text.length() - 1]
let digit = last.to_int() - '0'.to_int()
if digit >= 0 && digit <= 9 {
root_text
} else {
root_text + "4"
}
} else {
"C4"
}
match (@theory.Note::parse(root_name), @theory.ScaleType::parse(type_text)) {
(Ok(note), Ok(scale)) =>
Some(@theory.Key::new(note.letter, note.accidental, scale))
(_, _) => None
}
}
///|
fn cli_demo_score(length : Int) -> @theory.Score raise Failure {
let score = @theory.Score::new(480)
let track = score.add_track("cli-fixture")
let roots = ["C4", "F4", "G4", "C4"]
let types = [
@theory.ChordType::MajorTriad,
@theory.ChordType::MajorTriad,
@theory.ChordType::DominantSeventh,
@theory.ChordType::MajorTriad,
]
let count = if length < 1 { 1 } else if length > 4 { 4 } else { length }
for i in 0.. Unit {
println("MoonBit Music Theory CLI Toolbox")
println("Usage:")
println(
" moon run src/cli -- chord - Query chord notes (e.g. Cmaj7)",
)
println(
" moon run src/cli -- scale - Generate scale notes (e.g. C major)",
)
println(
" moon run src/cli -- transpose - Transpose a note by an interval (e.g. C4 M3)",
)
println(
" moon run src/cli -- roman - Analyze roman numeral degree (e.g. C major G7)",
)
println(
" moon run src/cli -- progression - Generate progression with voice leading (e.g. C major I,vi,IV,V)",
)
println(
" moon run src/cli -- counterpoint - Check 1st species counterpoint rules (e.g. C4,D4,E4,D4,C4 C5,B4,G4,B4,C5)",
)
println(
" moon run src/cli -- analyze-chord - Detect chord name from notes list (e.g. C4,E4,G4)",
)
println(" moon run src/cli -- report ")
println(
" moon run src/cli -- report-json ",
)
println(" moon run src/cli -- midi-roundtrip ")
println(
" moon run src/cli -- generate ",
)
}
///|
fn main raise Failure {
let args = @env.args()
if args.length() < 2 {
print_usage()
return
}
let cmd = args[1]
match cmd {
"report" | "report-json" => {
if args.length() != 5 {
println("Error: report requires ")
return
}
match cli_key(args[2], args[3]) {
Some(key) => {
let symbols = args[4].split(",").collect()
let report = @theory.analyze_score(
cli_demo_score(symbols.length()),
key,
)
if cmd == "report-json" {
println(report.to_json().stringify())
} else {
println(report.to_text())
}
}
None => println("Error: invalid key root or scale type")
}
}
"midi-roundtrip" => {
if args.length() != 3 {
println("Error: midi-roundtrip requires ")
return
}
let midi = @theory.MidiFile::new(480)
let track = midi.add_track("cli-roundtrip")
let notes = [
@theory.Note::parse("C4").unwrap(),
@theory.Note::parse("E4").unwrap(),
@theory.Note::parse("G4").unwrap(),
]
track.add_chord(0, notes, 480, 90)
try {
let decoded = @theory.MidiFile::from_smf(midi.to_smf())
println(
"round_trip=true tracks=\{decoded.tracks.length()} events=\{decoded.tracks[0].events.length()}",
)
} catch {
err => println("Error: \{err.to_string()}")
}
}
"generate" => {
if args.length() != 6 {
println(
"Error: generate requires ",
)
return
}
match
(
cli_key(args[2], args[3]),
parse_cli_int(args[4]),
parse_cli_int(args[5]),
) {
(Some(key), Some(length), Some(seed)) if length > 0 => {
let cycle = ["I", "IV", "V", "I"]
let symbols : Array[String] = []
for i in 0.. println("seed=\{seed} symbols=\{symbols.join(",")}")
Err(err) => println("Error: \{err}")
}
}
(_, _, _) =>
println(
"Error: length and seed must be valid integers; length must be positive",
)
}
}
"chord" => {
if args.length() < 3 {
println("Error: Missing chord symbol")
print_usage()
return
}
let symbol = args[2]
match @theory.parse_chord_symbol(symbol) {
Ok((root, chord_type)) => {
let notes = @theory.generate_chord(root, chord_type)
let notes_strs = notes.map(fn(n) { n.to_string() })
// Format as JSON manually to be clean and simple
let mut notes_json = "["
for i in 0.. 0 {
notes_json = notes_json + ","
}
notes_json = notes_json + "\"" + notes_strs[i] + "\""
}
notes_json = notes_json + "]"
println(
"{\"root\":\"\{root}\",\"type\":\"\{chord_type}\",\"notes\":\{notes_json}}",
)
}
Err(err) => println("Error: \{err}")
}
}
"scale" => {
if args.length() < 4 {
println("Error: Missing scale root and type")
print_usage()
return
}
let root_str = args[2]
let type_str = args[3]
// parse root note
// If root note doesn't specify octave, default to 4
let root_note_str = if root_str.length() > 0 {
let last_char = root_str[root_str.length() - 1]
let is_digit = last_char.to_int() - '0'.to_int() >= 0 &&
last_char.to_int() - '0'.to_int() <= 9
if is_digit {
root_str
} else {
root_str + "4"
}
} else {
"C4"
}
let root = match @theory.Note::parse(root_note_str) {
Ok(n) => n
Err(err) => {
println("Error parsing root note: \{err}")
return
}
}
let scale_type = match @theory.ScaleType::parse(type_str) {
Ok(t) => t
Err(err) => {
println("Error parsing scale type: \{err}")
return
}
}
let notes = @theory.generate_scale(root, scale_type)
let notes_strs = notes.map(fn(n) { n.to_string() })
let mut notes_json = "["
for i in 0.. 0 {
notes_json = notes_json + ","
}
notes_json = notes_json + "\"" + notes_strs[i] + "\""
}
notes_json = notes_json + "]"
println(
"{\"root\":\"\{root}\",\"type\":\"\{scale_type}\",\"notes\":\{notes_json}}",
)
}
"transpose" => {
if args.length() < 4 {
println("Error: Missing note and interval")
print_usage()
return
}
let note_str = args[2]
let interval_str = args[3]
let note = match @theory.Note::parse(note_str) {
Ok(n) => n
Err(err) => {
println("Error parsing note: \{err}")
return
}
}
let interval = match @theory.Interval::parse(interval_str) {
Ok(inv) => inv
Err(err) => {
println("Error parsing interval: \{err}")
return
}
}
let res_note = note.transpose(interval)
println(
"{\"note\":\"\{note}\",\"interval\":\"\{interval}\",\"result\":\"\{res_note}\"}",
)
}
"roman" => {
if args.length() < 5 {
println("Error: Missing key_root, key_type and chord")
print_usage()
return
}
let key_root_str = args[2]
let key_type_str = args[3]
let chord_str = args[4]
let key_root_note_str = if key_root_str.length() > 0 {
let last_char = key_root_str[key_root_str.length() - 1]
let is_digit = last_char.to_int() - '0'.to_int() >= 0 &&
last_char.to_int() - '0'.to_int() <= 9
if is_digit {
key_root_str
} else {
key_root_str + "4"
}
} else {
"C4"
}
let key_root = match @theory.Note::parse(key_root_note_str) {
Ok(n) => n
Err(err) => {
println("Error parsing key root note: \{err}")
return
}
}
let key_scale_type = match @theory.ScaleType::parse(key_type_str) {
Ok(t) => t
Err(err) => {
println("Error parsing key scale type: \{err}")
return
}
}
let key = @theory.Key::new(
key_root.letter,
key_root.accidental,
key_scale_type,
)
match key.analyze_chord(chord_str) {
Ok(r) =>
println(
"{\"key\":\"\{key}\",\"chord\":\"\{chord_str}\",\"result\":\"\{r}\"}",
)
Err(err) => println("Error: \{err}")
}
}
"progression" => {
if args.length() < 5 {
println("Error: Missing key_root, key_type and progression csv")
print_usage()
return
}
let key_root_str = args[2]
let key_type_str = args[3]
let prog_csv = args[4]
let key_root_note_str = if key_root_str.length() > 0 {
let last_char = key_root_str[key_root_str.length() - 1]
let is_digit = last_char.to_int() - '0'.to_int() >= 0 &&
last_char.to_int() - '0'.to_int() <= 9
if is_digit {
key_root_str
} else {
key_root_str + "4"
}
} else {
"C4"
}
let key_root = match @theory.Note::parse(key_root_note_str) {
Ok(n) => n
Err(err) => {
println("Error parsing key root note: \{err}")
return
}
}
let key_scale_type = match @theory.ScaleType::parse(key_type_str) {
Ok(t) => t
Err(err) => {
println("Error parsing key scale type: \{err}")
return
}
}
let key = @theory.Key::new(
key_root.letter,
key_root.accidental,
key_scale_type,
)
let roman_numerals = prog_csv
.split(",")
.collect()
.map(fn(s) { s.to_owned() })
match @theory.generate_progression(key, roman_numerals) {
Ok(voicings) => {
let mut voicings_json = "["
for i in 0.. 0 {
voicings_json = voicings_json + ","
}
let notes_strs = voicings[i].map(fn(n) { n.to_string() })
let mut v_json = "["
for j in 0.. 0 {
v_json = v_json + ","
}
v_json = v_json + "\"" + notes_strs[j] + "\""
}
v_json = v_json + "]"
voicings_json = voicings_json + v_json
}
voicings_json = voicings_json + "]"
println(
"{\"key\":\"\{key}\",\"progression\":[\"\{prog_csv}\"],\"voicings\":\{voicings_json}}",
)
}
Err(err) => println("Error: \{err}")
}
}
"counterpoint" => {
if args.length() < 4 {
println("Error: Missing cantus firmus and counterpoint csv")
print_usage()
return
}
let cf_csv = args[2]
let cp_csv = args[3]
let cf_strs = cf_csv.split(",").collect()
let cp_strs = cp_csv.split(",").collect()
let cf_notes = []
for s in cf_strs {
match @theory.Note::parse(s.to_owned()) {
Ok(n) => cf_notes.push(n)
Err(err) => {
println("Error parsing cantus firmus note: \{err}")
return
}
}
}
let cp_notes = []
for s in cp_strs {
match @theory.Note::parse(s.to_owned()) {
Ok(n) => cp_notes.push(n)
Err(err) => {
println("Error parsing counterpoint note: \{err}")
return
}
}
}
let warnings = @theory.check_first_species(cf_notes, cp_notes)
let mut warnings_json = "["
for i in 0.. 0 {
warnings_json = warnings_json + ","
}
let w = warnings[i]
warnings_json = warnings_json +
"{\"index\":\{w.index},\"rule\":\"\{w.rule_name}\",\"message\":\"\{w.message}\"}"
}
warnings_json = warnings_json + "]"
println(
"{\"status\":\"" +
(if warnings.length() == 0 { "passed" } else { "warnings" }) +
"\",\"warnings\":\{warnings_json}}",
)
}
"analyze-chord" => {
if args.length() < 3 {
println("Error: Missing notes list csv")
print_usage()
return
}
let notes_csv = args[2]
let notes_strs = notes_csv.split(",").collect()
let notes = []
for s in notes_strs {
match @theory.Note::parse(s.to_owned()) {
Ok(n) => notes.push(n)
Err(err) => {
println("Error parsing note: \{err}")
return
}
}
}
match @theory.analyze_chord_notes(notes) {
Ok((root, ty, inv)) =>
println(
"{\"detected_root\":\"\{root}\",\"type\":\"\{ty}\",\"inversion\":\{inv}}",
)
Err(err) => println("Error: \{err}")
}
}
_ => {
println("Error: Unknown command \{cmd}")
print_usage()
}
}
}