bixb922 / umidiparser

MIDI file parser for Micropython, CircuitPython and Python
MIT License
28 stars 4 forks source link

Play Two mono-tone (single-track) MIDI Files At Once? #5

Open waltribeiro opened 6 months ago

waltribeiro commented 6 months ago

I'm looking into generating polyphonic notes with 2 buzzers using UMIDIParser.

Is this possible? Either through use of internal timer functions, or something else?

I'm not looking to play one 2-track MIDI file. Instead, I'm looking to play 2 single-track MIDI files (therefore the 2nd MIDI track can turn on and off from an event).

bixb922 commented 6 months ago

You can certainly open two MIDI files simultaneously, and iterate over both getting the events, for example:

import umidiparser

filename1 = "/tunelib/we wish merry christmas.mid"
filename2 = "/tunelib/youtellm.mid"
mf1 = umidiparser.MidiFile(filename1)
mf2 = umidiparser.MidiFile(filename2)
mf1_iterator = iter(mf1)
mf2_iterator = iter(mf2)
while True:
    print("file1:", next(mf1_iterator))
    print("file2:", next(mf2_iterator))

This example is rather crude, there is no calculation of times, nor waiting for the time to pass.

What you will have to add is merging the times of the tracks. For each event you will have to calculate the time to the next event and wait that time (which is something umidiparser does for multitrack files).

Perhaps the following code might work, I haven't tested this, and this really isn't part of the scope of this project:

mf1 = umidiparser.MidiFile(filename1)
mf2 = umidiparser.MidiFile(filename2)
mf2.tracks.append(mf1.tracks[0])

for ev in mf2.play():
    ' process event
    print(ev)

This should add the track of midi file 1 to midi file 2 (in memory, the files will be untouched) and then play the combined midi file as a multitrack file. The header of both files should be the same.

Just out of curiosity: why don't you want to combine the two files in one as a standard multitrack file? It is very uncommon to play two files simultaneously.

waltribeiro commented 6 months ago

I'll test this out tonight. Looks like it might work.

As for your question at the end - perhaps it might be better that both instruments were in a single MIDI file. But could I "turn off" and "turn on" each instrument separately? Like, have instrument 1 play at velocity 120 while instrument 2 plays at velocity 0 ?

That might be a better solution.

bixb922 commented 6 months ago

If you have two instruments in a MIDI file, usually each instrument is assigned a channel, that is, all events of that instrument have the channel attribute set to a distinct value. That is done both for single track and multitrack files. When iterating through the MIDI events, the MidiEvent.channel attribute is a way to distinguish which instrument is playing.

For example:

active_channels = [0,1]
for ev in MidiFile("myfile.mid"):
    if ev.is_channel() and ev.channel in active_channels:
         ... process this event ...

or also:

muted_channels = [0,1]
for ev in MidiFile("myfile.mid"):
    if ev.status in (NOTE_ON, NOTE_OFF) and ev.channel in muted_channels:
        ev.velocity = 0
    ... continue processing the event ...

This is usually done to implement muting a channel. Does this apply to your use case?

waltribeiro commented 6 months ago

This documentation helps. However, when I choose "active_channels = [0,1]" or just "active_channels = [1]" then the 2nd channel gets all wacky. The MIDI file I'm playing with is just a simple 2-channel MIDI file with quarter notes and eighth notes. So not sure what's happening.

I have a push button on GP8 that I'd like to bring the velocity from 0 to 127 velocity_button = Pin(11, Pin.IN, Pin.PULL_UP)

So basically, a 2-channel MIDI file is playing, and when a push button event happens, then the Treble (channel 0) is either muted or unmuted while the other channel (channel 1) continues playing at 127 velocity.

Is there documentation for that?

waltribeiro commented 6 months ago

Here's what I have so far:

import umidiparser
from time import sleep_us
from machine import Pin, PWM

pwm = PWM(Pin(20))
led = Pin('LED', Pin.OUT)
button = Pin(21, Pin.IN, Pin.PULL_UP)  # Push button on GPIO pin 21

def play_tone(freq, usec):
    # play RTTL/midi notes, also flash onboard LED
    print('freq = {:6.1f} || usec = {:6.1f}'.format(freq, usec))
    if freq > 20:
        pwm.freq(int(freq))       # Set frequency
        pwm.duty_u16(32767)       # 50% duty cycle
    led.on()
    sleep_us(int(0.9 * usec))     # Play for a number of usec
    pwm.duty_u16(0)               # Stop playing for gap between notes
    led.off()
    sleep_us(int(0.1 * usec))     # Pause for a number of usec

# map MIDI notes (0-127) to frequencies. Note 69 is 440 Hz ('A4')
freqs = [440 * 2**((float(x) - 69) / 12) for x in range(128)]

filename1 = "03-01.mid"
filename2 = "03-02.mid"
mf1 = umidiparser.MidiFile(filename1)
mf2 = umidiparser.MidiFile(filename2)
mf1_iterator = iter(mf1)
mf2_iterator = iter(mf2)

active_channels = [0, 1]

# Variables to manage the pause state
paused = False
button_pressed = False

# Function to be called when the button is pressed
def button_callback(pin):
    global button_pressed
    button_pressed = True

# Initialize the button and attach the interrupt
button.irq(trigger=Pin.IRQ_FALLING, handler=button_callback)

while True:
    if button_pressed:
        paused = not paused  # Toggle the pause state
        button_pressed = False

    if not paused:
        try:
            event1 = next(mf1_iterator)
            event2 = next(mf2_iterator)

            if event1.is_channel() and event1.channel in active_channels:
                if event1.status == umidiparser.PROGRAM_CHANGE:
                    # Handle Program Change events separately
                    print(f"Program Change on channel {event1.channel}, program: {event1.program}")
                elif event1.status == umidiparser.NOTE_OFF:
                    play_tone(freqs[event1.note], event1.delta_us)

            if event2.is_channel() and event2.channel in active_channels:
                if event2.status == umidiparser.PROGRAM_CHANGE:
                    # Handle Program Change events separately
                    print(f"Program Change on channel {event2.channel}, program: {event2.program}")
                elif event2.status == umidiparser.NOTE_OFF:
                    play_tone(freqs[event2.note], event2.delta_us)

        except StopIteration:
            # Restart the MIDI file iteration when it reaches the end
            mf1_iterator = iter(mf1)
            mf2_iterator = iter(mf2)
bixb922 commented 6 months ago

Nice!

Some comments: I'd moveevent1 = next(mf1_iterator) just after play_tone(freqs[event1.note], event1.delta_us), similarly with event2 = next(mf2_iterator) just after play_tone(freqs[event2.note], event2.delta_us)

I'd put the try: except: block just around the next(), so you can reset the iterator for each file only when that file comes to a StopIteration.

Is that a mechanical button you are using? If so, mechanical buttons bounce on contact, so you will receive a flurry of interrupts in a row. You need to disregard interrupts for 20 to 100 milliseconds after the first contact to get rid of these excess signals.

And I suppose you need to change the active_channel each time a button is pressed?

bixb922 commented 6 months ago

Hello, I'm sorry I couldn't answer faster. I think I understand the requirement a bit better. I'd structure the program this way:

import umidiparser
from time import sleep_us, ticks_ms, ticks_diff
from machine import Pin, PWM
from asyncio import ThreadSafeFlag

pwm = PWM(Pin(20))
led = Pin('LED', Pin.OUT)
button = Pin(21, Pin.IN, Pin.PULL_UP)  # Push button on GPIO pin 21

def note_on(freq):
    # play RTTL/midi notes, also flash onboard LED
    print(f'{freq=:6.1f}')
    if freq > 20:
        pwm.freq(int(freq))       # Set frequency
        pwm.duty_u16(32767)       # 50% duty cycle
    led.on()

def note_off():
    pwm.duty_u16(0)               # Stop playing for gap between notes
    led.off()

# map MIDI notes (0-127) to frequencies. Note 69 is 440 Hz ('A4')
freqs = [440 * 2**((float(x) - 69) / 12) for x in range(128)]

# Function to be called when the button is pressed
last_button_press = ticks_ms()
# Use thread safe event to communicate IRQ level routine with main code:
button_down = ThreadSafeFlag()
def button_callback(pin):
    global last_button_press
    now = ticks_ms()
    # Ignore IRQs during 100 milliseconds after button down detected
    # to get rid of contact bouncing
    if ticks_diff( now, last_button_press) < 100:
        return
    last_button_press = now
    # Signal button has pressed
    button_down.set()

# Initialize the button and attach the interrupt
button.irq(trigger=Pin.IRQ_FALLING, handler=button_callback)

channel1_paused = False
play_note = True
while True:
    mf = umidiparser.MidiFile("myfile.mid")
    for event in mf.play():
        # Toggle channel1_paused if button was pressed
        if button_down.state:
            button_down.clear()
            channel1_paused = not channel1_paused
        if event.is_channel():
            play_note = event.channel == 0 or (event.channel == 1 and not channel1_paused)
        if event.status == umidiparser.NOTE_ON and play_note:
            note_on(freqs[event.note])
        elif event.status == umidiparser.NOTE_OFF and play_note:
            note_off()

I added some debouncing logic for the button. The PWM will not be able to sound two notes at a time, however, so if two notes are played at the same time, only one of those will sound. I have not been able to test this program to hear the sound with a speaker, however it runs and calls note_on and note_off when it should.

The button is checked only when a note is turned off. If this is not acceptable, I'd rewrite the code with asyncio, which is better suited for this than interrupts.

Does this help?