Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to correctly convert MIDI ticks to milliseconds?

Tags:

python

midi

I'm trying to convert MIDI ticks/delta time to milliseconds and have found a few helpful resources already:

  1. MIDI Delta Time Ticks to Seconds
  2. How to convert midi timeline into the actual timeline that should be played
  3. MIDI Time Code spec
  4. MTC

The problem is I don't think I'm using this information correctly. I've tried applying the formula Nik expanded:

[  1 min    60 sec   1 beat     Z clocks ]
| ------- * ------ * -------- * -------- | = seconds
[ X beats   1 min    Y clocks       1    ]

using the metadata from this test MIDI file:

<meta message set_tempo tempo=576923 time=0>
<meta message key_signature key='Ab' time=0>
<meta message time_signature numerator=4 denominator=4 clocks_per_click=24 notated_32nd_notes_per_beat=8 time=0>

Like so:

self.toSeconds = 60.0 * self.t[0][2].clocks_per_click / (self.t[0][0].tempo * self.t[0][2].denominator) * 10

This initially looks ok, but then it seems to drift. Here is a basic runnable example using Mido and pygame (assuming pygame plays back correctly):

import threading

import pygame
from pygame.locals import *

from mido import MidiFile,MetaMessage

music_file = "Bee_Gees_-_Stayin_Alive-Voice.mid"

#audio setup
freq = 44100    # audio CD quality
bitsize = -16   # unsigned 16 bit
channels = 2    # 1 is mono, 2 is stereo
buffer = 1024    # number of samples
pygame.mixer.init(freq, bitsize, channels, buffer)
pygame.mixer.music.set_volume(0.8)


class MIDIPlayer(threading.Thread):
    def __init__(self,music_file):
        try:
            #MIDI parsing
            self.mid = MidiFile(music_file)
            self.t = self.mid.tracks

            for i, track in enumerate(self.mid.tracks):
                print('Track {}: {}'.format(i, track.name))
                for message in track:
                    if isinstance(message, MetaMessage):
                        if message.type == 'time_signature' or message.type == 'set_tempo' or message.type == 'key_signature':
                            print message

            self.t0 = self.t[0][3:len(self.t[0])-1]
            self.t0l = len(self.t0)
            self.toSeconds = 60.0 * self.t[0][2].clocks_per_click / (self.t[0][0].tempo * self.t[0][2].denominator) * 10
            print "self.toSeconds",self.toSeconds
            #timing setup
            self.event_id = 0
            self.now = pygame.time.get_ticks()
            self.play_music(music_file)
        except KeyboardInterrupt:
            pygame.mixer.music.fadeout(1000)
            pygame.mixer.music.stop()
            raise SystemExit

    def play_music(self,music_file):
        clock = pygame.time.Clock()
        try:
            pygame.mixer.music.load(music_file)
            print "Music file %s loaded!" % music_file
        except pygame.error:
            print "File %s not found! (%s)" % (music_file, pygame.get_error())
            return
        pygame.mixer.music.play()
        while pygame.mixer.music.get_busy():
            # check if playback has finished
            millis = pygame.time.get_ticks()
            deltaMillis = self.t0[self.event_id].time * self.toSeconds * 1000
            # print millis,deltaMillis
            if millis - self.now >= deltaMillis:
                print self.t0[self.event_id].text
                self.event_id = (self.event_id + 1) % self.t0l
                self.now = millis
            clock.tick(30)

MIDIPlayer(music_file)

What the above code should do is print the correct lyric at the correct time based on the midi file, yet it drifts over time.

What's the correct way of converting MIDI delta time to seconds/milliseconds ?

Update

Based on CL's helpful answer I've updated the code to use ticks_per_beat from the header. Since there is a single set_tempo meta message, I am using this value throughout:

import threading

import pygame
from pygame.locals import *

from mido import MidiFile,MetaMessage

music_file = "Bee_Gees_-_Stayin_Alive-Voice.mid"

#audio setup
freq = 44100    # audio CD quality
bitsize = -16   # unsigned 16 bit
channels = 2    # 1 is mono, 2 is stereo
buffer = 1024    # number of samples
pygame.mixer.init(freq, bitsize, channels, buffer)
pygame.mixer.music.set_volume(0.8)


class MIDIPlayer(threading.Thread):
    def __init__(self,music_file):
        try:
            #MIDI parsing
            self.mid = MidiFile(music_file)
            self.t = self.mid.tracks

            for i, track in enumerate(self.mid.tracks):
                print('Track {}: {}'.format(i, track.name))
                for message in track:
                    # print message
                    if isinstance(message, MetaMessage):
                        if message.type == 'time_signature' or message.type == 'set_tempo' or message.type == 'key_signature' or message.type == 'ticks_per_beat':
                            print message

            self.t0 = self.t[0][3:len(self.t[0])-1]
            self.t0l = len(self.t0)
            self.toSeconds = 60.0 * self.t[0][2].clocks_per_click / (self.t[0][0].tempo * self.t[0][2].denominator) * 10
            print "self.toSeconds",self.toSeconds

            # append delta delays in milliseconds
            self.delays = []

            tempo = self.t[0][0].tempo
            ticks_per_beat = self.mid.ticks_per_beat

            last_event_ticks = 0
            microseconds = 0

            for event in self.t0:
                delta_ticks = event.time - last_event_ticks
                last_event_ticks = event.time
                delta_microseconds = tempo * delta_ticks / ticks_per_beat
                microseconds += delta_microseconds
                print event.text,microseconds/1000000.0
                self.delays.append(microseconds/1000)

            #timing setup
            self.event_id = 0
            self.now = pygame.time.get_ticks()
            self.play_music(music_file)
        except KeyboardInterrupt:
            pygame.mixer.music.fadeout(1000)
            pygame.mixer.music.stop()
            raise SystemExit

    def play_music(self,music_file):
        clock = pygame.time.Clock()
        try:
            pygame.mixer.music.load(music_file)
            print "Music file %s loaded!" % music_file
        except pygame.error:
            print "File %s not found! (%s)" % (music_file, pygame.get_error())
            return
        pygame.mixer.music.play()
        while pygame.mixer.music.get_busy():
            # check if playback has finished
            millis = pygame.time.get_ticks()
            # deltaMillis = self.t0[self.event_id].time * self.toSeconds * 1000
            deltaMillis = self.delays[self.event_id]
            # print millis,deltaMillis
            if millis - self.now >= deltaMillis:
                print self.t0[self.event_id].text
                self.event_id = (self.event_id + 1) % self.t0l
                self.now = millis
            clock.tick(30)

MIDIPlayer(music_file)

The timing of the messages I print based on the time converted to milliseconds looks much better. However, after a few seconds it still drifts.

Am I correctly converting MIDI ticks to milliseconds and keep track of passed milliseconds in the update while loop ?

This how the conversion is made: self.delays = []

    tempo = self.t[0][0].tempo
    ticks_per_beat = self.mid.ticks_per_beat

    last_event_ticks = 0
    microseconds = 0

    for event in self.t0:
        delta_ticks = event.time - last_event_ticks
        last_event_ticks = event.time
        delta_microseconds = tempo * delta_ticks / ticks_per_beat
        microseconds += delta_microseconds
        print event.text,microseconds/1000000.0
        self.delays.append(microseconds/1000)

and this is how the check if a 'cue' was encountered as time passes:

millis = pygame.time.get_ticks()
            deltaMillis = self.delays[self.event_id]
            if millis - self.now >= deltaMillis:
                print self.t0[self.event_id].text
                self.event_id = (self.event_id + 1) % self.t0l
                self.now = millis
            clock.tick(30)

I'm not sure if this implementation converts MIDI delta ticks to milliseconds incorrectly, incorrectly check if millisecond based delays pass or both.

like image 606
George Profenza Avatar asked Dec 08 '15 21:12

George Profenza


2 Answers

First, you have to merge all tracks, to ensure that the tempo change events are processed properly. (This is probably easier if you convert delta times to absolute tick values first; otherwise, you'd have to recompute the delta times whenever an event is inserted between events of another track.)

Then you have to compute, for each event, the relative time to the last event, like in the following pseudocode. It is important that the computation must use relative times because the tempo could have changed at any time:

tempo = 500000        # default: 120 BPM
ticks_per_beat = ...  # from the file header

last_event_ticks = 0
microseconds = 0
for each event:
    delta_ticks = event.ticks - last_event_ticks
    last_event_ticks = event.ticks
    delta_microseconds = tempo * delta_ticks / ticks_per_beat
    microseconds += delta_microseconds
    if event is a tempo event:
        tempo = event.new_tempo
    # ... handle event ...
like image 103
CL. Avatar answered Oct 08 '22 03:10

CL.


You might want to increase the frame rate. On my system, increasing clock.tick(30) to clock.tick(300) gives good results. You can measure this by printing how much your timing is off:

print self.t0[self.event_id].text, millis - self.now - deltaMillis

With 30 ticks the cues are lagging 20 to 30 millisecond behind. With 300 ticks they are at most 2 milliseconds behind. You might want to increase this even further.

Just to be safe you should run python with the -u switch to prevent stdout from buffering (this might be unnecessary, since lines end with newline).

I have a hard time determining the timing, but judging from the "Ah ha ha ha"'s it seems to be correct with these changes.

like image 41
YellowBird Avatar answered Oct 08 '22 03:10

YellowBird