Media

MIDI files

MIDI files read and written: each track's notes with their names, start and length in beats and seconds, tempo and time signature, instruments by General MIDI name; songs written from lists of notes; notes named and transposed.

Version 1.0.0 · 4 functions · 2 crates

#audio#files#sounds#games

Functions

What use midi.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use midi_read, midi_write from midi (Quillo 0.6).

midi_read(path: str, max_notes: int) → anyor an error

A MIDI file read: { format, ticks_per_beat, bpm, time_signature, duration, tracks }; each track { name, channel, program, instrument, notes }, each note { note, name, start, length, velocity, seconds } (start and length in beats, seconds when it starts, tempo changes counted). At most max_notes notes a track.

midi_write(song: any, path: str) → intor an error

A song written as a MIDI file at path (folders made): song is { bpm, time_signature: "3/4", tracks: [{ name, program, channel, notes: [{ note, start, length, velocity }] }] }, notes by number (60) or name ("C4", "F#3"), start and length in beats, channel 1 to 16 (10 for drums), program a General MIDI instrument 0 to 127. The number of notes written.

midi_note(note: any) → anyor an error

A note by number or name: { number, name, frequency }, "A4" is 69 at 440 Hz; flats come back as sharps ("Bb3" is "A#3").

midi_transpose(path: str, out_path: str, semitones: int) → intor an error

A MIDI file transposed by semitones (drums, on channel 10, left as they are) and written to out_path: the number of notes moved. Notes pushed past 0 or 127 are an error.

Get it

$ quillo install midi               # midi.qwer into this folder$ quillo install midi --example     # and its example, midi.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/midi.qwer$ curl -O https://quillolang.org/plugins/files/midi.qwe$ curl -O https://quillolang.org/plugins/files/midi_sample.mid

Then, in your program, all of it or only what you call:

main.qwe
use midi.qweruse midi_read, midi_write from midi      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
midly = "=0.5.3"serde_json = { version = "1", features = ["preserve_order"] }

Example

midi.qwe
// A music school's practice pieces: a MIDI file read for an on-screen piano roll, a scale written for practice, and the piece moved into another key.////   quillo run midi.qwe      then open http://localhost:8080/pieceuse "midi.qwer"let PIECE = "midi_sample.mid"route GET /piece {    let song = midi_read(PIECE, 1000)?    {        bpm: song.bpm,        time_signature: song.time_signature,        duration: song.duration,        tracks: song.tracks | map((t) => { name: t.name, instrument: t.instrument, notes: len(t.notes) }),        opening: slice(song.tracks[0].notes, 0, 3),        melody: join(song.tracks[0].notes | map((n) => n.name), " "),    }}// A C major scale, up and down, at 90 beats a minute: written, then read back.route GET /scale {    let up = ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5"]    let notes = (up + reverse(slice(up, 0, 7))) | enumerate | map((p) => { note: p[1], start: float(p[0]) * 0.5, length: 0.5, velocity: 80 })    let written = midi_write({ bpm: 90.0, tracks: [{ name: "Scale", program: 0, notes: notes }] }, "data/midi/scale.mid")?    let back = midi_read("data/midi/scale.mid", 100)?    { written: written, duration: back.duration, last: last(back.tracks[0].notes).name }}route GET /notes = {    a4: midi_note("A4")?,    middle_c: midi_note(60)?,    flat: midi_note("Bb3")?.name,    transposed: midi_transpose(PIECE, "data/midi/in-d.mid", 2)?,    in_d: join(midi_read("data/midi/in-d.mid", 4)?.tracks[0].notes | map((n) => n.name), " "),}
$ quillo run midi.qwe$ curl 'localhost:8080/piece'$ curl 'localhost:8080/scale'$ curl 'localhost:8080/notes'

Source

midi.qwer
//! MIDI files read and written: each track's notes with their names, start and length in beats and seconds, the tempo and time signature; songs written from lists of notes; notes named and transposed. For music teaching apps, generated ringtones and backing tracks.//! version: 1.0.0//!//! [dependencies]//! midly = "=0.5.3"//! serde_json = { version = "1", features = ["preserve_order"] }use midly::num::{u15, u24, u28, u4, u7};use midly::{Format, Header, MetaMessage, MidiMessage, Smf, Timing, TrackEvent, TrackEventKind};use serde_json::{json, Value};const NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];const PROGRAMS: [&str; 128] = ["Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano", "Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavinet", "Celesta", "Glockenspiel", "Music Box", "Vibraphone", "Marimba", "Xylophone", "Tubular Bells", "Dulcimer", "Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", "Reed Organ", "Accordion", "Harmonica", "Tango Accordion", "Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)", "Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar Harmonics", "Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass", "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2", "Violin", "Viola", "Cello", "Contrabass", "Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani", "String Ensemble 1", "String Ensemble 2", "Synth Strings 1", "Synth Strings 2", "Choir Aahs", "Voice Oohs", "Synth Voice", "Orchestra Hit", "Trumpet", "Trombone", "Tuba", "Muted Trumpet", "French Horn", "Brass Section", "Synth Brass 1", "Synth Brass 2", "Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", "Oboe", "English Horn", "Bassoon", "Clarinet", "Piccolo", "Flute", "Recorder", "Pan Flute", "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina", "Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)", "Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass + lead)", "Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)", "FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)", "Sitar", "Banjo", "Shamisen", "Koto", "Kalimba", "Bagpipe", "Fiddle", "Shanai", "Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", "Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal", "Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", "Telephone Ring", "Helicopter", "Applause", "Gunshot"];fn note_name(n: u8) -> String { format!("{}{}", NAMES[(n % 12) as usize], n as i32 / 12 - 1) }fn parse_note(v: &Value) -> Result<u8, String> {    if let Some(n) = v.as_i64() { return if (0..=127).contains(&n) { Ok(n as u8) } else { Err(format!("note {} is not 0 to 127", n)) } }    let s = v.as_str().ok_or_else(|| format!("{} is not a note", v))?.trim();    let mut c = s.chars();    let letter = c.next().ok_or("an empty note")?.to_ascii_uppercase();    let base = match letter { 'C' => 0, 'D' => 2, 'E' => 4, 'F' => 5, 'G' => 7, 'A' => 9, 'B' => 11, _ => return Err(format!("`{}` is not a note name (C4, F#3, Bb2)", s)) };    let rest: String = c.collect();    let (acc, oct) = if rest.starts_with('#') || rest.starts_with('♯') { (1, &rest[rest.chars().next().unwrap().len_utf8()..]) } else if rest.starts_with('b') || rest.starts_with('♭') { (-1, &rest[rest.chars().next().unwrap().len_utf8()..]) } else { (0, rest.as_str()) };    let octave: i32 = oct.parse().map_err(|_| format!("`{}` is not a note name (C4, F#3, Bb2)", s))?;    let n = (octave + 1) * 12 + base + acc;    if !(0..=127).contains(&n) { return Err(format!("`{}` is outside MIDI's range", s)) }    Ok(n as u8)}fn load(path: &str) -> Result<Vec<u8>, String> {    let m = std::fs::metadata(path).map_err(|e| format!("{}: {}", path, e))?;    if m.len() > 50 * 1024 * 1024 { return Err(format!("{}: larger than 50 MB", path)) }    std::fs::read(path).map_err(|e| format!("{}: {}", path, e))}/// A MIDI file read: `{ format, ticks_per_beat, bpm, time_signature, duration, tracks }`; each track `{ name, channel, program, instrument, notes }`, each note `{ note, name, start, length, velocity, seconds }` (`start` and `length` in beats, `seconds` when it starts, tempo changes counted). At most `max_notes` notes a track.pub fn midi_read(path: &str, max_notes: i64) -> Result<Value, String> {    let bytes = load(path)?;    let smf = Smf::parse(&bytes).map_err(|e| format!("{}: not a MIDI file: {}", path, e))?;    let tpb = match smf.header.timing { Timing::Metrical(t) => t.as_int() as f64, Timing::Timecode(_, _) => return Err("SMPTE-timed MIDI files are not read here".into()) };    // The tempo map, from every track.    let mut tempos: Vec<(u64, f64)> = Vec::new();    let mut time_sig = None;    for t in &smf.tracks {        let mut tick = 0u64;        for e in t {            tick += e.delta.as_int() as u64;            match e.kind {                TrackEventKind::Meta(MetaMessage::Tempo(us)) => tempos.push((tick, us.as_int() as f64)),                TrackEventKind::Meta(MetaMessage::TimeSignature(n, d, _, _)) if time_sig.is_none() => time_sig = Some(format!("{}/{}", n, 1u32 << d)),                _ => {}            }        }    }    tempos.sort_by_key(|t| t.0);    if tempos.first().map(|t| t.0 != 0).unwrap_or(true) { tempos.insert(0, (0, 500_000.0)) }    let seconds = |tick: u64| -> f64 {        let mut s = 0.0;        for (i, &(t0, us)) in tempos.iter().enumerate() {            let t1 = tempos.get(i + 1).map(|x| x.0).unwrap_or(u64::MAX);            if tick <= t0 { break }            s += (tick.min(t1) - t0) as f64 / tpb * us / 1e6;        }        s    };    let r3 = |x: f64| (x * 1000.0).round() / 1000.0;    let mut tracks = Vec::new();    let mut end_tick = 0u64;    for t in &smf.tracks {        let mut tick = 0u64;        let (mut name, mut program, mut channel) = (None, None, None);        let mut open: std::collections::HashMap<(u8, u8), (u64, u8)> = std::collections::HashMap::new();        let mut notes = Vec::new();        for e in t {            tick += e.delta.as_int() as u64;            match e.kind {                TrackEventKind::Meta(MetaMessage::TrackName(n)) if name.is_none() => name = Some(String::from_utf8_lossy(n).trim().to_string()),                TrackEventKind::Midi { channel: ch, message } => {                    if channel.is_none() { channel = Some(ch.as_int()) }                    match message {                        MidiMessage::ProgramChange { program: p } if program.is_none() => program = Some(p.as_int()),                        MidiMessage::NoteOn { key, vel } if vel.as_int() > 0 => { open.insert((ch.as_int(), key.as_int()), (tick, vel.as_int())); }                        MidiMessage::NoteOn { key, .. } | MidiMessage::NoteOff { key, .. } => {                            if let Some((start, vel)) = open.remove(&(ch.as_int(), key.as_int())) {                                if (notes.len() as i64) < max_notes {                                    let k = key.as_int();                                    notes.push((start, json!({ "note": k, "name": note_name(k), "start": r3(start as f64 / tpb), "length": r3((tick - start) as f64 / tpb), "velocity": vel, "seconds": r3(seconds(start)) })));                                }                                end_tick = end_tick.max(tick);                            }                        }                        _ => {}                    }                }                _ => {}            }        }        notes.sort_by_key(|n| n.0);        if notes.is_empty() && name.is_none() { continue }        let drums = channel == Some(9);        tracks.push(json!({            "name": name, "channel": channel.map(|c| c + 1), "program": program,            "instrument": if drums { Some("Drums".to_string()) } else { program.map(|p| PROGRAMS[(p as usize).min(127)].to_string()) },            "notes": notes.into_iter().map(|n| n.1).collect::<Vec<_>>(),        }));    }    Ok(json!({        "format": match smf.header.format { Format::SingleTrack => 0, Format::Parallel => 1, Format::Sequential => 2 },        "ticks_per_beat": tpb as i64,        "bpm": r3(60_000_000.0 / tempos[0].1),        "time_signature": time_sig.unwrap_or_else(|| "4/4".into()),        "duration": r3(seconds(end_tick)),        "tracks": tracks,    }))}/// A song written as a MIDI file at `path` (folders made): `song` is `{ bpm, time_signature: "3/4", tracks: [{ name, program, channel, notes: [{ note, start, length, velocity }] }] }`, notes by number (60) or name (`"C4"`, `"F#3"`), `start` and `length` in beats, `channel` 1 to 16 (10 for drums), `program` a General MIDI instrument 0 to 127. The number of notes written.pub fn midi_write(song: serde_json::Value, path: &str) -> Result<i64, String> {    let tpb: u16 = 480;    let bpm = song.get("bpm").and_then(|b| b.as_f64()).unwrap_or(120.0);    if !(10.0..=400.0).contains(&bpm) { return Err(format!("bpm is 10 to 400, not {}", bpm)) }    let (tn, td) = match song.get("time_signature").and_then(|t| t.as_str()).unwrap_or("4/4").split_once('/') {        Some((n, d)) => (n.trim().parse::<u8>().map_err(|_| "a bad time signature")?, d.trim().parse::<u32>().map_err(|_| "a bad time signature")?),        None => return Err("time_signature is like \"4/4\"".into()),    };    let tracks_in = song.get("tracks").and_then(|t| t.as_array()).ok_or("the song has `tracks`")?;    let mut smf = Smf::new(Header::new(Format::Parallel, Timing::Metrical(u15::new(tpb))));    let conductor = vec![        TrackEvent { delta: u28::new(0), kind: TrackEventKind::Meta(MetaMessage::Tempo(u24::new((60_000_000.0 / bpm).round() as u32))) },        TrackEvent { delta: u28::new(0), kind: TrackEventKind::Meta(MetaMessage::TimeSignature(tn, td.trailing_zeros() as u8, 24, 8)) },        TrackEvent { delta: u28::new(0), kind: TrackEventKind::Meta(MetaMessage::EndOfTrack) },    ];    smf.tracks.push(conductor);    let names: Vec<String> = tracks_in.iter().map(|t| t.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string()).collect();    let mut count = 0i64;    for (ti, t) in tracks_in.iter().enumerate() {        let ch = t.get("channel").and_then(|c| c.as_i64()).unwrap_or(ti as i64 + 1);        if !(1..=16).contains(&ch) { return Err(format!("track {}: channel is 1 to 16", ti + 1)) }        let ch = u4::new(ch as u8 - 1);        // (tick, order, event): note-offs before note-ons at the same tick.        let mut evs: Vec<(u64, u8, TrackEventKind)> = Vec::new();        if !names[ti].is_empty() { evs.push((0, 0, TrackEventKind::Meta(MetaMessage::TrackName(names[ti].as_bytes())))) }        if let Some(p) = t.get("program").and_then(|p| p.as_i64()) {            if !(0..=127).contains(&p) { return Err(format!("track {}: program is 0 to 127", ti + 1)) }            evs.push((0, 1, TrackEventKind::Midi { channel: ch, message: MidiMessage::ProgramChange { program: u7::new(p as u8) } }));        }        for n in t.get("notes").and_then(|n| n.as_array()).cloned().unwrap_or_default() {            let key = u7::new(parse_note(n.get("note").unwrap_or(&Value::Null))?);            let start = n.get("start").and_then(|s| s.as_f64()).ok_or("each note has a `start` in beats")?;            let length = n.get("length").and_then(|s| s.as_f64()).unwrap_or(1.0);            let vel = n.get("velocity").and_then(|v| v.as_i64()).unwrap_or(90).clamp(1, 127) as u8;            if start < 0.0 || length <= 0.0 { return Err("a note's start is 0 or more and its length above 0".into()) }            let (a, b) = ((start * tpb as f64).round() as u64, ((start + length) * tpb as f64).round() as u64);            evs.push((a, 3, TrackEventKind::Midi { channel: ch, message: MidiMessage::NoteOn { key, vel: u7::new(vel) } }));            evs.push((b.max(a + 1), 2, TrackEventKind::Midi { channel: ch, message: MidiMessage::NoteOff { key, vel: u7::new(64) } }));            count += 1;        }        evs.sort_by_key(|e| (e.0, e.1));        let mut last = 0u64;        let mut track: Vec<TrackEvent> = evs.into_iter().map(|(tick, _, kind)| { let d = tick - last; last = tick; TrackEvent { delta: u28::new(d as u32), kind } }).collect();        track.push(TrackEvent { delta: u28::new(0), kind: TrackEventKind::Meta(MetaMessage::EndOfTrack) });        smf.tracks.push(track);    }    if let Some(d) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(d).map_err(|e| e.to_string())?; }    smf.save(path).map_err(|e| format!("{}: {}", path, e))?;    Ok(count)}/// A note by number or name: `{ number, name, frequency }`, `"A4"` is 69 at 440 Hz; flats come back as sharps (`"Bb3"` is `"A#3"`).pub fn midi_note(note: serde_json::Value) -> Result<Value, String> {    let n = parse_note(&note)?;    Ok(json!({ "number": n, "name": note_name(n), "frequency": (440.0 * 2f64.powf((n as f64 - 69.0) / 12.0) * 100.0).round() / 100.0 }))}/// A MIDI file transposed by `semitones` (drums, on channel 10, left as they are) and written to `out_path`: the number of notes moved. Notes pushed past 0 or 127 are an error.pub fn midi_transpose(path: &str, out_path: &str, semitones: i64) -> Result<i64, String> {    let bytes = load(path)?;    let mut smf = Smf::parse(&bytes).map_err(|e| format!("{}: not a MIDI file: {}", path, e))?;    let mut moved = 0;    for t in smf.tracks.iter_mut() {        for e in t.iter_mut() {            if let TrackEventKind::Midi { channel, message } = &mut e.kind {                if channel.as_int() == 9 { continue }                let key = match message { MidiMessage::NoteOn { key, .. } | MidiMessage::NoteOff { key, .. } | MidiMessage::Aftertouch { key, .. } => key, _ => continue };                let k = key.as_int() as i64 + semitones;                if !(0..=127).contains(&k) { return Err(format!("a note would be moved to {}, outside 0 to 127", k)) }                *key = u7::new(k as u8);                if matches!(message, MidiMessage::NoteOn { .. }) { moved += 1 }            }        }    }    if let Some(d) = std::path::Path::new(out_path).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(d).map_err(|e| e.to_string())?; }    smf.save(out_path).map_err(|e| format!("{}: {}", out_path, e))?;    Ok(moved)}
Loudness loudness.qwer · 1.0.0Loudness of WAV audio as broadcasters measure it (EBU R128): integrated LUFS, loudness range and true peak; silences found; leading and trailing silence trimmed; and files normalised to -16, -14 or -23 LUFS under a peak limit.audiopodcastssoundsfiles7 views · 9 downloadsWAV wav.qwer · 1.0.0WAV files made and read: tones, melodies and silence for alerts and tests, joined end to end, and a file's length, peak and loudness.audiosoundsfilesalerts7 views · 7 downloadsAudio info audio_info.qwer · 1.0.0An audio file's length, bitrate, sample rate, tags and cover art, for MP3, FLAC, M4A, Ogg, WAV and AIFF.audiofilesuploadspodcasts6 views · 8 downloadsAudio tags audio_tags.qwer · 1.0.0Audio tags written as well as read, for MP3, FLAC, Ogg, M4A, WAV and AIFF: title, artist, album, track, year, genre, composer, lyrics, BPM and more, cover art set, and every tag stripped; written to a copy.audiofilespodcastsprivacy8 views · 7 downloadsAudio waveform audio_wave.qwer · 1.0.0A WAV file's waveform for an audio player: the loudness of each slice from 0 to 1, or drawn as an SVG of bars.audioplayerssvg6 views · 7 downloadsCUE sheets cue_sheet.qwer · 1.0.0CUE sheets read and written: REM comments, CATALOG, FILE lines, tracks with their titles, performers, ISRC, flags, PREGAP and POSTGAP, and indexes in mm:ss:ff at 75 frames a second, kept as exact frames. Each track's length given the audio's total, several files included, old Latin-1 and UTF-16 sheets read, and damage or oddities named by line.audiocdtrackschapters2 views · 7 downloads
Thread
77Useful? Like it, or add to its thread below.

Thread 0 posts

On MIDI files: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.