################################################################################ import sys import argparse import os import os.path import shutil import logging import subprocess import xml.etree.ElementTree as ET import re from mutagen.id3 import ID3, TCOM, TEXT, TIT1, TIT2, TIT3, USLT, APIC, MVIN, MVNM, GRP1, TALB, TPE1, TPE2 from datetime import datetime ################################################################################ # some constants, you might want to change some depending on your taste and system # console log level loglevel=logging.DEBUG # MuseScore Application (here: MuseScore 3 on MacOS) mscore="/Applications/MuseScore 3.app/Contents/MacOS/mscore" # mp3 id3 cover art picture artpng='cover.png' # mp3 id3 album name album = "Markus' Musikmappe" # mp3 id3 artist and album artist name artist = "Markus" # text box style: if you have text boxes with this style in your score, # the text will be collected in the mp3 id3 unsynchronized lyrics lyricstyle = "Sticking" # standard volume depends on number of voices: vols = dict({ 1: 100, 2: 80, 3: 70, 4: 63, 5: 55, 6: 50, 7: 48, 8: 45, 9: 42, 10: 40, 11: 40, 12: 40, 13: 40, 14: 40, 15: 40, 16: 40, 17: 40, 18: 40, 19: 40, 20: 40 }) # emphasized and karaoke volumes vol_e = '127' vol_k = '5' # favorite instrument to play choir voices (71 is clarinet) instr="71" # expressions to find voices from instrument shortname/longname/trackname, examples: # Soprano, S => s, # Tenor Solo, Tenor S => ts # Alto Choir 2, AC2 => ac2 ere_voice = dict([ ('ss', '^s.*s'), ('sc1', '^s.*c.*1'), ('s1', '^s.*1|cantus'), ('sc2', '^s.*c.*2'), ('s2', '^s.*2|altus'), ('s', '^s'), ('0s', '^\d *s'), ('as', '^a.*s'), ('ac1', '^a.*c.*1'), ('a1', '^a.*1'), ('ac2', '^a.*c.*2'), ('a2', '^a.*2'), ('a', '^a|quintus'), ('0a', '^\d *a'), ('ts', '^t.*s'), ('tc1', '^t.*c.*1'), ('t1', '^t.*1'), ('tc2', '^t.*c.*2'), ('t2', '^t.*2'), ('t', '^t'), ('0t', '^\d *t'), ('bs', '^b.*s'), ('bc1', '^b.*c.*1'), ('b1', '^b.*1'), ('bc2', '^b.*c.*2'), ('b2', '^b.*2'), ('b', '^b'), ('0b', '^\d *b')]) # order of voices left to right voiceorder = [ 's1', 'sc1', 's2', 'ac1', 's', '0s', 'a1', 'tc1', 'a2', 'bc1', 'a', '0a', 'ss', 'as', 'ts', 'bs', 't1', 'sc2', 't2', 'ac2', 'tc2', 't', '0t', 'b1', 'b2', 'bc2', 'b', '0b' ] ################################################################################ # you shouldn't need to touch anything below here ################################################################################ # parse command line parser = argparse.ArgumentParser(description='analyze musescore files and export to various formats') parser.add_argument('mscz', metavar='mscz-file', nargs='*', help='musescore file (mscz files in current directory if empty)') parser.add_argument('-pdf', action='store_true', help='export score as pdf') parser.add_argument('-xml', action='store_true', help='export score as musicxml') parser.add_argument('-mp3', action='store_true', help='export score as mp3') parser.add_argument('-bitrate', default=96, type=int, help='bitrate for mp3 export (96)') parser.add_argument('-uncompressed', action='store_true', help='export score as uncompressed musescore format mscx') parser.add_argument('-musescore', action='store_true', help='export score as musescore format mscz') parser.add_argument('-continuous', action='store_true', help='export musescore in continuous view') parser.add_argument('-emphasized', action='store_true', help='generate emphasized for each part') parser.add_argument('-karaoke', action='store_true', help='generate karaoke for each part') parser.add_argument('-all', action='store_true', help='generate all parts') parser.add_argument('-verbose', action='store_true', help='log more information on what is going on') parser.add_argument('-color', default=0xFF0000, type=int, help='color for the emphasized part in mscx export (0x770000)') args = parser.parse_args() # where are we? workdir = os.getcwd() # print("workdir: ", workdir) (root, ext)=os.path.splitext(sys.argv[0]) app=os.path.basename(root) # print("app: ", app) logfile = root + '.log' # print ("logfile: ", logfile) (appdir, appname) = os.path.split(sys.argv[0]) # print("appdir, appname", appdir, appname) ################################################################################ # configure logger logging.basicConfig(format='%(asctime)s %(name)-10s - %(levelname)-7s: %(message)s', level=logging.DEBUG, datefmt='%Y.%m.%d %H:%M:%S', filename=logfile, filemode='w') console = logging.StreamHandler() console.setLevel(loglevel) # set a format which is simpler for console use console.setFormatter(logging.Formatter('%(levelname)-7s %(message)s')) # add the handler to the root logger logging.getLogger('').addHandler(console) logger = logging.getLogger(appname) # set log level if args.verbose: logger.setLevel(logging.DEBUG) else: logger.setLevel(logging.INFO) logger.debug("################################################################################") # log the general information from above logger.debug("workdir: %s", workdir) logger.debug("appdir: %s", appdir) logger.debug("appname: %s", appname) logger.debug("app: %s", app) logger.debug("logfile: %s", logfile) logger.debug("defined voices: %s", ere_voice) logger.debug("standard volume for voice count: %s", vols) logger.debug("MuseScore application: %s", mscore) logger.info("Args: %s", vars(args)) # check album art artpng = os.path.join(appdir, artpng) if os.path.isfile(artpng) == False: logger.warning("missing album art (%s)", artpng) artpng = None else: logger.debug("album art: %s", artpng) # check if we have musescore result=subprocess.run([mscore, "-v"], capture_output=True) if result.returncode != 0: logger.warning("cannot get musescore version, command %s returns %s", result.args, result.stderr.decode("utf-8").rstrip()) sys.exit(1) logger.info("musescore version is %s", result.stdout.decode("utf-8").rstrip()) # just to s.ee what we are doing logger.debug ("args: " + ', '.join(sys.argv[1:])) ################################################################################ # generic export function # def msout(infile, suffix, bitrate=96, srcfile=None, id3tags=None): logger.debug("infile: %s, suffix: %s, bitrate: %s, srcfile: %s", infile, suffix, bitrate, srcfile) (root, ext)=os.path.splitext(infile) outfile=root+"."+suffix # don't need to write if target is newer than source if srcfile != None: logger.debug("msout: checking %s", srcfile) if os.path.isfile(srcfile): if os.path.isfile(outfile): srctime = datetime.fromtimestamp(os.path.getmtime(srcfile)).strftime("%Y-%m-%d %H:%M:%S") outtime = datetime.fromtimestamp(os.path.getmtime(outfile)).strftime("%Y-%m-%d %H:%M:%S") logger.debug("srcfile: %s (%s), outfile: %s (%s)", srcfile, srctime, os.path.basename(outfile), outtime) if os.path.getmtime(outfile) > os.path.getmtime(srcfile): logger.info("msout: newer outfile already exists: %s", os.path.basename(outfile)) return None (head, tail)=os.path.split(outfile) tmpoutfile=os.path.join(head, "_"+tail) logger.debug("msout: infile: %s", infile) logger.debug("msout: tmpoutfile: %s", tmpoutfile) logger.debug("msout: calling musescore") logger.info("msout: writing %s", outfile) result=subprocess.run([mscore, "-o", tmpoutfile, "-b", str(bitrate), infile], capture_output=True) if result.returncode != 0: logger.warning("msout: musescore export failed, command %s returns %s", result.args, result.stderr.decode("utf-8").rstrip()) if id3tags != None: id3tags.save(outfile) logger.debug("msout: writing %s", outfile) if id3tags != None: logger.debug("id3 tags:\n%s", id3tags.pprint()) id3tags.save(tmpoutfile) os.rename(tmpoutfile, outfile) return outfile ################################################################################ # ok we're ready to start, let's see which files we need to process # input file is either in the mscz argument or we use the files in the current directory files=[] for source in args.mscz: logger.info("source: %s", source) if os.path.isdir(source): for file in os.listdir(source): if file.endswith(".mscz"): logger.info("found file: %s", file) files.append(os.path.join(source, file)) else: logger.info("found file: %s", source) files.append(source) #if args.mscz != []: # files = args.mscz #else: # for file in os.listdir(os.getcwd()): # if file.endswith(".mscz"): # logger.debug("found file: %s", file) # files.append(file) if files==[]: logger.warning("no input files") logger.info("printing help") parser.print_help() sys.exit() ################################################################################ # handle files ################################################################################ for file in files: logger.debug("################################################################################") # make sure we have fully specified paths if os.path.isabs(file): logger.debug("is absolute: %s", file) filedir=os.path.dirname(file) filename=os.path.basename(file) else: logger.debug("is relative: %s", file) filedir=os.getcwd() filename=file logger.debug("filedir: %s", filedir) logger.debug("filename: %s", filename) filepath=os.path.join(filedir, filename) logger.debug("checking file: %s", filepath) if not os.path.isfile(filepath): logger.warning("file does not exist: %s", filepath) continue logger.info("processing file %s", filepath) # do what we need to do to check the file here ################################################################################ # we always need to export a workfile to mscz # export to temporary mscx logger.debug("################################################################################") workfile=msout(infile=filepath, suffix="mscx") # read xml and analyze logger.debug("analyzing workfile %s", workfile) tree = ET.parse(workfile) treeroot = tree.getroot() # here we analyze what we have # meta tags metatags = dict() for metatag in treeroot.findall("Score/metaTag"): metatags[metatag.attrib.get('name')] = metatag.text for tag in metatags: logger.debug("metaTag %s: %s", tag, metatags[tag]) worknumber = metatags['workNumber'] worktitle = metatags['workTitle'] movementnumber = metatags['movementNumber'] movementtitle = metatags['movementTitle'] composer = metatags['composer'] lyricist = metatags['lyricist'] arranger = metatags['arranger'] # we need a movement title for the mp3 player. if it is empty, get it from the work title and clear that. if movementtitle == None: movementtitle = worktitle worktitle = None # if we still don't have one, use the file name if movementtitle == None: (root, ext) = os.path.splitext(workfile) movementtitle = os.path.basename(root) # if you want lyrics in the mp3 you can hide them in the score # here we read all invisible text boxes with style in lyricstyle logger.debug("looking for lyrics in text boxes with style %s", lyricstyle) lyrics = "" try: ttexts = treeroot.findall("Score/Staff/TBox/Text") for ttext in ttexts: text = ttext.find("text") style = ttext.find("style") visible = ttext.find("visible") if style != None: if style.text == lyricstyle: if text != None: if lyrics != "": lyrics += "\n" lyrics += text.text if visible == None: logger.warning("text box is visible: %s", text.text) else: if visible.text != "0": logger.warning("text box is visible: %s", text.text) except AttributeError: logger.warning("unable to find text boxes in score") if lyrics == "": lyrics = None logger.info("Work Number: %s", worknumber) logger.info("Work Title: %s", worktitle) logger.info("Movement Number: %s", movementnumber) logger.info("Movement Title: %s", movementtitle) logger.info("Composer: %s", composer) logger.info("Lyricist: %s", lyricist) logger.info("Arranger: %s", arranger) logger.info("lyrics:\n ---\n%s\n ---", lyrics) # things we ought to have before exporting if composer == None: logger.warning("missing composer") if movementtitle == None: logger.warning("missing title") if lyrics == None: logger.warning("missing lyrics") # prepare id3 tags for mp3 export id3 = ID3() if album != None: id3.add(TALB(encoding=3, text=album)) if artist != None: id3.add(TPE1(encoding=3, text=artist)) if artist != None: id3.add(TPE2(encoding=3, text=artist)) if lyricist != None: id3.add(TEXT(encoding=3, text=lyricist)) if composer != None: id3.add(TCOM(encoding=3, text=composer)) if worktitle != None: id3.add(TIT1(encoding=3, text=worktitle)) if movementtitle != None: id3.add(TIT2(encoding=3, text=movementtitle)) # if subtitle != None: id3.add(TIT3(encoding=3, text=subtitle)) if lyrics != None: id3.add(USLT(encoding=3, text=lyrics)) if movementnumber != None: id3.add(MVIN(encoding=3, text=movementnumber)) if movementtitle != None: id3.add(MVNM(encoding=3, text=movementtitle)) if worktitle != None: id3.add(GRP1(encoding=3, text=worktitle)) if artpng != None: id3.add(APIC(encoding=3, mime='image/png', type=3, desc='Cover', data=open(artpng, 'rb').read())) # find the score score = treeroot.find("Score") # set things to invisible that we need for editing only visibleflags = ['markIrregularMeasures', 'showMargins', 'showFrames', 'showUnprintable', 'showInvisible'] for visibleflag in visibleflags: element = score.find(visibleflag) if element == None: element = ET.Element(visibleflag) score.insert(0, element) logger.debug("create element %s", visibleflag) if element.text == None: element.text = '0' logger.debug("%s=%s", visibleflag, element.text) element.text = '0' logger.info("set %s %s", visibleflag, element.text) # layoutmode useful if we want to create videos later. note: works for uncompressed export only layoutmode = score.find("layoutMode") if layoutmode == None: logger.debug("create element layoutMode") layoutmode = ET.Element('layoutMode') score.insert(0, layoutmode) logger.info("set layoutMode line (continuous)") layoutmode.text = 'line' # cool to have measure numbers for every measure above (system 0) style = score.find("Style") measurenumberinterval = style.find("measureNumberInterval") measurenumbersystem = style.find ("measureNumberSystem") if measurenumberinterval == None: logger.debug("create element measureNumberInterval") measurenumberinterval = ET.Element('measureNumberInterval') style.append(measurenumberinterval) if measurenumbersystem == None: logger.debug("create element measureNumberSystem") measurenumbersystem = ET.Element('measureNumberSystem') style.append(measurenumbersystem) measurenumberinterval.text = '1' measurenumbersystem.text = '0' ################################################################################ # here we export as-is if desired if args.all: if args.mp3: msout(infile=workfile, suffix='mp3', bitrate=args.bitrate, srcfile=file, id3tags=id3) if args.pdf: msout(infile=workfile, suffix='pdf', srcfile=file) if args.xml: msout(infile=workfile, suffix='xml', srcfile=file) if args.uncompressed: logger.info("keeping work file: %s", workfile) # we have loaded the work file already so we don't need it anymore logger.debug("removing work file: %s", workfile) os.remove(workfile) ################################################################################ # parts # voicecount = dict() myvoices = [] parts = treeroot.findall('Score/Part') # pass 1: identify parts to work with and write them to short name, else delete short name logger.debug("################################################################################") logger.debug("pass 1: analyze score") for part in parts: logger.debug("################################################################################") staff = part.find("Staff") staffcolor = staff.find("color") logger.debug("staffcolor: %s", staffcolor) staffid = staff.attrib.get("id") logger.debug("staff id %s", staffid) # which part are we working with? parttrackname = part.find("trackName") instrument = part.find("Instrument") logger.debug("part: %s", parttrackname.text) trackname = instrument.find("trackName") longname = instrument.find("longName") shortname = instrument.find("shortName") if shortname == None: if longname == None: if trackname == None: logger.error("cannot find part name") sys.exit(1) else: partname = trackname.text else: partname = longname.text else: partname = shortname.text # check color. -1 is white and we skip that part. remove shortName so we know for pass 2. colorelem=part.find("color") if colorelem != None: if colorelem.text == "-1": logger.debug("skipping white instrument %s", trackname.text) if shortname != None: instrument.remove(shortname) continue # which voice are we working with? (s1, s2, s, a1, ...) myvoice=None for voice in ere_voice: # logger.debug("find voice %s in %s using ere %s", voice, partname, ere_voice[voice]) m = re.search(ere_voice[voice], partname.lower()) if m != None: myvoice = voice # special for semi-choir starting with a digit, restore digit from partname logger.debug("first char in voice is %s", myvoice[:1]) if myvoice[:1] == '0': logger.debug("first char in part is %s", partname[:1]) myvoice = partname[:1] + myvoice[1:] logger.debug("found voice: %s", myvoice) break # can't identify voice? too bad, won't work with it... if myvoice == None: logger.warning("cannot find voice for partname %s", partname) if shortname != None: instrument.remove(shortname) continue myvoices.append(myvoice) # duplicate check if myvoice in voicecount: logger.warning("duplicate voice: %s count %d", myvoice, voicecount[myvoice]) voicecount[myvoice] += 1 else: voicecount[myvoice] = 1 logger.debug("voice %s count %d", myvoice, voicecount[myvoice]) # now store my voice in shortname if shortname == None: logger.debug("creating shortname") shortname = ET.Element('shortName') instrument.append(shortname) shortname.text = myvoice logger.debug("shortName: %s", shortname.text) # evaluate pass 1 logger.debug("################################################################################") logger.debug("pass 1: evaluate score") # calculate standard volume numparts = len(voicecount) if numparts == 0: logger.warning("no parts to process") break if numparts > len(vols): logger.warning("too many parts to process (%d > %d)", numparts, len(vols)) break stdvol = vols[numparts] logger.debug("voices to handle: %d, standard volume: %d", numparts, stdvol) # sort the voices according to the voiceorder list defined above logger.debug("unsorted voices: %s", myvoices) sortedvoices = [] for voice in voiceorder: for myvoice in myvoices: if myvoice == voice: sortedvoices.append(myvoice) logger.debug("sorting voice: %s", myvoice) continue if len(sortedvoices) == len(myvoices): myvoices = sortedvoices logger.debug("sorted voices: %s", myvoices) else: logger.warning("cannot sort voices: %s", myvoices) # calculate pan for each voice - distribute them equally from left (0) to right (127) pans = dict() mypos = 0 for myvoice in myvoices: mypos += 1 pans[myvoice] = (127 * mypos // numparts) - 127 // 2 // numparts logger.debug("pans: %s", pans) logger.info("voices: %s", list(pans)) # if we are not expecting to export emphasized or karaoke, we're done if args.emphasized == False and args.karaoke == False: continue # pass 2: set volume and pan for all voices logger.debug("################################################################################") logger.debug("pass 2: set base values for volume, pan, instrument") for part in parts: # now check volume, pan and instrument instrument = part.find("Instrument") shortname = instrument.find("shortName") trackname = instrument.find("trackName") if shortname == None: logger.debug("skipping part without shortname: %s", trackname.text) continue channel = instrument.find("Channel") # volume volctrl = channel.find("controller[@ctrl='7']") if volctrl == None: logger.debug("creating vol for voice %s", shortname.text) volctrl = ET.Element('controller', attrib={'ctrl': '7', 'value': str(stdvol)}) channel.append(volctrl) # set volume to standard volctrl.attrib['value'] = str(stdvol) vol = channel.find("controller[@ctrl='7']").attrib.get('value') # set pan as we calculated before panctrl = channel.find("controller[@ctrl='10']") if panctrl == None: logger.debug("creating pan for voice %s", shortname.text) panctrl = ET.Element('controller', attrib={'ctrl': '10', 'value': str(pans[shortname.text])}) channel.append(panctrl) panctrl.attrib['value'] = str(pans[shortname.text]) pan = channel.find("controller[@ctrl='10']").attrib.get('value') # the instrument we like for rehearsal program = channel.find("program") if program == None: logger.debug("creating program for voice %s", shortname.text) program = ET.element('program', attrib={'value': instr}) channel.append(program) # program.set('value', instr) program.attrib['value'] = instr prg = channel.find("program").attrib.get('value') logger.debug("voice: %s, vol: %s, pan: %s, instrument: %s", shortname.text, vol, pan, prg) # are we done? if args.emphasized == False and args.karaoke == False: continue ################################################################################ # here we export if desired # parts are only used in emphasized and karaoke mode, export as-is logger.debug("################################################################################") logger.debug("pass 3: set volume and color for each part, export, and reset values") for part in parts: ################################################################################ # handle parts in files ################################################################################ logger.debug("################################################################################") instrument = part.find("Instrument") shortname = instrument.find("shortName") trackname = instrument.find("trackName") if shortname == None: logger.debug("skipping part without shortname: %s", trackname.text) continue # here we should check if we have a semi-choir (1s, 2a, 3t etc). # if so, we have to find the corresponding main voice and set the values for that part aswell. if shortname.text[0].isdigit(): logger.debug("semi-choir %s, looking for main voice", shortname.text) for otherpart in parts: otherinstrument =otherpart.find("Instrument") othershortname = otherinstrument.find("shortName") othertrackname = otherinstrument.find("trackName") if othershortname == None: logger.debug("skipping part without shortname: %s", othertrackname.text) continue # we are looking for the base voice for the semi-choir (e.g. t for 1t) logger.debug("checking %s", othershortname.text) if othershortname.text != shortname.text[1:]: continue # so we have the base voice now logger.debug("base voice for %s: %s", shortname.text, othershortname.text) # to do: set values channel = instrument.find("Channel") volctrl = channel.find("controller[@ctrl='7']") staff = part.find("Staff") staffcolor = staff.find("color") stafftype = staff.find("StaffType") stafftypecolor = stafftype.find("color") # set values for this part if staffcolor == None: logger.debug("creating staffcolor for voice %s", shortname.text) staffcolor = ET.Element('color', attrib={'r': '255', 'g': '0', 'b': '0', 'a': '255'}) staff.append(staffcolor) staffcolor.attrib['r'] = str(args.color // 0x10000) staffcolor.attrib['g'] = str(args.color % 0x10000 // 0x100) staffcolor.attrib['b'] = str(args.color % 0x100) staffcolor.attrib['a'] = '255' logger.debug("color: %s", ET.tostringlist(staffcolor)) vol = volctrl.attrib.get('value') vol_orig = vol (root, ext) = os.path.splitext(workfile) workfile_part = root + ' ' + shortname.text + ext workfile_partk = root + ' ' + shortname.text + 'k' + ext workfile_partbak = root + ' ' + shortname.text + 'bak' + ext title_part = movementtitle + ' ' + shortname.text title_partk = movementtitle + ' ' + shortname.text + 'k' # pdf is here because it is the same for emphasized and karaoke if args.pdf: tree.write(workfile_part) msout(infile=workfile_part, suffix='pdf', srcfile=file) if args.emphasized: # set emphasized volume volctrl.attrib['value'] = vol_e # write workfile for the part tree.write(workfile_part) if args.mp3: mp3file= msout(infile=workfile_part, suffix='mp3', bitrate=args.bitrate, srcfile=file) # add id3 info logger.debug("adding id3 tags") id3.add(TIT2(encoding=3, text=title_part)) id3.save(mp3file) if args.musescore: msout(infile=workfile_part, suffix='mscz', srcfile=file) if args.uncompressed: msout(infile=workfile_part, suffix='mscx', srcfile=file) if args.xml: msout(infile=workfile_part, suffix='xml', srcfile=file) # remove work file # shutil.copyfile(workfile_part, workfile_partbak) if not args.uncompressed: os.remove(workfile_part) # reset volume volctrl.attrib['value'] = vol_orig if args.karaoke: # set karaoke volume volctrl.attrib['value'] = vol_k # write workfile for the part tree.write(workfile_partk) if args.mp3: mp3file= msout(infile=workfile_partk, suffix='mp3', bitrate=args.bitrate, srcfile=file, id3tags=id3) # add id3 info logger.debug("adding id3 tags") id3.add(TIT2(encoding=3, text=title_partk)) id3.save(mp3file) if args.musescore: msout(infile=workfile_partk, suffix='mscz', srcfile=file) if args.uncompressed: msout(infile=workfile_partk, suffix='mscx', srcfile=file) if args.xml: msout(infile=workfile_partk, suffix='xml', srcfile=file) # remove work file os.remove(workfile_partk) # reset volume volctrl.attrib['value'] = vol_orig # reset values for this part volctrl.attrib['value'] = vol_orig if staffcolor != None: staff.remove(staffcolor) if stafftypecolor != None: stafftype.remove(stafftypecolor) logger.debug("parts done for %s", workfile) logger.debug("done")