83 lines
3.5 KiB
Python
83 lines
3.5 KiB
Python
"""Content-addressed, non-executable catalog compilation.
|
|
|
|
Only validated production XML is compiled here. The cache contains no song,
|
|
role, candidate, reference, or fitted musical result. A checksum and decoder
|
|
version protect stale/corrupt payloads; no pickle or executable serialization.
|
|
"""
|
|
from __future__ import annotations
|
|
import dataclasses
|
|
from fractions import Fraction as F
|
|
import functools
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
from generator import rhythm, vocabulary
|
|
from generator.scales import library as scales
|
|
from generator.solo import sequences
|
|
|
|
VERSION = 'catalog-json-1'
|
|
|
|
|
|
def identity(directory):
|
|
directory=Path(directory)
|
|
return {name:vocabulary.fingerprint([(p.relative_to(directory/name).as_posix(),
|
|
hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted((directory/name).rglob('*.xml'))])
|
|
for name in ('scales','sequences','rhythms')}
|
|
|
|
|
|
def encode(libraries):
|
|
scale,sequence,rhythms=libraries
|
|
return {'scales':[dataclasses.asdict(p) for p in scale.patterns],
|
|
'sequences':[dataclasses.asdict(p) for values in sequence.paths.values() for p in values],
|
|
'rhythms':[dataclasses.asdict(p) for values in rhythms.snippets.values() for p in values]}
|
|
|
|
|
|
def decode(value):
|
|
scale=scales.Library(tuple(scales.Pattern(p['family'],p['mode'],tuple(p['intervals'])) for p in value['scales']))
|
|
sequence=sequences.Library(tuple(sequences.Sequence(p['id'],tuple(p['steps'])) for p in value['sequences']))
|
|
snippets=[]
|
|
for p in value['rhythms']:
|
|
bars=[]
|
|
for b in p['bars']:
|
|
events=tuple(rhythm.Event(F(e['onset']),F(e['duration']),e['sounding'],e['written_type'],e['dots'],
|
|
tuple(tuple(v) for v in e['beams']),e['continuation'],F(e['ratio'])) for e in b['events'])
|
|
bars.append(rhythm.Bar(events,F(b['length'])))
|
|
snippets.append(rhythm.Snippet(p['id'],p['meter'],tuple(bars)))
|
|
return scale,sequence,rhythm.Library(tuple(snippets))
|
|
|
|
|
|
def load(directory, fingerprints=None, cache_dir=None):
|
|
directory=Path(directory)
|
|
fingerprints=fingerprints or identity(directory)
|
|
# Code edits invalidate compiled importer semantics, but do not reseed music.
|
|
root=Path(__file__).parent
|
|
code=[root/n for n in ('resources.py','rhythm.py','musicxml.py','model.py','pitch.py','harmony.py','solo/sequences.py','scales/library.py')]
|
|
key=vocabulary.fingerprint({'version':VERSION,'inputs':fingerprints,
|
|
'decoder':[(p.relative_to(root).as_posix(),hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(code)]})
|
|
return _load(str(directory.resolve()),key,str(Path(cache_dir).resolve()) if cache_dir else '')
|
|
|
|
|
|
@functools.lru_cache(maxsize=4)
|
|
def _load(directory,key,cache_dir):
|
|
path=Path(cache_dir)/'_resources'/(key+'.json') if cache_dir else None
|
|
if path and path.is_file() and not path.is_symlink():
|
|
try:
|
|
entry=json.loads(path.read_text())
|
|
if entry['key']==key and vocabulary.fingerprint(entry['value'])==entry['digest']:
|
|
return decode(entry['value'])
|
|
except (ValueError,KeyError,TypeError,OverflowError):
|
|
pass
|
|
root=Path(directory)
|
|
libraries=(scales.load_library(root/'scales'),sequences.load_library(root/'sequences'),rhythm.load_library(root/'rhythms'))
|
|
if path:
|
|
value=json.loads(json.dumps(encode(libraries),default=str))
|
|
path.parent.mkdir(parents=True,exist_ok=True)
|
|
with tempfile.NamedTemporaryFile('w',dir=path.parent,delete=False) as out:
|
|
json.dump({'key':key,'digest':vocabulary.fingerprint(value),'value':value},out,separators=(',',':'))
|
|
temporary=out.name
|
|
os.replace(temporary,path)
|
|
return libraries
|