Reference
Cookbook
Spec: Web MIDI API
Short, self-contained recipes for the things you do every day with MIDI. Each one is shown in up to four ecosystems: the browser's Web MIDI API, Python with mido, C++ with RtMidi, and Rust with midir and midly. They open the first available port to stay short; real programs should let the user pick a port by name.
Setup
| Language | Library | Install |
|---|---|---|
| JavaScript (browser) | Web MIDI API | nothing to install (Chromium browsers, Firefox 108+, HTTPS only) |
| JavaScript (Node) | @tonejs/midi, midi-file | npm i @tonejs/midi midi-file |
| Python | mido + python-rtmidi backend | pip install mido python-rtmidi |
| C++ | RtMidi | brew install rtmidi · apt install librtmidi-dev · vcpkg install rtmidi |
| Rust | midir, midly | cargo add midir midly |
Web MIDI snippets
The browser examples use top-level await, so run them in a module script or the DevTools console.
Web MIDI needs a secure context (HTTPS or localhost) and a user permission prompt. You can try them
right now on the live monitor page's console.
Tested
Every snippet on this page was run on macOS against a virtual MIDI port: mido 1.3.3 with python-rtmidi 1.5.8,
@tonejs/midi 2.0.28, midi-file 1.2.4, RtMidi 6.0.0, midir 0.11.1 and midly 0.5.3. The Web MIDI snippets were
run in Node through the web-midi-api polyfill. Build the C++ examples with
c++ -std=c++17 main.cpp $(pkg-config --cflags --libs rtmidi), or compile
RtMidi.cpp alongside your file with the platform's API define (for example
-D__MACOSX_CORE__ -framework CoreMIDI -framework CoreAudio -framework CoreFoundation).
List MIDI ports
Enumerate every input and output the operating system exposes. Port names and order are platform-specific, so match by name rather than by index in real code.
See also: Transports
const access = await navigator.requestMIDIAccess();
for (const input of access.inputs.values()) {
console.log('in ', input.id, input.name, input.manufacturer);
}
for (const output of access.outputs.values()) {
console.log('out', output.id, output.name, output.manufacturer);
}
// Fires when devices are plugged in or removed.
access.onstatechange = (e) => console.log(e.port.type, e.port.name, e.port.state);
import mido
print('Inputs: ', mido.get_input_names())
print('Outputs:', mido.get_output_names())
#include <iostream>
#include "RtMidi.h"
int main() {
RtMidiIn in;
RtMidiOut out;
for (unsigned i = 0; i < in.getPortCount(); i++)
std::cout << "in " << i << ": " << in.getPortName(i) << "\n";
for (unsigned i = 0; i < out.getPortCount(); i++)
std::cout << "out " << i << ": " << out.getPortName(i) << "\n";
}
use midir::{MidiInput, MidiOutput};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = MidiInput::new("lister")?;
for p in input.ports() {
println!("in {}", input.port_name(&p)?);
}
let output = MidiOutput::new("lister")?;
for p in output.ports() {
println!("out {}", output.port_name(&p)?);
}
Ok(())
}
Monitor incoming messages
Print every incoming message as hex. The Web MIDI API and midir hand you one complete message per callback; you never have to reassemble running status yourself.
See also: Messages · Live monitor
const access = await navigator.requestMIDIAccess();
const hex = (data) => [...data].map((b) => b.toString(16).padStart(2, '0')).join(' ');
for (const input of access.inputs.values()) {
input.onmidimessage = (e) => {
console.log(input.name, e.timeStamp.toFixed(1), hex(e.data));
};
}
import mido
name = mido.get_input_names()[0]
with mido.open_input(name) as port:
for msg in port: # blocks; yields messages as they arrive
print(msg.hex(), msg)
#include <cstdio>
#include <iostream>
#include <vector>
#include "RtMidi.h"
void onMessage(double deltaSeconds, std::vector<unsigned char>* msg, void*) {
std::printf("%8.4f ", deltaSeconds);
for (unsigned char b : *msg) std::printf("%02X ", b);
std::printf("\n");
}
int main() {
RtMidiIn in;
if (in.getPortCount() == 0) return 1;
in.openPort(0);
in.setCallback(&onMessage);
in.ignoreTypes(false, true, true); // keep SysEx; drop clock and active sensing
std::cout << "Listening on " << in.getPortName(0) << ". Press Enter to quit.\n";
std::cin.get();
}
use midir::{Ignore, MidiInput};
use std::io::stdin;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut input = MidiInput::new("monitor")?;
input.ignore(Ignore::None); // receive SysEx, clock and active sensing too
let ports = input.ports();
let port = ports.first().ok_or("no input ports")?;
println!("Listening on {}", input.port_name(port)?);
let _conn = input.connect(port, "monitor-in", |stamp, message, _| {
let hex: Vec<String> = message.iter().map(|b| format!("{b:02X}")).collect();
println!("{stamp:>12} µs {}", hex.join(" "));
}, ())?;
stdin().read_line(&mut String::new())?; // keep the connection open
Ok(())
}
Send a note
Note On, wait half a second, Note Off. Status 0x90 is Note On on channel 1; OR the channel number (0–15) into the low nibble for other channels.
See also: Messages · Notes & Pitch
const access = await navigator.requestMIDIAccess();
const output = [...access.outputs.values()][0];
output.send([0x90, 60, 100]); // Note On, ch 1, middle C, velocity 100
output.send([0x80, 60, 0], performance.now() + 500); // Note Off, 500 ms later
send() takes an optional DOMHighResTimeStamp, so the browser schedules the Note Off for you.
import time
import mido
with mido.open_output(mido.get_output_names()[0]) as out:
out.send(mido.Message('note_on', note=60, velocity=100)) # channel=0 is MIDI channel 1
time.sleep(0.5)
out.send(mido.Message('note_off', note=60))
#include <chrono>
#include <thread>
#include <vector>
#include "RtMidi.h"
int main() {
RtMidiOut out;
if (out.getPortCount() == 0) return 1;
out.openPort(0);
std::vector<unsigned char> on{0x90, 60, 100}, off{0x80, 60, 0};
out.sendMessage(&on);
std::this_thread::sleep_for(std::chrono::milliseconds(500));
out.sendMessage(&off);
}
use midir::MidiOutput;
use std::{thread::sleep, time::Duration};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let out = MidiOutput::new("note")?;
let ports = out.ports();
let port = ports.first().ok_or("no output ports")?;
let mut conn = out.connect(port, "note-out")?;
conn.send(&[0x90, 60, 100])?;
sleep(Duration::from_millis(500));
conn.send(&[0x80, 60, 0])?;
Ok(())
}
Send a Control Change and a Program Change
Set channel volume, select a bank, then change program. Program numbers are 0–127 on the wire but 1–128 in the General MIDI list, so GM program 41 (Violin) is sent as 40.
See also: Control Change · General MIDI
const access = await navigator.requestMIDIAccess();
const out = [...access.outputs.values()][0];
const ch = 0; // channel 1
out.send([0xB0 | ch, 7, 100]); // CC 7 Channel Volume = 100
out.send([0xB0 | ch, 0, 0]); // CC 0 Bank Select MSB
out.send([0xB0 | ch, 32, 0]); // CC 32 Bank Select LSB
out.send([0xC0 | ch, 40]); // Program Change: GM program 41, Violin
import mido
with mido.open_output(mido.get_output_names()[0]) as out:
out.send(mido.Message('control_change', channel=0, control=7, value=100))
out.send(mido.Message('control_change', channel=0, control=0, value=0))
out.send(mido.Message('control_change', channel=0, control=32, value=0))
out.send(mido.Message('program_change', channel=0, program=40)) # GM 41, Violin
#include <vector>
#include "RtMidi.h"
int main() {
RtMidiOut out;
if (out.getPortCount() == 0) return 1;
out.openPort(0);
const unsigned char ch = 0; // channel 1
std::vector<std::vector<unsigned char>> msgs{
{(unsigned char)(0xB0 | ch), 7, 100}, // Channel Volume
{(unsigned char)(0xB0 | ch), 0, 0}, // Bank Select MSB
{(unsigned char)(0xB0 | ch), 32, 0}, // Bank Select LSB
{(unsigned char)(0xC0 | ch), 40}, // Program Change (GM 41, Violin)
};
for (auto& m : msgs) out.sendMessage(&m);
}
use midir::MidiOutput;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let out = MidiOutput::new("cc")?;
let ports = out.ports();
let mut conn = out.connect(ports.first().ok_or("no output ports")?, "cc-out")?;
let ch: u8 = 0; // channel 1
conn.send(&[0xB0 | ch, 7, 100])?; // Channel Volume
conn.send(&[0xB0 | ch, 0, 0])?; // Bank Select MSB
conn.send(&[0xB0 | ch, 32, 0])?; // Bank Select LSB
conn.send(&[0xC0 | ch, 40])?; // Program Change (GM 41, Violin)
Ok(())
}
Set pitch-bend range with RPN 0
Select RPN 0,0 (Pitch Bend Sensitivity) with CC 101/100, write semitones to Data Entry MSB (CC 6) and cents to the LSB (CC 38), then send the null RPN (127,127) so a stray CC 6 later cannot change it.
See also: RPN & NRPN · MPE
function setBendRange(out, ch, semitones, cents = 0) {
const cc = (num, value) => out.send([0xB0 | ch, num, value]);
cc(101, 0); cc(100, 0); // select RPN 0,0: Pitch Bend Sensitivity
cc(6, semitones); cc(38, cents); // Data Entry MSB = semitones, LSB = cents
cc(101, 127); cc(100, 127); // null RPN: deselect
}
const access = await navigator.requestMIDIAccess();
setBendRange([...access.outputs.values()][0], 0, 12); // ±12 semitones on channel 1
import mido
def set_bend_range(out, channel, semitones, cents=0):
for control, value in [(101, 0), (100, 0), # select RPN 0,0
(6, semitones), (38, cents), # Data Entry MSB / LSB
(101, 127), (100, 127)]: # null RPN
out.send(mido.Message('control_change', channel=channel,
control=control, value=value))
with mido.open_output(mido.get_output_names()[0]) as out:
set_bend_range(out, 0, 12)
#include <vector>
#include "RtMidi.h"
void setBendRange(RtMidiOut& out, unsigned char ch, unsigned char semitones,
unsigned char cents = 0) {
const unsigned char pairs[6][2] = {{101, 0}, {100, 0}, // select RPN 0,0
{6, semitones}, {38, cents}, // Data Entry
{101, 127}, {100, 127}}; // null RPN
for (auto& p : pairs) {
std::vector<unsigned char> m{(unsigned char)(0xB0 | ch), p[0], p[1]};
out.sendMessage(&m);
}
}
int main() {
RtMidiOut out;
if (out.getPortCount() == 0) return 1;
out.openPort(0);
setBendRange(out, 0, 12);
}
use midir::{MidiOutput, MidiOutputConnection, SendError};
fn set_bend_range(conn: &mut MidiOutputConnection, ch: u8, semitones: u8, cents: u8)
-> Result<(), SendError> {
for (cc, value) in [(101, 0), (100, 0), (6, semitones), (38, cents), (101, 127), (100, 127)] {
conn.send(&[0xB0 | ch, cc, value])?;
}
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let out = MidiOutput::new("rpn")?;
let ports = out.ports();
let mut conn = out.connect(ports.first().ok_or("no output ports")?, "rpn-out")?;
set_bend_range(&mut conn, 0, 12, 0)?;
Ok(())
}
Ask a device who it is (SysEx Identity Request)
Send the Universal Non-Real Time Identity Request F0 7E 7F 06 01 F7 and wait for an Identity Reply (sub-IDs 06 02). Not every device answers; give up after a timeout.
See also: System Exclusive
const access = await navigator.requestMIDIAccess({ sysex: true });
const input = [...access.inputs.values()][0];
const output = [...access.outputs.values()][0];
input.onmidimessage = ({ data }) => {
if (data[0] === 0xF0 && data[1] === 0x7E && data[3] === 0x06 && data[4] === 0x02) {
const hex = [...data].map((b) => b.toString(16).padStart(2, '0')).join(' ');
console.log('Identity Reply:', hex);
}
};
output.send([0xF0, 0x7E, 0x7F, 0x06, 0x01, 0xF7]);
SysEx needs explicit permission: request access with { sysex: true }.
import time
import mido
inp = mido.open_input(mido.get_input_names()[0])
out = mido.open_output(mido.get_output_names()[0])
out.send(mido.Message('sysex', data=[0x7E, 0x7F, 0x06, 0x01])) # mido adds F0/F7
deadline = time.time() + 2
while time.time() < deadline:
for msg in inp.iter_pending():
if msg.type == 'sysex' and msg.data[0] == 0x7E and list(msg.data[2:4]) == [0x06, 0x02]:
print('Identity Reply:', msg.hex())
deadline = 0
time.sleep(0.01)
#include <chrono>
#include <cstdio>
#include <thread>
#include <vector>
#include "RtMidi.h"
int main() {
RtMidiIn in;
RtMidiOut out;
if (in.getPortCount() == 0 || out.getPortCount() == 0) return 1;
in.openPort(0);
out.openPort(0);
in.ignoreTypes(false, true, true); // SysEx is ignored by default
std::vector<unsigned char> req{0xF0, 0x7E, 0x7F, 0x06, 0x01, 0xF7}, msg;
out.sendMessage(&req);
for (int i = 0; i < 200; i++) { // poll for up to ~2 s
in.getMessage(&msg);
if (msg.size() > 5 && msg[0] == 0xF0 && msg[1] == 0x7E && msg[3] == 0x06 && msg[4] == 0x02) {
for (unsigned char b : msg) std::printf("%02X ", b);
std::printf("\n");
return 0;
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return 2; // no reply
}
use midir::{Ignore, MidiInput, MidiOutput};
use std::{sync::mpsc, time::Duration};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut input = MidiInput::new("ident")?;
input.ignore(Ignore::None); // don't drop SysEx
let in_ports = input.ports();
let (tx, rx) = mpsc::channel();
let _in = input.connect(in_ports.first().ok_or("no inputs")?, "ident-in",
move |_, msg, _| { let _ = tx.send(msg.to_vec()); }, ())?;
let output = MidiOutput::new("ident")?;
let out_ports = output.ports();
let mut out = output.connect(out_ports.first().ok_or("no outputs")?, "ident-out")?;
out.send(&[0xF0, 0x7E, 0x7F, 0x06, 0x01, 0xF7])?;
while let Ok(msg) = rx.recv_timeout(Duration::from_secs(2)) {
if msg.len() > 5 && msg[0] == 0xF0 && msg[1] == 0x7E && msg[3] == 0x06 && msg[4] == 0x02 {
println!("Identity Reply: {:02X?}", msg);
break;
}
}
Ok(())
}
Read a .mid file and print notes in seconds
Converting ticks to seconds needs the tempo map. mido and @tonejs/midi do it for you; with midly you walk the events and apply Set Tempo yourself.
See also: Standard MIDI Files · Timing
// Node.js. In the browser, pass an ArrayBuffer from fetch() instead.
const { Midi } = require('@tonejs/midi'); // CommonJS package: no named ESM import in Node
const { readFileSync } = require('node:fs');
const midi = new Midi(readFileSync('song.mid'));
for (const track of midi.tracks) {
for (const note of track.notes) {
console.log(
note.time.toFixed(3) + 's',
'ch' + (track.channel + 1),
note.midi, note.name,
'dur', note.duration.toFixed(3),
);
}
}
import mido
mid = mido.MidiFile('song.mid')
t = 0.0
for msg in mid: # tracks merged; msg.time is the delta in seconds (tempo-aware)
t += msg.time
if msg.type == 'note_on' and msg.velocity > 0:
print(f'{t:8.3f}s ch{msg.channel + 1} note {msg.note} vel {msg.velocity}')
use midly::{MetaMessage, MidiMessage, Smf, Timing, TrackEventKind};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let bytes = std::fs::read("song.mid")?;
let smf = Smf::parse(&bytes)?;
let ppq = match smf.header.timing {
Timing::Metrical(t) => t.as_int() as f64,
Timing::Timecode(..) => return Err("SMPTE timing not handled here".into()),
};
for (i, track) in smf.tracks.iter().enumerate() {
let (mut secs, mut us_per_quarter) = (0.0, 500_000.0);
for ev in track {
secs += ev.delta.as_int() as f64 * us_per_quarter / ppq / 1e6;
match ev.kind {
TrackEventKind::Meta(MetaMessage::Tempo(t)) => us_per_quarter = t.as_int() as f64,
TrackEventKind::Midi { channel, message: MidiMessage::NoteOn { key, vel } }
if vel.as_int() > 0 =>
{
println!("track {i} {secs:8.3}s ch{} note {} vel {}",
channel.as_int() + 1, key.as_int(), vel.as_int());
}
_ => {}
}
}
}
Ok(())
}
Tempo is tracked per track here, which is exact for format 0 files. For format 1, build the tempo map from track 0 first.
Write a C major scale to a .mid file
A format 0 file at 480 ticks per quarter note and 120 BPM: one quarter note per scale degree. Delta times are in ticks, measured from the previous event.
See also: Standard MIDI Files · VLQ encoder
// Node.js
const { writeMidi } = require('midi-file');
const { writeFileSync } = require('node:fs');
const track = [{ deltaTime: 0, meta: true, type: 'setTempo', microsecondsPerBeat: 500000 }];
for (const n of [60, 62, 64, 65, 67, 69, 71, 72]) {
track.push({ deltaTime: 0, channel: 0, type: 'noteOn', noteNumber: n, velocity: 90 });
track.push({ deltaTime: 480, channel: 0, type: 'noteOff', noteNumber: n, velocity: 0 });
}
track.push({ deltaTime: 0, meta: true, type: 'endOfTrack' });
const bytes = writeMidi({ header: { format: 0, numTracks: 1, ticksPerBeat: 480 }, tracks: [track] });
writeFileSync('c-major.mid', Buffer.from(bytes));
import mido
from mido import Message, MetaMessage, MidiFile, MidiTrack
mid = MidiFile(type=0, ticks_per_beat=480)
track = MidiTrack()
mid.tracks.append(track)
track.append(MetaMessage('set_tempo', tempo=mido.bpm2tempo(120)))
for note in [60, 62, 64, 65, 67, 69, 71, 72]:
track.append(Message('note_on', note=note, velocity=90, time=0))
track.append(Message('note_off', note=note, velocity=0, time=480)) # time = delta ticks
mid.save('c-major.mid') # mido appends End of Track for you
use midly::num::{u15, u24, u28, u4, u7};
use midly::{Format, Header, MetaMessage, MidiMessage, Smf, Timing, TrackEvent, TrackEventKind};
fn main() -> std::io::Result<()> {
let mut smf = Smf::new(Header::new(Format::SingleTrack, Timing::Metrical(u15::new(480))));
let ch = u4::new(0);
let ev = |delta: u32, kind| TrackEvent { delta: u28::new(delta), kind };
let mut track = vec![ev(0, TrackEventKind::Meta(MetaMessage::Tempo(u24::new(500_000))))];
for key in [60u8, 62, 64, 65, 67, 69, 71, 72] {
let key = u7::new(key);
track.push(ev(0, TrackEventKind::Midi { channel: ch, message: MidiMessage::NoteOn { key, vel: u7::new(90) } }));
track.push(ev(480, TrackEventKind::Midi { channel: ch, message: MidiMessage::NoteOff { key, vel: u7::new(0) } }));
}
track.push(ev(0, TrackEventKind::Meta(MetaMessage::EndOfTrack)));
smf.tracks.push(track);
smf.save("c-major.mid")
}
Send MIDI clock at a given BPM
MIDI clock is 24 Timing Clock (F8) bytes per quarter note: 20.83 ms apart at 120 BPM. Timers jitter, so schedule against an absolute timeline instead of sleeping a fixed interval, and in the browser use send() timestamps to look ahead.
See also: Timing & Sync · Tempo calculator
const access = await navigator.requestMIDIAccess();
const out = [...access.outputs.values()][0];
const bpm = 120;
const interval = 60000 / bpm / 24; // ms between clocks
const LOOKAHEAD = 100; // ms
let next = performance.now() + 50;
out.send([0xFA], next); // Start
const timer = setInterval(() => {
while (next < performance.now() + LOOKAHEAD) {
out.send([0xF8], next); // Timing Clock, scheduled ahead
next += interval;
}
}, 25);
// Later: clearInterval(timer); out.send([0xFC]); // Stop
setInterval alone can jitter by several milliseconds. Timestamped send() moves the timing into the browser’s MIDI scheduler.
import time
import mido
bpm = 120
interval = 60 / bpm / 24
with mido.open_output(mido.get_output_names()[0]) as out:
out.send(mido.Message('start'))
next_t = time.perf_counter()
try:
while True:
out.send(mido.Message('clock'))
next_t += interval # absolute schedule: errors don't accumulate
while (remaining := next_t - time.perf_counter()) > 0:
time.sleep(remaining / 2 if remaining > 0.002 else 0) # sleep, then spin
except KeyboardInterrupt:
out.send(mido.Message('stop'))
#include <chrono>
#include <thread>
#include <vector>
#include "RtMidi.h"
int main() {
using clock = std::chrono::steady_clock;
RtMidiOut out;
if (out.getPortCount() == 0) return 1;
out.openPort(0);
const double bpm = 120;
const auto interval = std::chrono::duration<double>(60.0 / bpm / 24);
std::vector<unsigned char> start{0xFA}, tick{0xF8};
out.sendMessage(&start);
auto next = clock::now();
for (int i = 0; i < 24 * 4 * 16; i++) { // 16 bars of 4/4
out.sendMessage(&tick);
next += std::chrono::duration_cast<clock::duration>(interval);
std::this_thread::sleep_until(next);
}
std::vector<unsigned char> stop{0xFC};
out.sendMessage(&stop);
}
use midir::MidiOutput;
use std::time::{Duration, Instant};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let out = MidiOutput::new("clock")?;
let ports = out.ports();
let mut conn = out.connect(ports.first().ok_or("no output ports")?, "clock-out")?;
let bpm = 120.0;
let interval = Duration::from_secs_f64(60.0 / bpm / 24.0);
conn.send(&[0xFA])?; // Start
let mut next = Instant::now();
for _ in 0..24 * 4 * 16 { // 16 bars of 4/4
conn.send(&[0xF8])?;
next += interval; // absolute schedule
if let Some(wait) = next.checked_duration_since(Instant::now()) {
std::thread::sleep(wait);
}
}
conn.send(&[0xFC])?; // Stop
Ok(())
}
Convert between note numbers, names, and frequencies
Equal temperament with A4 = 440 Hz at note 69. Octave naming varies: this uses C4 = 60 (scientific pitch); Yamaha-style software calls the same note C3.
See also: Notes & Pitch · Note table
const NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const noteToFreq = (n, a4 = 440) => a4 * 2 ** ((n - 69) / 12);
const freqToNote = (hz, a4 = 440) => 69 + 12 * Math.log2(hz / a4); // fractional
const noteName = (n) => NAMES[n % 12] + (Math.floor(n / 12) - 1);
function parseNote(name) { // "C#4" -> 61
const m = /^([A-G])(#|b)?(-?\d+)$/.exec(name);
if (!m) throw new Error('bad note name: ' + name);
const pc = NAMES.indexOf(m[1]) + (m[2] === '#' ? 1 : m[2] === 'b' ? -1 : 0);
return (Number(m[3]) + 1) * 12 + pc;
}
console.log(noteToFreq(60).toFixed(2)); // 261.63
console.log(noteName(69), parseNote('C#4'), Math.round(freqToNote(196))); // A4 61 55
import math
import re
NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
def note_to_freq(n, a4=440.0):
return a4 * 2 ** ((n - 69) / 12)
def freq_to_note(hz, a4=440.0): # fractional note number
return 69 + 12 * math.log2(hz / a4)
def note_name(n):
return f'{NAMES[n % 12]}{n // 12 - 1}'
def parse_note(name): # "C#4" -> 61
letter, accidental, octave = re.fullmatch(r'([A-G])(#|b)?(-?\d+)', name).groups()
pc = NAMES.index(letter) + {'#': 1, 'b': -1}.get(accidental, 0)
return (int(octave) + 1) * 12 + pc
print(round(note_to_freq(60), 2), note_name(69), parse_note('C#4'), round(freq_to_note(196)))
# 261.63 A4 61 55
Spotted an error? Edit this page on GitHub.