Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Request for example of playing a midi file #77

Open
owenlamont opened this issue Jun 20, 2022 · 6 comments
Open

Request for example of playing a midi file #77

owenlamont opened this issue Jun 20, 2022 · 6 comments
Labels
documentation Improvements or additions to documentation

Comments

@owenlamont
Copy link

I wasn't sure if playing midi files is something ipytone even supports, but if it is, I'd love an example of loading and playing a midi file.

@benbovy
Copy link
Collaborator

benbovy commented Jun 21, 2022

There's no built-in support (yet) in ipytone for playing midi files, but you could parse the midi file using, e.g., mido to create ipytone.Part events without much effort. For example:

import ipytone
import mido
# convert MIDI note numbers to notes (string notation)
# modified from https://gist.github.com/devxpy/063968e0a2ef9b6db0bd6af8079dad2a

NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
OCTAVES = list(range(-2, 9))
NOTES_IN_OCTAVE = len(NOTES)

def number_to_note(number):
    octave = number // NOTES_IN_OCTAVE - 2
    assert octave in OCTAVES, errors['notes']
    assert 0 <= number <= 127, errors['notes']
    note = NOTES[number % NOTES_IN_OCTAVE]

    return note + str(octave)
# read midi file and extract 1st track
mid = mido.MidiFile("filename.mid")
track = mid.tracks[0]

# use 120 BPM by default (possible to read it from the midi file)
tempo = 500_000
ipytone.transport.bpm.value = mido.tempo2bpm(tempo)

# parse midi note messages and create ipytone.Part events
current_time = 0.
current_notes = {}
events = []

for msg in track:
    current_time += mido.tick2second(msg.time, mid.ticks_per_beat, tempo)
    
    if msg.type == "note_on":
        current_notes[msg.note] = {
            "time": current_time,
            "note": number_to_note(msg.note),
            "velocity": msg.velocity / 127,
        }
    elif msg.type == "note_off":
        event = current_notes.pop(msg.note)
        event["duration"] = current_time - event["time"]
        events.append(event)

Then create the partition and play it (e.g., with an ipytone.PolySynth):

msynth = ipytone.PolySynth(volume=-8).to_destination()

def clb(time, note):
    msynth.trigger_attack_release(
        note.note, note.duration, time=time, velocity=note.velocity
    )

part = ipytone.Part(callback=clb, events=events)
part.start()
ipytone.transport.start()

Not sure that we should add mido as a dependency here, but such example would be a nice addition in the docs.

@benbovy
Copy link
Collaborator

benbovy commented Jun 21, 2022

In the mid/long term, it would be nice to have features like GUI widgets to visualize (#12) and/or edit partitions as well as widgets to connect MIDI devices using https://webmidijs.org/ (a bit like https://github.com/jupyter-widgets/midicontrols but generalized to any device), probably in a 3rd party package.

@owenlamont
Copy link
Author

Thanks for the example! That alone is great, I was just curious how to make it work, with or without the support of another package.

@owenlamont
Copy link
Author

I'll have to play with it more but the example appears to be working well, only tweak I had to make was to the:

current_notes.pop(msg.note)

line. The midi I tested on apparently had note off without a corresponding note on which threw a KeyError which I just caught and skipped the event with.

@benbovy
Copy link
Collaborator

benbovy commented Jun 21, 2022

I don't have much experience with midi files, maybe this could happen when starting to record a performance while a key is already pressed down? I guess a "note on" without a "note off" could happen as well, then?

Probably a better approach would be to use the instrument trigger_attack() and trigger_release() methods in the callback instead of computing the note duration and use trigger_attack_release(). This is not possible right now, though, but it can be easily fixed:

  • ipytone.PolySynth.trigger_release() is missing a notes argument (required in Tone.js)
  • ipytone.Note should have an attribute used to call either trigger_attack or trigger_release in the callback (in Tone.js the structure of that callback argument is arbitrary and thus much more flexible).

@benbovy benbovy added the documentation Improvements or additions to documentation label Jun 23, 2022
@benbovy
Copy link
Collaborator

benbovy commented Apr 26, 2023

With #104 and #105 (available in the next release), creating a Part object from a midi file is slightly more straightforward than in #77 (comment):

events = []

for msg in track:
    current_time += mido.tick2second(msg.time, mid.ticks_per_beat, tempo)

    if msg.type not in ["note_on", "note_off"]:
        continue

    event = {
        "time": current_time,
        "note": number_to_note(msg.note),
        "velocity": msg.velocity / 127,
        "trigger_type": "attack" if msg.type == "note_on" else "release"
    }
    events.append(event)
msynth = ipytone.PolySynth(volume=-8).to_destination()

def clb(time, note):
    msynth.trigger_note(note, time)

part = ipytone.Part(callback=clb, events=events)

I think this solution should work without needing any additional check. One drawback is when ipytone.transport is stopped between the scheduled attack and release of some note, that note is never released (silencing the instrument would require a manual call to trigger_release() or release_all() for polyphonic instruments).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
documentation Improvements or additions to documentation
Projects
None yet
Development

No branches or pull requests

2 participants