|
#!/usr/bin/env python |
|
|
|
""" |
|
Fuzzy music searcher |
|
|
|
This script takes a music fragment written in music21's TinyNotation, fuzzy-searches through |
|
the current directory for matching musicxml files, and outputs the names of the files and the |
|
match percentages of their best-matching subsequence. |
|
|
|
It's sometimes hard to get a TinyNotation string right on the first try, so this script also is |
|
capable of rendering the input string as an image of music notation. This feature uses iTerm2's |
|
inline image feature, and thus is not available in other terminal emulators. |
|
|
|
You can find more info on TinyNotation here: https://web.mit.edu/music21/doc/usersGuide/usersGuide_16_tinyNotation.html |
|
|
|
Example: |
|
$ search.py "a8 b c d' e' f'" -s |
|
<image of notation> |
|
$ search.py "a8 b c d' e' f'" |
|
55% file1.mxl |
|
63% file2.mxl |
|
95% file3.mxl |
|
|
|
Installation: |
|
* Clone the repo |
|
* Make a virtualenv with python3 and install the requirements: pillow, fuzzywuzzy[speedup], git+https://github.com/maxrothman/music21 |
|
* That's it! |
|
|
|
Notes: |
|
|
|
* Without the [speedup] version of fuzzywuzzy, you'll get incorrect results! By default, |
|
difflib.SequenceMatcher does automatic junk detection and fuzzywuzzy doesn't expose an option to |
|
turn it off. using [speedup] makes fuzzywuzzy use python-Levenshtein instead, which doesn't do any |
|
junk detection (thankfully). |
|
|
|
* This script currently depends on my fork of music21 for ossia detection. You'll have to install |
|
the fork if you want ossia detection to work, at least until it's merged back into the main music21 repo. |
|
(See https://github.com/cuthbertLab/music21/pull/449) |
|
|
|
* This script only works on scores that only have a single non-ossia part, and if ossias are |
|
annotated in musicxml using the <staff-details> element. If you have a multi-part score |
|
where the ossia part(s) are not annotated this way, you can use parts2ossia.py to fix it. |
|
|
|
How it works: |
|
|
|
The fuzzy searching works by using music21's search.translateIntervalsAndSpeed() to transform a |
|
flattened part into a string encoding the relative speeds and intervals of adjacent notes. This way, |
|
matches don't depend on the notated speed and key of scores. Ossias (aka variants) are activated one |
|
at a time, transformed, and appended to the string. Then the search string is likewise converted and |
|
fuzzywuzzy is used to perform a fuzzy substring search for the search string in the score string. |
|
This is repeated for all *.mxl files in the current directory, and matches are ouptutted. |
|
""" |
|
|
|
from argparse import ArgumentParser |
|
from base64 import b64encode |
|
from io import BytesIO |
|
from pathlib import Path |
|
import sys |
|
from textwrap import dedent |
|
|
|
from fuzzywuzzy import fuzz |
|
import music21 as m21 # Hot spot |
|
from PIL import Image |
|
|
|
# NB: if something should come up in search and doesn't, make sure there's a .mxl file for it. |
|
# To check for missing mxl files, use `comm -2 -3 <(basename -s .mscz *.mscz | sort) <(basename -s .mxl *.mxl | sort)` |
|
|
|
def parse_args(): |
|
parser = ArgumentParser( |
|
description = """ |
|
Search for musicxml files in the current directory that match a TinyNotation-formatted |
|
music fragment |
|
""" |
|
) |
|
parser.add_argument('search_string', help="Music fragment to search for") |
|
parser.add_argument('-s', '--show', action='store_true', |
|
help="Show the fragment as an image instead of searching for it. Requires iTerm2.") |
|
|
|
return parser.parse_args() |
|
|
|
|
|
def score2str(score): |
|
""" |
|
Translate a m21 score/stream into a searchable string |
|
""" |
|
# Turn any ossia-like parts into actual variants |
|
parts = list(score.recurse().getElementsByClass('Part')) |
|
if parts: |
|
ossias = [p for p in parts if p.metadata and p.metadata.custom.get('staff-type') == 'ossia'] |
|
|
|
assert len(score.parts) - len(ossias) == 1, ( |
|
"I don't know how to handle scores with more than 1 non-ossia part! " |
|
"If the other parts were supposed to be ossias, try running parts2ossia.py on the score." |
|
) |
|
main_part = (set(parts) - set(ossias)).pop() |
|
|
|
for ossia in ossias: |
|
m21.variant.mergePartAsOssia(main_part, ossia, ossia.id, inPlace=True) |
|
else: |
|
main_part = score |
|
|
|
|
|
def part2str(part): |
|
# Chord symbols count as notes for some reason, so we have to filter them out or else |
|
# they'll make translateIntervalsAndSpeed blow up |
|
flat = part.flat.notesAndRests.getElementsNotOfClass(m21.harmony.ChordSymbol) |
|
return m21.search.translateIntervalsAndSpeed(flat) |
|
|
|
result = part2str(main_part) |
|
|
|
# Search each variant separately |
|
for variant in main_part.variants: |
|
variant_part = main_part.activateVariants(variant.groups[0]) |
|
|
|
result += '|||||' + part2str(variant_part) |
|
|
|
return result |
|
|
|
|
|
# The cutoff is arbitrary, feel free to change it |
|
def search(search_str, path='.', cutoff=50): |
|
results = [] |
|
for mxl_file in Path(path).glob('*.mxl'): |
|
work = m21.converter.parseFile(mxl_file) # Hot spot |
|
|
|
try: |
|
work_str = score2str(work) |
|
except AssertionError as e: |
|
raise AssertionError(f'{str(e)}: {mxl_file}') |
|
|
|
match_pct = fuzz.partial_ratio(search_str, work_str) |
|
if match_pct > cutoff: |
|
results.append((mxl_file.name, match_pct)) |
|
|
|
return sorted(results, key=lambda x: x[1]) |
|
|
|
|
|
#TODO: leaves the cursor a weird color until next newline |
|
def show_iterm(score): |
|
""" |
|
Use iTerm2's inline image protocol to show an image of rendered music notation |
|
See <https://www.iterm2.com/documentation-images.html> for details |
|
""" |
|
file_name = score.write('musicxml.png') |
|
|
|
# By default, the score image has a transparent background, which doesn't show up great on |
|
# a black terminal background. Instead, give it a white background. |
|
# Stolen from https://stackoverflow.com/questions/9166400/convert-rgba-png-to-rgb-with-pil/9459208 |
|
png = Image.open(file_name) |
|
png.load() |
|
background = Image.new('RGB', png.size, (255, 255, 255)) |
|
background.paste(png, mask=png.split()[3]) |
|
|
|
buffer = BytesIO() |
|
background.save(buffer, 'PNG') |
|
|
|
sys.stdout.buffer.write(b"\033]1337;File=inline=1:" + b64encode(buffer.getvalue())) |
|
|
|
|
|
def main(): |
|
args = parse_args() |
|
search_fragment = m21.converter.parse(f'tinynotation: {args.search_string}') |
|
if args.show: |
|
show_iterm(search_fragment) |
|
else: |
|
results = search(score2str(search_fragment)) |
|
print('\n'.join([f'{int(r[1])}% {r[0]}' for r in results])) |
|
|
|
|
|
|
|
if __name__ == '__main__': |
|
main() |