"""LilyScript β€” a symbolic-music AIGC app built on the LilyletNotaGen model. Left column: (1) generation parameter panel (2) streaming run log (3) .lyl file list (session outputs + built-in examples) (4) editable lyl editor Right column: sheet-music panel (placeholder for now; later a Lilylet music score renderer reusing the lilylet-live-editor pipeline). Generation streams patch-by-patch: raw decoded text (with `[r:x/y]` stream markers) goes to the run log, while the measure-segmented postprocessed text fills the editor. The backend is the int8 + two-level KV-cache ONNX generator (see lilyscript/generator.py); weights are pulled from the HF model repo `k-l-lambda/LilyNota` on first use (override with LILYSCRIPT_MODEL_DIR locally). """ import os import re import time import json import random import logging from collections import deque import gradio as gr from lilyscript.generator import StreamingLilyletGenerator from lilyscript.postprocess import postprocess from lilyscript.mask_monitor import MaskMonitor, load_blacklist from lilyscript.lang import T, LANG HERE = os.path.dirname(os.path.abspath(__file__)) # Model weights are pulled from a model hub at first use (the int8 + KV-cache ONNX # bundle lives under its `onnx/` dir). The repo is configured via LILYSCRIPT_MODEL_REPO: # - "owner/name" -> HuggingFace Hub (default) # - "modelscope:owner/name" -> ModelScope hub (for users behind the GFW / in China) # For local development, point LILYSCRIPT_MODEL_DIR at a local onnx dir to skip the # download, or drop the bundle into the repo-local `models/` dir. HF_MODEL_REPO = os.environ.get('LILYSCRIPT_MODEL_REPO', 'k-l-lambda/LilyNota') HF_MODEL_SUBDIR = 'onnx' # weights + geometry + tokenizer live here in the repo MODEL_DIR = os.environ.get('LILYSCRIPT_MODEL_DIR') # set -> use this local dir instead of the hub LOCAL_MODEL_DIR = os.path.join(HERE, 'models') # repo-local onnx bundle; preferred over the hub when present ASSET_DIR = os.path.join(HERE, 'assets') EXAMPLES_DIR = os.path.join(HERE, 'examples') OUTPUT_DIR = os.path.join(HERE, 'outputs') WEB_DIR = os.path.join(HERE, 'web') # vendored browser libs + score player (gitignored bundles) EXAMPLE_PREFIX = '\U0001F4C4 ' # πŸ“„ examples OUTPUT_PREFIX = '✨ ' # ✨ session outputs # Suggested metadata values (editable β€” the dropdowns allow custom input), loaded # from assets/styles.json. Drawn from the NotaGenX period/instrumentation # vocabulary + values seen in examples. _STYLES = json.load(open(os.path.join(ASSET_DIR, 'styles.json'), encoding='utf-8')) COMPOSERS = _STYLES['composers'] PERIODS = _STYLES['periods'] GENRES = _STYLES['genres'] _GEN = None # Syntax-blacklist mask: discovered 2-grams whose forbidden next-tokens are masked # during sampling so the model can't emit those locally-illegal continuations. # Loaded once at startup; a fresh (stateful) MaskMonitor is built per generation. # Empty/missing file -> no masking (behavior unchanged). BLACKLIST_PATH = os.path.join(ASSET_DIR, 'lilylet-blacklist.json') _BLACKLIST = load_blacklist(BLACKLIST_PATH) # ---- system log capture ---------------------------------------------------- # A ring buffer that mirrors Python logging (lifecycle messages, warnings, and # errors) into the Logs panel, alongside the streamed generation text. LOG = logging.getLogger('lilyscript') class _RingBufferHandler (logging.Handler): '''Keep the most recent N log records as formatted strings.''' def __init__ (self, capacity=400): super().__init__() self.records = deque(maxlen=capacity) def emit (self, record): try: self.records.append(self.format(record)) except Exception: pass def text (self): return '\n'.join(self.records) _LOG_BUFFER = _RingBufferHandler() _LOG_BUFFER.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s: %(message)s', datefmt='%H:%M:%S')) def _init_logging (): '''Route app + library logs and Python warnings into the ring buffer (once), and also echo to stderr so the terminal keeps a copy.''' logging.captureWarnings(True) root = logging.getLogger() if _LOG_BUFFER not in root.handlers: root.addHandler(_LOG_BUFFER) stderr_h = logging.StreamHandler() stderr_h.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s: %(message)s', datefmt='%H:%M:%S')) root.addHandler(stderr_h) root.setLevel(logging.INFO) LOG.setLevel(logging.INFO) _init_logging() def resolve_model_dir (): '''Where the ONNX weights live, in priority order: 1. LILYSCRIPT_MODEL_DIR (explicit override, local dev), 2. the repo-local `models/` dir IF it holds the full weight bundle, 3. otherwise pull the `onnx/` bundle from the configured model hub (HuggingFace by default; ModelScope when LILYSCRIPT_MODEL_REPO is prefixed with `modelscope:`). The tokenizer is NOT pulled β€” it's read from the app's own assets/ dir β€” so we only fetch the weight files.''' if MODEL_DIR: return MODEL_DIR required = ('geometry.json', 'patch_kv_int8.onnx', 'token_kv_int8.onnx', 'wte.npy') if os.path.isdir(LOCAL_MODEL_DIR) and all( os.path.isfile(os.path.join(LOCAL_MODEL_DIR, name)) for name in required): LOG.info('using local model weights in %s', LOCAL_MODEL_DIR) return LOCAL_MODEL_DIR # Only the four weight files under `onnx/` are needed (the tokenizer is local). patterns = [f'{HF_MODEL_SUBDIR}/{name}' for name in required] # Dispatch on the `modelscope:` prefix. Both hubs' snapshot_download share the # `allow_patterns` semantics (glob over repo-relative paths) and return the local # snapshot root, so the `onnx/` subdir join below is the same for either backend. if HF_MODEL_REPO.startswith('modelscope:'): repo_id = HF_MODEL_REPO[len('modelscope:'):] try: from modelscope.hub.snapshot_download import snapshot_download except ImportError as e: raise RuntimeError( 'LILYSCRIPT_MODEL_REPO is set to a modelscope: repo but the modelscope ' 'package is not installed. Run `pip install modelscope` (see requirements.txt).' ) from e LOG.info('downloading model weights from modelscope:%s (%s/) ...', repo_id, HF_MODEL_SUBDIR) local = snapshot_download(repo_id, allow_patterns=patterns) else: from huggingface_hub import snapshot_download LOG.info('downloading model weights from hf:%s (%s/) ...', HF_MODEL_REPO, HF_MODEL_SUBDIR) local = snapshot_download(repo_id=HF_MODEL_REPO, allow_patterns=patterns) return os.path.join(local, HF_MODEL_SUBDIR) def get_generator (): '''Lazily build the (heavy) ONNX generator on first use.''' global _GEN if _GEN is None: model_dir = resolve_model_dir() LOG.info('loading ONNX generator from %s ...', model_dir) t0 = time.perf_counter() _GEN = StreamingLilyletGenerator(model_dir, ASSET_DIR) LOG.info('generator ready (%.1fs)', time.perf_counter() - t0) LOG.info('syntax blacklist: %d contexts / %d forbidden pairs from %s', len(_BLACKLIST), sum(len(v) for v in _BLACKLIST.values()), BLACKLIST_PATH) return _GEN def load_examples (): '''Read built-in example .lyl files into a {label: text} dict. Examples may be stored raw (with inline `[r:x/y]` stream markers and run-together header lines); run them through `postprocess` so the editor β€” and the score renderer it feeds β€” get syntactically clean Lilylet. It's a no-op on already-clean text.''' store = {} if os.path.isdir(EXAMPLES_DIR): for name in sorted(os.listdir(EXAMPLES_DIR)): if name.endswith('.lyl'): with open(os.path.join(EXAMPLES_DIR, name), encoding='utf-8') as f: store[EXAMPLE_PREFIX + name] = postprocess(f.read()) return store def load_outputs (): '''Read previously-generated .lyl files from the outputs dir into a {label: text} dict, so past session outputs survive a server restart.''' store = {} exists = os.path.isdir(OUTPUT_DIR) names = sorted(os.listdir(OUTPUT_DIR)) if exists else [] lyls = [n for n in names if n.endswith('.lyl')] LOG.info('load_outputs: OUTPUT_DIR=%s exists=%s total_entries=%d lyl_files=%d', OUTPUT_DIR, exists, len(names), len(lyls)) if exists: for name in lyls: with open(os.path.join(OUTPUT_DIR, name), encoding='utf-8') as f: store[OUTPUT_PREFIX + name[:-4]] = f.read() return store def load_library (): '''Initial file list: built-in examples + any persisted session outputs.''' return {**load_examples(), **load_outputs()} def refresh_library (): '''Re-read the library from disk and return updates for (store, file_list). Wired to demo.load so EVERY new session / page refresh picks up outputs that were generated after server boot. Without this, store=gr.State(examples) and the Radio's choices are frozen at the boot-time snapshot, so a refresh drops any freshly-generated score from the Score List even when it persisted to disk.''' lib = load_library() LOG.info('refresh_library: %d entries (%d examples + %d outputs)', len(lib), len(load_examples()), len(load_outputs())) return lib, gr.update(choices=list(lib.keys())) # A managed style line in the new `--styles-in-comments` format: a leading `%` # comment carrying period / composer / instrumentation (one per line). `sync_prompt` # regenerates these from the dropdowns; any other line the user typed is preserved. _STYLE_LINE_RE = re.compile(r'^%(?!%).*$') def sync_prompt (composer, period, genre, current): '''Rewrite the metadata-prompt text from the three style dropdowns. The model is trained on the `--styles-in-comments` format: style is carried by leading `%` comment lines, ordered period/composer/instrumentation (matching abc2lilylet's catalogCommentLines: % / % / %). These are regenerated from the dropdowns and placed at the top; any other line the user typed (e.g. a `[staves "..."]` header) is preserved below in its original order. ''' lines = [] for value in (period, composer, genre): value = (value or '').strip() if value: lines.append(f'%{value}') # keep every line that isn't one of the managed `%
%s
''' % T('loading_renderer') # Static-file URL prefix Gradio serves allowed_paths under (verified at 6.18.0). def _file_url (path): return '/gradio_api/file=' + path def build_head (): ''' injection: load the vendored browser libs (lilylet bundle, Verovio WASM, music-widgets) then the score player, and point the soundfont loader at the vendored copy. Gradio delivers this via its client config and injects it on the frontend, so absolute `/gradio_api/file=` URLs resolve correctly.''' vendor = os.path.join(WEB_DIR, 'vendor') scripts = [ os.path.join(vendor, 'lilylet.bundle.js'), os.path.join(vendor, 'verovio.bundle.js'), os.path.join(vendor, 'musicWidgetsBrowser.umd.min.js'), # FluidSynth audio backend: js-synthesizer UMD (window.JSSynth) + our adapter # (window.LilyFluidAudio). Must precede score-player.js, which calls # LilyFluidAudio.init() in initAudio(). The libfluidsynth runtime + gm.sf3 are # loaded on demand by the adapter from web/fluid/ and web/soundfont/. os.path.join(vendor, 'js-synthesizer.min.js'), os.path.join(WEB_DIR, 'fluid-audio.js'), os.path.join(WEB_DIR, 'score-player.js'), # our own CodeMirror 6 editor (CM + grammar-derived lilylet() highlighter) and # the mount/bridge script that wires it to the hidden #ls-editor-state textbox. os.path.join(vendor, 'lyl-editor.bundle.js'), os.path.join(WEB_DIR, 'lyl-editor-mount.js'), # pako (deflate) + js-base64: used by the "Open in live-editor" share link to # compress+url-safe-base64 the editor text into a ?code= param, matching # lilylet-live-editor's share.ts encoding exactly. Expose window.pako/Base64. os.path.join(vendor, 'pako_deflate.min.js'), os.path.join(vendor, 'js-base64.js'), # computes --ls-fill-h so the bottom Score List | editor row fills the height # left under Compose + Logs (robust against Gradio's flex-nesting quirks). os.path.join(WEB_DIR, 'layout-fit.js'), ] tags = ['' % (_file_url(os.path.join(WEB_DIR, 'soundfont')) + '/', _file_url(os.path.join(WEB_DIR, 'fluid')) + '/')] tags.append('' % _file_url(os.path.join(WEB_DIR, 'score-player.css'))) tags.append('' % _file_url(os.path.join(WEB_DIR, 'lyl-editor.css'))) for s in scripts: tags.append('' % _file_url(s)) return '\n'.join(tags) # ---- client-side glue (Gradio `js=` handlers) ------------------------------- # These run in the browser. They wait for LilyScore (score-player.js) to load, # then mount it into #ls-score and drive render / generation-gating. _JS_HELPERS = ''' function () { // poll until the score player + its #ls-score mount exist, then mount once. const tryMount = () => { const root = document.getElementById('ls-score'); if (window.LilyScore && root) { window.LilyScore.mount(root); return true; } return false; }; if (!tryMount()) { const iv = setInterval(() => { if (tryMount()) clearInterval(iv); }, 200); setTimeout(() => clearInterval(iv), 20000); } // Initial render on RELOAD/restore. Gradio repopulates the hidden #ls-editor-state // textbox with the previous score on a page reload but does NOT fire a `change` // event, so the normal change->render path never runs and the sheet stays blank. // This is self-healing against the reload race (mount, Gradio's textbox restore, // and Verovio init all settle at different times): poll until an SVG has actually // appeared, re-calling render each tick while the editor has text but no SVG is // shown yet. render() has a lastCode no-op guard, so repeat calls with the same // text are cheap once it has succeeded. On a FRESH deep-link load the editor // starts empty and the radio-click path renders it, so the SVG shows up and this // loop stops without ever calling render itself (no double render). { let rtries = 0; const initialRender = () => { const root = document.getElementById('ls-score'); const shown = root && root.querySelector('.ls-svg svg'); if (shown) return; // something rendered β€” done const ta = document.querySelector('#ls-editor-state textarea'); const txt = ta && ta.value; if (window.LilyScore && root && txt && txt.trim()) { try { window.LilyScore.render(txt); } catch (e) {} } if (++rtries < 100) setTimeout(initialRender, 150); }; initialRender(); } // Deep-link: if the URL carries #score=, select that Score List entry on // first load (so a bookmarked/shared score opens directly). We strip the leading // emoji prefix (πŸ“„/✨) when comparing, and click the matching radio input β€” that // fires Gradio's .select handler, which loads the file into the editor exactly // as a manual click would. Poll until the radio list is populated. const stripPrefix = (s) => (s || '').replace(/^\\s*[\\u{1F4C4}\\u2728]\\s*/u, '').trim(); const m = (location.hash || '').match(/(?:^#|&)score=([^&]*)/); if (m) { const want = decodeURIComponent(m[1]); let tries = 0; const pick = () => { const labels = document.querySelectorAll('.score-list label'); if (!labels.length) { if (++tries < 100) setTimeout(pick, 150); return; } for (const lab of labels) { const span = lab.querySelector('span'); const text = stripPrefix(span ? span.textContent : lab.textContent); if (text === want) { const input = lab.querySelector('input'); if (input && !input.checked) input.click(); return; } } if (++tries < 40) setTimeout(pick, 150); // list may still be filling }; pick(); } } ''' # file-list selection -> write the chosen file into location.hash (#score=) so # the URL deep-links to it. This is a js-ONLY listener (no python fn) running beside # the load_file handler; `value` is the selected radio label. Strip the emoji prefix # for a clean, shareable hash. Returning [] is fine β€” there are no outputs. _JS_SELECT_HASH = ''' function (value) { try { const name = (value || '').replace(/^\\s*[\\u{1F4C4}\\u2728]\\s*/u, '').trim(); if (name) history.replaceState(null, '', '#score=' + encodeURIComponent(name)); } catch (e) {} return []; } ''' # Share button: copy the current deep-link URL to the clipboard and flash a hint. # # iframe notes (this app is embedded in a cross-origin iframe on HF Spaces): # - location.href ALWAYS reads THIS frame's own document URL β€” the Gradio app's # direct URL incl. the #score= hash β€” even inside a cross-origin iframe. # The same-origin policy only blocks reading window.parent/window.top.location, # not your own frame's. So this is exactly the shareable deep-link; the outer # huggingface.co/spaces URL is both unreadable AND wrong (it has no hash). # - navigator.clipboard.writeText may be blocked by Permissions-Policy in a # cross-origin iframe (needs allow="clipboard-write", which the HF embed may not # grant). So we try the async Clipboard API first and fall back to a hidden #