diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/fonts/CourierPrime-Bold.ttf b/fonts/CourierPrime-Bold.ttf new file mode 100644 index 0000000..91d6de4 Binary files /dev/null and b/fonts/CourierPrime-Bold.ttf differ diff --git a/fonts/CourierPrime-BoldItalic.ttf b/fonts/CourierPrime-BoldItalic.ttf new file mode 100644 index 0000000..0afaa98 Binary files /dev/null and b/fonts/CourierPrime-BoldItalic.ttf differ diff --git a/fonts/CourierPrime-Italic.ttf b/fonts/CourierPrime-Italic.ttf new file mode 100644 index 0000000..f8a20bd Binary files /dev/null and b/fonts/CourierPrime-Italic.ttf differ diff --git a/fonts/CourierPrime-Regular.ttf b/fonts/CourierPrime-Regular.ttf new file mode 100644 index 0000000..4f638f6 Binary files /dev/null and b/fonts/CourierPrime-Regular.ttf differ diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/chordFinder.py b/lib/chordFinder.py new file mode 100644 index 0000000..b0bc480 --- /dev/null +++ b/lib/chordFinder.py @@ -0,0 +1,11 @@ +# This file returns tablature for chords in different positions and voicings + +# TODO: we need to itemize all chords in the song and major/minor/diminshed/augmented/dom7/maj7/etc (we can support more voicings as we go) +# then for each chord, get location for each (C A G E D) shape +# for each shape, generate finger position tab, like so: +# B x24442 +# C#m x46654 +# Amaj7 x02120 +# F#m 244222 +# Am6 x04555 +# diff --git a/lib/dataStructures.py b/lib/dataStructures.py new file mode 100644 index 0000000..38e3435 --- /dev/null +++ b/lib/dataStructures.py @@ -0,0 +1,183 @@ +# !/usr/bin/python +# This file hosts the classes for storing song data +import re + +# TODO: move to separate file with helper functions like this +def stripEmptyLines(inputString): + nonEmptyLines = "" + lines = inputString.split("\n") + for line in lines: + if line.strip() != "": + nonEmptyLines += line + "\r\n" + return nonEmptyLines +# read .txt input TODO: move to separate input functions if we want to support multiple types of inputs some day, like web or PDF +def readSourceFile(inputFile): + with open(inputFile, 'r') as file: + return file.read() + +def isChordType(inputString): + if not inputString: + return + #print("Checking '{}' for line type".format(inputString)) + # Assume CHORD line if any NUMBER character + if any(char.isdigit() for char in inputString): + #print("'{}' is a CHORD line, since it contains a number".format(inputString)) + return True + # Assume CHORD line if any character {/, #, (, ), } + chordSpecificCharacterString = r"/#" + if any(elem in inputString for elem in chordSpecificCharacterString): + #print("'{}' is a CHORD line, since it contains a chord specific character".format(inputString)) + return True + # Assume LYRIC line if any TEXT character OTHER THAN {a, b, c, d, e, f, g, h, b, x, m} + lyricSpecificCharacterString = r"abcdefghbxm" + for char in inputString: + if char.isalpha(): + if not char.lower() in lyricSpecificCharacterString: + #print("'{}' is a LYRIC line, since it contains lyric specific text characters".format(inputString)) + return False + # Assume LYRIC line if any character {.} + lyricSpecialChars = r"." + if any(elem in inputString for elem in lyricSpecialChars): + #print("'{}' is a LYRIC line, since it contains lyric specific special characters".format(inputString)) + return False + # Else warn and assume chord line + #print("Unable to identify if '{}' is a lyric or chord line. Assuming it is a chord line. Please improve the isChordType function".format(inputString)) + return True + + +class Section: + def __init__(self): + # List of lines of lyrics strings + self.lyrics = [] + # List of lines of chord strings + self.chords = [] + # section type string + self.header = "" + # string of chord and lyric data + self.rawData = "" + # Flag for succesfully parsed + self.isParsed = False + + # Parses self.rawData into lyrics and chord strings + def parseMe(self): + isFirstLine = True + # Input sections may have chord-only or lyric-only sections + # So we have to insert empty lines if we have subsequent chord or lyric lines + lines = self.rawData.split('\r\n') + for line in lines: + # Determine lyric or chord line + currentIsChord = isChordType(line) + # Initially just fill in the first line correctly + if isFirstLine: + isFirstLine = False + if currentIsChord: + self.chords.append(line) + else: + self.lyrics.append(line) + # We want alternating lines, so if the prev is of the same type + # we need to insert an empty line of the other type + elif currentIsChord == prevWasChord: + if currentIsChord: + #print("Inserting empty Lyric line") + self.chords.append(line) + self.lyrics.append("") + else: + #print("Inserting empty Chord line") + self.lyrics.append(line) + self.chords.append("") + # also insert the current line + elif currentIsChord: + #print("Inserting empty Lyric line") + self.chords.append(line) + else: + self.lyrics.append(line) + + prevWasChord = currentIsChord + # Simple check to see if it worked + if abs(len(self.lyrics) - len(self.chords)) > 1: + print("Unable to parse section, since there is a mismatch between the amount of chord and lyric lines.") + return + # Add a final empty line if necessary + elif len(self.lyrics) > len(self.chords): + self.chords.append("") + elif len(self.lyrics) < len(self.chords): + self.lyrics.append("") + self.isParsed = True + + +class Song: + def __init__(self): + # Src file + self.inputFile = "" + # Path to folder + self.outputLocation = "" + # Title - based on input file + self.title = "" + # List of Section objects + self.sections = [] + # Meta info: the text before the first section + self.metadata = "" + # String of entire input + self.rawData = "" + # Flag for succesfully parsed + self.isParsed = False + + # Parses self.rawData into Section objects and metadata + def parseMe(self): + # Fill raw data + self.rawData = readSourceFile(self.inputFile) + # Clean up input + parseData = stripEmptyLines(self.rawData) + #print("Clean data='{}'\n".format(parseData)) + # While !EOF: build sections (untill []). + delimiterIndex = parseData.find("[") + if delimiterIndex == -1: + print("Cannot parse input file, since it is not delimited by '[]' entries") + return + # Start with metadata + self.metadata = parseData[:delimiterIndex] + #print("Set '{}' as metadata".format(self.metadata)) + parseData = parseData[delimiterIndex:] + # We are now at the start of the first section, at the '[' character + while parseData: + thisSection = Section() + # Get first line + delimiterIndex = parseData.find("]\r\n") + if delimiterIndex == -1: + print("Cannot parse input file, delimitor did not match '[]'") + return + # Set header to first line + thisSection.header = parseData[:delimiterIndex+3] + parseData = parseData[delimiterIndex+3:] + # Find next section + delimiterIndex = parseData.find("[") + # If EOF, current buffer is final section + if delimiterIndex == -1: + # Set current section data to remaining buffer + thisSection.rawData = parseData + parseData = "" + else: + # Set thisSection's data and remove it from the buffer + thisSection.rawData = parseData[:delimiterIndex] + #print("set rawData of '{}' to this section".format(thisSection.rawData)) + parseData = parseData[delimiterIndex:] + # Finally parse section data + thisSection.parseMe() + if thisSection.isParsed: + self.sections.append(thisSection) + else: + print("Aborting parse due to section not being parseable.") + return + self.isParsed = True + + + + + + + + + + + + diff --git a/lib/initSongs.py b/lib/initSongs.py new file mode 100644 index 0000000..500948d --- /dev/null +++ b/lib/initSongs.py @@ -0,0 +1,42 @@ +# !/usr/bin/python +# Iterate through input folders and create a list of Song objects +import lib.dataStructures +import os + +# For now manually whitelist folders to convert +whitelist = ["/mnt/koios/Band/1-sugmesties", "/mnt/koios/Band/2-oefenen", "/mnt/koios/Band/3-uitgewerkt"] + +def initSong(filePath): + thisSong = lib.dataStructures.Song() + thisSong.inputFile = filePath + # set base folder name - depending on selected outputs the output folder name changes + thisSong.outputLocation = filePath[:filePath.rfind('.')] + # title is just the name of the .txt file + thisSong.title = thisSong.outputLocation[filePath.rfind('/')+1:] + #print("Finished init for input file '{}'.\nBase output folder is '{}'\nSong title is '{}'\n".format(thisSong.inputFile, thisSong.outputLocation, thisSong.title)) + return thisSong + + +def getSongObjects(): + # path to song folders, which MAY contain a .txt source file + txtFileLocations = [] + # list of Song objects + songList = [] + + # get all subdirectories + for inputFolder in whitelist: + for root, dirs, files in os.walk(inputFolder): + for name in files: + if(name[name.rfind('.'):] == ".txt"): + filePath = os.path.join(root, name) + #print("Found .txt file '{}'".format(filePath)) + txtFileLocations.append(filePath) + #else: + #print("Skipping file '{}' for it is not a .txt file".format(name)) + + # go through all input locations. find .txt files. for each .txt file initSong. return list + while(txtFileLocations): + filePath = txtFileLocations.pop() + if (filePath != ""): + songList.append(initSong(filePath)) + return songList diff --git a/lib/transpose.py b/lib/transpose.py new file mode 100644 index 0000000..518d8f4 --- /dev/null +++ b/lib/transpose.py @@ -0,0 +1,10 @@ +# This file takes a string corresponding to chord data and transposes it + +slider = ['E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B', 'C', 'Db', 'D', 'Eb'] +# TODO: take a line of chord data, for each string enclosed in whitespace or tabs: + # ignore if its in major or minor, just take the ROOT of the chord + # then get its index in the slider + # then add/subtract transposition amount and loop around the slider if it goes over + # make sure to keep line width persistent: + # if from E to Eb for example, remove a whitespace + # if from Eb to D for example, add a whitespace diff --git a/main.py b/main.py new file mode 100644 index 0000000..8f2c756 --- /dev/null +++ b/main.py @@ -0,0 +1,24 @@ +# !/usr/bin/python +# This program converts all songs in a given directory to a printable format +import lib.chordFinder +import lib.dataStructures +import lib.initSongs +import lib.transpose +import output2img + +def main(): + # Init Song objects for all songs with compatible inputs + songs = lib.initSongs.getSongObjects() + # Convert all songs into sections + for song in songs: + song.parseMe() + # Parse as PNG a4 + if song.isParsed: + # Create subdirectory where we will output our images + targetDirectory = song.outputLocation + "-a4-png" + print("Successfully parsed '{}' file. Writing output to '{}'".format(song.inputFile, targetDirectory)) + # Write out metadata and sections, as many as can fit on one page + output2img.outputToImage(targetDirectory, song) + +if __name__ == "__main__": + main() diff --git a/output2img.py b/output2img.py new file mode 100644 index 0000000..9846d00 --- /dev/null +++ b/output2img.py @@ -0,0 +1,122 @@ +# !/usr/bin/python +# This program converts Song objects to imgs printable on A4 paper +import os +import lib.dataStructures +from PIL import Image, ImageDraw, ImageFont + +# size and font of metadata +metaFontFamily = 'fonts/CourierPrime-Regular.ttf' +metaFontWeight = 8 + +# size and font of chord and lyric text +lyricFontFamily = 'fonts/CourierPrime-Regular.ttf' +chordFontFamily = 'fonts/CourierPrime-Bold.ttf' +songFontWeight = 14 + +# image properties +imageWidth, imageHeight = (595, 842) # A4 at 72dpi +background = (255, 255, 255) +fontMetadata = ImageFont.truetype(metaFontFamily, metaFontWeight) +fontLyrics = ImageFont.truetype(lyricFontFamily, songFontWeight) +fontChords = ImageFont.truetype(chordFontFamily, songFontWeight) +fontColour = () +topMargin = 10 +leftMargin = 25 + +# return expected height of rendering the complete current section +def calcSectionHeight(section): + lineIterator = 0 + amountOfLines = len(section.lyrics) + heightSum = 0 + # add section title + headerWidth, headerHeight = fontChords.getsize(section.header) + heightSum += headerHeight + while lineIterator < amountOfLines: + # Get chord&lyric line + lyricTextWidth, lyricTextHeight = fontLyrics.getsize(section.lyrics[lineIterator]) + chordTextWidth, chordTextHeight = fontChords.getsize(section.chords[lineIterator]) + heightSum += lyricTextHeight + chordTextHeight + lineIterator += 1 + + return heightSum + +def outputToImage(folderLocation, songObj): + # Create target Directory if don't exist + if not os.path.exists(folderLocation): + os.mkdir(folderLocation) + print("Directory " , folderLocation , " Created ") + #else: + #print("Directory " , folderLocation , " already exists") + + # Init image info + imageNumber = 1 + currentHeight = topMargin + + # New Image + a4image = Image.new('RGB',(imageWidth, imageHeight),(background)) + draw = ImageDraw.Draw(a4image) + + # Write metadata + for line in songObj.metadata.split('\n'): + # remove any unwanted characters from metadat + line = line.rstrip() + if not line: + continue + #print("meta line '{}'".format(line)) + metadataTextWidth, metadataTextHeight = fontMetadata.getsize(line) + draw.text((leftMargin,currentHeight), line, fill=(128, 128, 128), font=fontMetadata) + currentHeight += metadataTextHeight + + currentHeight += topMargin + + for section in songObj.sections: + lineIterator = 0 + amountOfLines = len(section.lyrics) + if (amountOfLines != len(section.chords)): + print("Cannot write this section to file, since it was not processed correctly. There are {} chord lines and {} lyric lines. Aborting...".format(len(section.chords), amountOfLines)) + return + # See if it can fit on the current page - if it does not, write & reset + if currentHeight + calcSectionHeight(section) > imageHeight: + #print("overflow! starting with a new image") + outputLocation = folderLocation + "/" + str(imageNumber) + ".png" + imageNumber += 1 + a4image.save(outputLocation) + currentHeight = topMargin + a4image = Image.new('RGB',(imageWidth, imageHeight),(background)) + draw = ImageDraw.Draw(a4image) + + # add section title + headerWidth, headerHeight = fontChords.getsize(section.header) + draw.text((leftMargin,currentHeight), section.header, fill=(0, 0, 0), font=fontChords) + currentHeight += headerHeight + while lineIterator < amountOfLines: + #print("Printing chord line {} and lyrics line {}".format(section.chords[lineIterator], section.lyrics[lineIterator])) + # Get chord&lyric line + lyricTextWidth, lyricTextHeight = fontLyrics.getsize(section.lyrics[lineIterator]) + chordTextWidth, chordTextHeight = fontChords.getsize(section.chords[lineIterator]) + # add to image file + draw.text((leftMargin,currentHeight), section.chords[lineIterator], fill=(0, 0, 0), font=fontChords) + currentHeight += chordTextHeight + draw.text((leftMargin,currentHeight), section.lyrics[lineIterator], fill=(0, 0, 0), font=fontLyrics) + currentHeight += lyricTextHeight + lineIterator += 1 + #print("currentheight={}".format(currentHeight)) + currentHeight += topMargin + + # Write remaining image to file as well + outputLocation = folderLocation + "/" + str(imageNumber) + ".png" + a4image.save(outputLocation) + + + + + + + + + + + + + +