view musiccutter.py @ 38:9e8ed92b477c

Re-jig note translation to only happen when we are going to emit a note. This fixes "note_off with no note_on" cases (iWriteMusic likes to emit these for rests). This means some messages have untransposed notes but we draw the line because they only have to be transposed because of limitations in the organ so before that they are are untransposed (except for the bulk adjustment)
author Daniel O'Connor <darius@dons.net.au>
date Mon, 23 May 2016 22:35:44 +0930
parents c490fecec0ef
children 86622ba474e4
line wrap: on
line source

#!/usr/bin/env python

from IPython.core.debugger import Tracer
import exceptions
import itertools
import math
import mido
import os.path
import reportlab.lib.colors
import reportlab.pdfgen.canvas
from reportlab.lib.units import mm
import sys

CUT_COLOUR = reportlab.lib.colors.red
ENGRAVE_COLOUR = reportlab.lib.colors.black

class Stats(object):
    pass

class EVWrap(object):
    def __init__(self, ev):
        self.ev = ev

def test(filename = None, shift = 0):
    if filename == None:
        filename = 'test.midi'
    # Card layout from http://www.orgues-de-barbarie.com/wp-content/uploads/2014/09/format-cartons.pdf
    # Notes are read from right to left
    # Highest note is at the bottom (closest to the crank)
    #  fold   fold
    #    in   out
    #     V   ^
    # +---+---+---+ lowest note
    # |   |   |   |
    # +---+---+---+ highest note
    #
    m = Midi2PDF('notes', 120, 155, 5.5, 3.3, 6.0, 50, False, True, False, False, False, shift, 1, 30, 0.9, 'Helvetica', 12)
    base, ext = os.path.splitext(filename)
    base = os.path.basename(base)
    m.processMidi(filename, base + '-%02d.pdf')

class Midi2PDF(object):
    def __init__(self, notefile, pagewidth, pageheight, pitch, slotsize, heel, leadin, timemarks, trytranspose, drawrect, notenames, notelines, noteoffset, pagesperpdf, timescale, notescale, fontname, fontsize):
        self.midi2note, self.note2midi = Midi2PDF.genmidi2note()
        self.note2slot, self.slot2note = Midi2PDF.loadnote2slot(notefile, self.note2midi)
        self.pagewidth = pagewidth # Dimensions are in millimetres
        self.pageheight = pageheight
        self.pitch = pitch # Distance between each slot
        self.slotsize = slotsize # Size of each slot cut out
        self.heel = heel # Bottom margin (from bottom of page to centre of slot)
        self.leadin = leadin # Extra at the start
        self.timemarks = timemarks # Draw vertical time lines
        self.trytranspose = trytranspose # Attempt to tranpose unplayable notes
        self.drawrect = drawrect # Draw rectangle around each page
        self.notenames = notenames # Draw note names on the right edge
        self.notelines = notelines # Draw line rulers
        self.noteoffset = noteoffset # Amount to adjust note pitches by (+12 = 1 octave)
        self.pagesperpdf = pagesperpdf # Number of pages to emit per PDF
        self.timescale = timescale # Width per second
        self.notescale = notescale # Multiply all note lengths by this (to get rearticulation)
        self.fontname = fontname
        self.fontsize = fontsize # Points

        self.pdfwidth = self.pagewidth * self.pagesperpdf

    def processMidi(self, midifile, outpat):
        stats = Stats()
        stats.playablecount = 0
        stats.unplayablecount = 0
        stats.transposeupcount = 0
        stats.transposedowncount = 0
        midi = mido.MidiFile(midifile)
        ctime = 0
        channels = []
        for i in range(16):
            channels.append({})

        npages = int(math.ceil(((midi.length * self.timescale) + self.leadin) / self.pagewidth))
        npdfs = int(math.ceil(float(npages) / self.pagesperpdf))
        print 'npages %d, npdfs %d' % (npages, npdfs)

        pdfs = []
        for i in range(npdfs):
            pdf = reportlab.pdfgen.canvas.Canvas(file(outpat % (i + 1), 'w'),
                                                 pagesize = (self.pdfwidth * mm, self.pageheight * mm))
            pdfs.append(pdf)

        title = os.path.basename(midifile)
        title, ext = os.path.splitext(title)
        for ev in midi:
            # Adjust pitch
            if hasattr(ev, 'note'):
                ev.note += self.noteoffset
            ctime += ev.time
            #print ctime, ev
            #Tracer()()

            if ev.type == 'text' and ctime == 0:
                title = ev.text
            if ev.type == 'note_on' and ev.velocity > 0:
                if ev.note in channels[ev.channel]:
                    print 'Duplicate note_on message at %.1f sec channel %d note %d' % (ctime, ev.channel, ev.note)
                else:
                    channels[ev.channel][ev.note] = ctime
            elif ev.type == 'note_off' or (ev.type == 'note_on' and ev.velocity == 0):
                if ev.note not in channels[ev.channel]:
                    print 'note_off with no corresponding note_on at %.1f sec for channel %d note %d' % (ctime, ev.channel, ev.note)
                    continue
                else:
                    orignote = ev.note
                    start = channels[ev.channel][orignote]
                    evw = EVWrap(ev)
                    # Find a slot (plus adjust pitch, attempt transposition etc)
                    if hasattr(ev, 'note'):
                        self.getslotfornote(evw, stats, ctime)
                    # Check if it was unplayable
                    if not evw.slot:
                        continue
                    notelen = ctime - start
                    #print 'Note %s (%d) at %.2f length %.2f' % (evw.notename, ev.slot, start, notelen)
                    self.emitnote(pdfs, evw.slot, start, notelen * self.notescale)

                del channels[ev.channel][orignote]
            elif ev.type == 'end_of_track':
                print 'EOT, not flushing, check for missed notes'
                for chan in channels:
                    for ev in chan:
                        print ev

        print 'Playable count:', stats.playablecount
        print 'Unplayable count:', stats.unplayablecount
        if self.trytranspose:
            print 'Transpose down:', stats.transposedowncount
            print 'Transpose up:', stats.transposeupcount

        for pindx in range(npages):
            pdf = pdfs[pindx / self.pagesperpdf] # PDF for this page
            # Offset into PDF where the page starts
            pageofs = self.pagewidth * (self.pagesperpdf - (pindx % self.pagesperpdf) - 1)
            # Add title and page number
            Midi2PDF.textHelper(pdf, pageofs * mm, 1 * mm,
                                ENGRAVE_COLOUR, True, self.fontname, self.fontsize,
                                '%s (%d / %d)' % (title, pindx + 1, npages))
            pdf.saveState()
            pdf.setLineWidth(0)

            # Draw time marks every 5 seconds
            if self.timemarks:
                pdfidx = pindx / self.pagesperpdf
                # Work out start and end times (pdf 1 is special due to leadin)
                tstart = self.leadin / self.timescale
                tend = (self.pagewidth * self.pagesperpdf) / self.timescale
                if pindx > 0:
                    tsize = self.pagewidth  / self.timescale # Amount of time per pdf
                    tstart = tend + tsize * pdfidx
                    tend = tend + tsize * (pdfidx + 1)
                for s in range(tstart, tend, 5):
                    x = self.pagewidth - (float(s * self.timescale + self.leadin) % self.pagewidth)
                    pdf.line(x * mm, self.heel, x * mm, self.pageheight * mm)
                    Midi2PDF.textHelper(pdf, x * mm, 1 * mm, ENGRAVE_COLOUR, False, self.fontname, self.fontsize, str(s) + 's')

            # Draw rectangle around page (upper and right hand ends don't seem to render though)
            if self.drawrect:
                pdf.rect((pindx % self.pagesperpdf) * self.pagewidth * mm, 0,
                         self.pagewidth * mm, self.pageheight * mm, fill = False, stroke = True)

            # Draw lines per note
            for slot in sorted(self.slot2note.keys()):
                ofs = self.pageheight - (self.heel + slot * self.pitch) - self.slotsize / 2
                if self.notelines:
                    pdf.line(0, ofs * mm, self.pdfwidth * mm, ofs * mm)
                # Note name
                if self.notenames:
                    Midi2PDF.textHelper(pdf, (self.pdfwidth - 10) * mm, (ofs + 0.5) * mm, ENGRAVE_COLOUR, False, self.fontname, self.fontsize, self.slot2note[slot])
            pdf.restoreState()
        for pdf in pdfs:
            pdf.save()

    def noteisplayable(self, midi):
        slot = None
        if midi in self.midi2note:
            notename = self.midi2note[midi]
            if notename in self.note2slot:
                slot = self.note2slot[notename]

        return slot

    def transposenote(self, evw, amount):
        evw.ev.note += amount
        evw.notename = self.midi2note[evw.ev.note]
        evw.slot = self.note2slot[evw.notename]

    def getslotfornote(self, evw, stats, ctime):
        evw.slot = None
        evw.notename = None

        # First off, is the note in our midi table?
        if evw.ev.note in self.midi2note:
            evw.notename = self.midi2note[evw.ev.note]
            # Is it playable?
            if self.noteisplayable(evw.ev.note) != None:
                evw.slot = self.note2slot[evw.notename]
            # Nope, maybe we can transpose?
            elif self.trytranspose:
                # Go for -3 to +3 octaves (going down first)
                for i in [-12, -24, -36, 12, 24, 36]:
                    if self.noteisplayable(evw.ev.note + i) != None:
                        self.transposenote(evw, i)
                        if i < 0:
                            stats.transposedowncount += 1
                            tmp = 'down'
                        else:
                            stats.transposeupcount += 1
                            tmp = 'up'
                        print 'Transposed note at %.1f sec %s (%d) %s %d octave(s) to %s (%d)' % (
                            ctime, self.midi2note[evw.ev.note - i], evw.ev.note - i, tmp,
                            abs(i / 12), evw.notename, evw.ev.note)
                        break
            if evw.slot != None:
                stats.playablecount += 1
            else:
                print 'Note at %.1f sec %d (%s) not playable' % (ctime, evw.ev.note, self.midi2note[evw.ev.note])
                stats.unplayablecount += 1
        else:
            print 'Note at %.1f sec, %d not in MIDI table' % (ctime, evw.ev.note)
            stats.unplayablecount += 1

    # http://newt.phys.unsw.edu.au/jw/notes.html
    # But this seems dumb since the lowest MIDI note is 0 which would be C-1..
    @staticmethod
    def genmidi2note():
        '''Create forward & reverse tables for midi number to note name (assuming 69 = A4 = A440)'''
        names = ['C%d', 'C%d#', 'D%d', 'D%d#', 'E%d', 'F%d', 'F%d#', 'G%d', 'G%d#', 'A%d', 'A%d#', 'B%d']
        midi2note = {}
        note2midi = {}
        for midi in range(12, 128):
            octave = midi / len(names) - 1
            index = midi % len(names)
            name = names[index] % (octave)
            midi2note[midi] = name
            note2midi[name] = midi

        return midi2note, note2midi

    @staticmethod
    def loadnote2slot(fname, note2midi):
        note2slot = {}
        slot2note = {}
        index = 0

        for note in file(fname):
            note = note.strip()
            if note[0] == '#':
                continue
            if note not in note2midi:
                raise exceptions.ValueError('Note \'%s\' not valid' % note)
            note2slot[note] = index
            slot2note[index] = note
            index += 1

        return note2slot, slot2note

    def emitnote(self, pdfs, slot, start, notelen):
        x = start * self.timescale + self.leadin # Convert start time to distance
        pdfidx = int(x / self.pdfwidth) # Determine which pdf
        x = x % self.pdfwidth # and where on that pdf
        h = self.slotsize
        y = self.pageheight - (self.heel + slot * self.pitch - self.slotsize / 2) - self.slotsize
        w = notelen * self.timescale # Convert note length in time to distance

        #print 'pdf = %d x = %.3f y = %.3f w = %.3f h = %.3f' % (pdfidx, x, y, w, h)
        w1 = w
        # Check if the note crosses a pdf
        if x + w > self.pdfwidth:
            w1 = self.pdfwidth - x # Crop first note
            w2 = w - w1 # Calculate length of second note
            assert w2 <= self.pdfwidth, 'note extends for more than a pdf'
            # Emit second half of note
            #print 'split note, pdf %d w2 = %.3f' % (pdfidx + 1, w2)
            Midi2PDF._emitnote(pdfs[pdfidx + 1], self.pdfwidth - w2, y, w2, h)

        Midi2PDF._emitnote(pdfs[pdfidx], self.pdfwidth - x - w1, y, w1, h)

    @staticmethod
    def _emitnote(pdf, x, y, w, h):
        pdf.saveState()
        pdf.setStrokeColor(CUT_COLOUR)
        pdf.setLineWidth(0)
        pdf.rect(x * mm, y * mm, w * mm, h * mm, fill = False, stroke = True)
        pdf.restoreState()

    @staticmethod
    def textHelper(pdf, x, y, colour, fill, fontname, fontsize, text):
        tobj = pdf.beginText()
        tobj.setTextOrigin(x, y)
        tobj.setFont(fontname, fontsize)
        tobj.setStrokeColor(colour)
        tobj.setFillColor(colour)
        if fill:
            tobj.setTextRenderMode(0)
        else:
            tobj.setTextRenderMode(1)
        tobj.textLine(text)
        pdf.drawText(tobj)

if __name__ == '__main__':
    main()