Spaces:
Running
Running
Commit ·
8565c15
1
Parent(s): e5a3bec
added i18n.
Browse files- README.md +12 -2
- app.py +61 -43
- lilyscript/lang.py +111 -0
- requirements.txt +3 -0
- web/score-player.css +30 -0
- web/score-player.js +15 -2
README.md
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
---
|
|
|
|
| 2 |
title: LilyScript
|
| 3 |
emoji: 🌼
|
| 4 |
colorFrom: pink
|
|
@@ -10,6 +11,15 @@ app_file: app.py
|
|
| 10 |
pinned: false
|
| 11 |
license: mit
|
| 12 |
short_description: A symbolic music AIGC app with Lilylet language
|
| 13 |
-
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
# https://huggingface.co/docs/hub/spaces-config-reference
|
| 3 |
title: LilyScript
|
| 4 |
emoji: 🌼
|
| 5 |
colorFrom: pink
|
|
|
|
| 11 |
pinned: false
|
| 12 |
license: mit
|
| 13 |
short_description: A symbolic music AIGC app with Lilylet language
|
|
|
|
| 14 |
|
| 15 |
+
# For ModelScope
|
| 16 |
+
domain:
|
| 17 |
+
- sd
|
| 18 |
+
tags:
|
| 19 |
+
- music
|
| 20 |
+
- symbolic_music
|
| 21 |
+
- lilylet
|
| 22 |
+
datasets: []
|
| 23 |
+
models:
|
| 24 |
+
- kllambda/LilyNota
|
| 25 |
+
---
|
app.py
CHANGED
|
@@ -27,12 +27,15 @@ import gradio as gr
|
|
| 27 |
from lilyscript.generator import StreamingLilyletGenerator
|
| 28 |
from lilyscript.postprocess import postprocess
|
| 29 |
from lilyscript.mask_monitor import MaskMonitor, load_blacklist
|
|
|
|
| 30 |
|
| 31 |
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 32 |
-
# Model weights are pulled from
|
| 33 |
-
#
|
| 34 |
-
#
|
| 35 |
-
# the
|
|
|
|
|
|
|
| 36 |
HF_MODEL_REPO = os.environ.get('LILYSCRIPT_MODEL_REPO', 'k-l-lambda/LilyNota')
|
| 37 |
HF_MODEL_SUBDIR = 'onnx' # weights + geometry + tokenizer live here in the repo
|
| 38 |
MODEL_DIR = os.environ.get('LILYSCRIPT_MODEL_DIR') # set -> use this local dir instead of the hub
|
|
@@ -111,7 +114,9 @@ def resolve_model_dir ():
|
|
| 111 |
'''Where the ONNX weights live, in priority order:
|
| 112 |
1. LILYSCRIPT_MODEL_DIR (explicit override, local dev),
|
| 113 |
2. the repo-local `models/` dir IF it holds the full weight bundle,
|
| 114 |
-
3. otherwise pull the `onnx/` bundle from the
|
|
|
|
|
|
|
| 115 |
The tokenizer is NOT pulled — it's read from the app's own assets/ dir — so we
|
| 116 |
only fetch the weight files.'''
|
| 117 |
if MODEL_DIR:
|
|
@@ -121,13 +126,26 @@ def resolve_model_dir ():
|
|
| 121 |
os.path.isfile(os.path.join(LOCAL_MODEL_DIR, name)) for name in required):
|
| 122 |
LOG.info('using local model weights in %s', LOCAL_MODEL_DIR)
|
| 123 |
return LOCAL_MODEL_DIR
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
return os.path.join(local, HF_MODEL_SUBDIR)
|
| 132 |
|
| 133 |
|
|
@@ -268,9 +286,9 @@ def run_generation (prompt, measures, temperature, max_patches, seed, store, top
|
|
| 268 |
# progress on the Generate button label: by measures (completed `|`
|
| 269 |
# separators vs target) when a measure count was requested, else by patches.
|
| 270 |
if meas:
|
| 271 |
-
btn_label = '
|
| 272 |
else:
|
| 273 |
-
btn_label = '
|
| 274 |
# The log streams every patch (raw text). The editor, however, must stay
|
| 275 |
# syntactically valid: only sync it at a measure boundary — i.e. when the
|
| 276 |
# accumulated text ends with the measure separator `|` (so it never shows a
|
|
@@ -280,7 +298,7 @@ def run_generation (prompt, measures, temperature, max_patches, seed, store, top
|
|
| 280 |
yield _log_panel(raw), editor_update, gr.update(), store, gr.update(), gr.update(value=btn_label)
|
| 281 |
except Exception as e:
|
| 282 |
LOG.exception('generation failed: %s', e)
|
| 283 |
-
yield _log_panel(raw), pretty, gr.update(), store, gr.update(), gr.update(value='
|
| 284 |
return
|
| 285 |
|
| 286 |
# timing: the stream yields once for prefill + once per generated patch, so the
|
|
@@ -302,7 +320,7 @@ def run_generation (prompt, measures, temperature, max_patches, seed, store, top
|
|
| 302 |
# randomize the seed slider for the next run (the file above already used the
|
| 303 |
# seed this generation ran with, so naming is unaffected)
|
| 304 |
next_seed = random.randint(0, 2147483647)
|
| 305 |
-
yield _log_panel(raw), pretty, gr.update(choices=list(store.keys()), value=label), store, gr.update(value=next_seed), gr.update(value='
|
| 306 |
|
| 307 |
|
| 308 |
def load_file (label, store):
|
|
@@ -311,17 +329,17 @@ def load_file (label, store):
|
|
| 311 |
|
| 312 |
|
| 313 |
SHEET_PLACEHOLDER = '''
|
| 314 |
-
<div id="ls-score" class="ls-score-mount" style="height:100%;min-height:600px;">
|
| 315 |
-
<div style="display:flex;align-items:center;justify-content:center;height:100%;
|
| 316 |
min-height:600px;border:1px dashed #c9c9c9;border-radius:8px;color:#999;
|
| 317 |
font-family:sans-serif;text-align:center;">
|
| 318 |
<div>
|
| 319 |
<div style="font-size:42px;margin-bottom:8px;">🎼</div>
|
| 320 |
-
<div>
|
| 321 |
</div>
|
| 322 |
</div>
|
| 323 |
</div>
|
| 324 |
-
'''
|
| 325 |
|
| 326 |
|
| 327 |
# Static-file URL prefix Gradio serves allowed_paths under (verified at 6.18.0).
|
|
@@ -754,7 +772,7 @@ def build_ui ():
|
|
| 754 |
examples = load_library()
|
| 755 |
|
| 756 |
with gr.Blocks(title='LilyScript') as demo:
|
| 757 |
-
gr.Markdown('
|
| 758 |
store = gr.State(examples)
|
| 759 |
|
| 760 |
with gr.Row(elem_id='main-row'):
|
|
@@ -762,31 +780,31 @@ def build_ui ():
|
|
| 762 |
with gr.Column(scale=5, elem_id='compose-col'):
|
| 763 |
# (1) compose params, with (2) the collapsible run log stacked below
|
| 764 |
with gr.Group(elem_classes=['lp-fixed']):
|
| 765 |
-
gr.Markdown('
|
| 766 |
with gr.Group():
|
| 767 |
-
gr.Markdown('
|
| 768 |
with gr.Row():
|
| 769 |
-
composer = gr.Dropdown(label='composer', choices=COMPOSERS, value='',
|
| 770 |
allow_custom_value=True)
|
| 771 |
-
period = gr.Dropdown(label='period', choices=PERIODS, value='',
|
| 772 |
allow_custom_value=True)
|
| 773 |
-
genre = gr.Dropdown(label='genre', choices=GENRES, value='',
|
| 774 |
allow_custom_value=True)
|
| 775 |
-
prompt = gr.Textbox(label='
|
| 776 |
-
placeholder=
|
| 777 |
-
gr.Markdown('
|
| 778 |
with gr.Row():
|
| 779 |
-
measures = gr.Number(label=
|
| 780 |
-
max_patches = gr.Number(label='
|
| 781 |
-
gr.Markdown('
|
| 782 |
with gr.Row():
|
| 783 |
-
temperature = gr.Slider(0.0, 2.0, value=1.0, step=0.05, label='temperature')
|
| 784 |
-
seed = gr.Slider(0, 2147483647, value=42, step=1, label='seed')
|
| 785 |
with gr.Row():
|
| 786 |
-
gen_btn = gr.Button('
|
| 787 |
-
stop_btn = gr.Button('
|
| 788 |
|
| 789 |
-
with gr.Accordion('
|
| 790 |
log = gr.Textbox(show_label=False, lines=10, max_lines=10,
|
| 791 |
autoscroll=True, interactive=False, container=False)
|
| 792 |
|
|
@@ -794,25 +812,25 @@ def build_ui ():
|
|
| 794 |
with gr.Row(equal_height=True, elem_classes=['lp-fill']):
|
| 795 |
with gr.Column(scale=2, min_width=160):
|
| 796 |
with gr.Group():
|
| 797 |
-
gr.Markdown('
|
| 798 |
file_list = gr.Radio(show_label=False, choices=list(examples.keys()),
|
| 799 |
value=None, interactive=True, container=False,
|
| 800 |
elem_classes=['score-list'])
|
| 801 |
with gr.Column(scale=5, elem_id='editor-col'):
|
| 802 |
with gr.Group():
|
| 803 |
-
gr.Markdown('
|
| 804 |
with gr.Row(elem_id='ls-editor-actions'):
|
| 805 |
# Share button: copies the current deep-link URL (#score=<file>)
|
| 806 |
# to the clipboard. js-only handler (_JS_SHARE) — see its comment
|
| 807 |
# for the iframe URL/clipboard caveats. A transient hint span next
|
| 808 |
# to it shows the copy result.
|
| 809 |
-
share_btn = gr.Button('
|
| 810 |
size='sm', scale=0, min_width=110)
|
| 811 |
# Open the current editor text in lilylet-live-editor: builds a
|
| 812 |
# ?code=<pako+base64> URL (matching the editor's share.ts) and
|
| 813 |
# opens it in a new tab. Hidden via CSS until the editor has text
|
| 814 |
# (toggled by _JS_RENDER, which receives the full text each change).
|
| 815 |
-
live_btn = gr.Button('
|
| 816 |
size='sm', scale=0, min_width=150, elem_classes=['ls-hidden'])
|
| 817 |
# Our own CodeMirror 6 editor (lyl-editor.bundle.js) mounts into
|
| 818 |
# this div and bridges to the hidden textbox below — Gradio's
|
|
@@ -830,7 +848,7 @@ def build_ui ():
|
|
| 830 |
# ---------------- RIGHT ----------------
|
| 831 |
with gr.Column(scale=6, elem_id='sheet-col'):
|
| 832 |
with gr.Group():
|
| 833 |
-
gr.Markdown('
|
| 834 |
gr.HTML(SHEET_PLACEHOLDER)
|
| 835 |
|
| 836 |
# ---- wiring ----
|
|
@@ -866,7 +884,7 @@ def build_ui ():
|
|
| 866 |
# run never reaches its final yield, so it'd otherwise stay "Generating…"),
|
| 867 |
# then lift the gate (js) so the player returns + button colors revert.
|
| 868 |
stop_btn.click(
|
| 869 |
-
lambda: gr.update(value='
|
| 870 |
).then(None, None, None, js=_JS_GEN_END)
|
| 871 |
file_list.select(load_file, inputs=[file_list, store], outputs=[editor])
|
| 872 |
# separate js-only listener: mirror the selected file into location.hash for deep-linking
|
|
|
|
| 27 |
from lilyscript.generator import StreamingLilyletGenerator
|
| 28 |
from lilyscript.postprocess import postprocess
|
| 29 |
from lilyscript.mask_monitor import MaskMonitor, load_blacklist
|
| 30 |
+
from lilyscript.lang import T, LANG
|
| 31 |
|
| 32 |
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 33 |
+
# Model weights are pulled from a model hub at first use (the int8 + KV-cache ONNX
|
| 34 |
+
# bundle lives under its `onnx/` dir). The repo is configured via LILYSCRIPT_MODEL_REPO:
|
| 35 |
+
# - "owner/name" -> HuggingFace Hub (default)
|
| 36 |
+
# - "modelscope:owner/name" -> ModelScope hub (for users behind the GFW / in China)
|
| 37 |
+
# For local development, point LILYSCRIPT_MODEL_DIR at a local onnx dir to skip the
|
| 38 |
+
# download, or drop the bundle into the repo-local `models/` dir.
|
| 39 |
HF_MODEL_REPO = os.environ.get('LILYSCRIPT_MODEL_REPO', 'k-l-lambda/LilyNota')
|
| 40 |
HF_MODEL_SUBDIR = 'onnx' # weights + geometry + tokenizer live here in the repo
|
| 41 |
MODEL_DIR = os.environ.get('LILYSCRIPT_MODEL_DIR') # set -> use this local dir instead of the hub
|
|
|
|
| 114 |
'''Where the ONNX weights live, in priority order:
|
| 115 |
1. LILYSCRIPT_MODEL_DIR (explicit override, local dev),
|
| 116 |
2. the repo-local `models/` dir IF it holds the full weight bundle,
|
| 117 |
+
3. otherwise pull the `onnx/` bundle from the configured model hub
|
| 118 |
+
(HuggingFace by default; ModelScope when LILYSCRIPT_MODEL_REPO is
|
| 119 |
+
prefixed with `modelscope:`).
|
| 120 |
The tokenizer is NOT pulled — it's read from the app's own assets/ dir — so we
|
| 121 |
only fetch the weight files.'''
|
| 122 |
if MODEL_DIR:
|
|
|
|
| 126 |
os.path.isfile(os.path.join(LOCAL_MODEL_DIR, name)) for name in required):
|
| 127 |
LOG.info('using local model weights in %s', LOCAL_MODEL_DIR)
|
| 128 |
return LOCAL_MODEL_DIR
|
| 129 |
+
# Only the four weight files under `onnx/` are needed (the tokenizer is local).
|
| 130 |
+
patterns = [f'{HF_MODEL_SUBDIR}/{name}' for name in required]
|
| 131 |
+
# Dispatch on the `modelscope:` prefix. Both hubs' snapshot_download share the
|
| 132 |
+
# `allow_patterns` semantics (glob over repo-relative paths) and return the local
|
| 133 |
+
# snapshot root, so the `onnx/` subdir join below is the same for either backend.
|
| 134 |
+
if HF_MODEL_REPO.startswith('modelscope:'):
|
| 135 |
+
repo_id = HF_MODEL_REPO[len('modelscope:'):]
|
| 136 |
+
try:
|
| 137 |
+
from modelscope.hub.snapshot_download import snapshot_download
|
| 138 |
+
except ImportError as e:
|
| 139 |
+
raise RuntimeError(
|
| 140 |
+
'LILYSCRIPT_MODEL_REPO is set to a modelscope: repo but the modelscope '
|
| 141 |
+
'package is not installed. Run `pip install modelscope` (see requirements.txt).'
|
| 142 |
+
) from e
|
| 143 |
+
LOG.info('downloading model weights from modelscope:%s (%s/) ...', repo_id, HF_MODEL_SUBDIR)
|
| 144 |
+
local = snapshot_download(repo_id, allow_patterns=patterns)
|
| 145 |
+
else:
|
| 146 |
+
from huggingface_hub import snapshot_download
|
| 147 |
+
LOG.info('downloading model weights from hf:%s (%s/) ...', HF_MODEL_REPO, HF_MODEL_SUBDIR)
|
| 148 |
+
local = snapshot_download(repo_id=HF_MODEL_REPO, allow_patterns=patterns)
|
| 149 |
return os.path.join(local, HF_MODEL_SUBDIR)
|
| 150 |
|
| 151 |
|
|
|
|
| 286 |
# progress on the Generate button label: by measures (completed `|`
|
| 287 |
# separators vs target) when a measure count was requested, else by patches.
|
| 288 |
if meas:
|
| 289 |
+
btn_label = T('generating') % (min(raw.count('|'), meas), meas)
|
| 290 |
else:
|
| 291 |
+
btn_label = T('generating') % (max(0, n_yields - 1), mp)
|
| 292 |
# The log streams every patch (raw text). The editor, however, must stay
|
| 293 |
# syntactically valid: only sync it at a measure boundary — i.e. when the
|
| 294 |
# accumulated text ends with the measure separator `|` (so it never shows a
|
|
|
|
| 298 |
yield _log_panel(raw), editor_update, gr.update(), store, gr.update(), gr.update(value=btn_label)
|
| 299 |
except Exception as e:
|
| 300 |
LOG.exception('generation failed: %s', e)
|
| 301 |
+
yield _log_panel(raw), pretty, gr.update(), store, gr.update(), gr.update(value=T('generate'))
|
| 302 |
return
|
| 303 |
|
| 304 |
# timing: the stream yields once for prefill + once per generated patch, so the
|
|
|
|
| 320 |
# randomize the seed slider for the next run (the file above already used the
|
| 321 |
# seed this generation ran with, so naming is unaffected)
|
| 322 |
next_seed = random.randint(0, 2147483647)
|
| 323 |
+
yield _log_panel(raw), pretty, gr.update(choices=list(store.keys()), value=label), store, gr.update(value=next_seed), gr.update(value=T('generate'))
|
| 324 |
|
| 325 |
|
| 326 |
def load_file (label, store):
|
|
|
|
| 329 |
|
| 330 |
|
| 331 |
SHEET_PLACEHOLDER = '''
|
| 332 |
+
<div id="ls-score" class="ls-score-mount" style="height:100%%;min-height:600px;">
|
| 333 |
+
<div style="display:flex;align-items:center;justify-content:center;height:100%%;
|
| 334 |
min-height:600px;border:1px dashed #c9c9c9;border-radius:8px;color:#999;
|
| 335 |
font-family:sans-serif;text-align:center;">
|
| 336 |
<div>
|
| 337 |
<div style="font-size:42px;margin-bottom:8px;">🎼</div>
|
| 338 |
+
<div>%s</div>
|
| 339 |
</div>
|
| 340 |
</div>
|
| 341 |
</div>
|
| 342 |
+
''' % T('loading_renderer')
|
| 343 |
|
| 344 |
|
| 345 |
# Static-file URL prefix Gradio serves allowed_paths under (verified at 6.18.0).
|
|
|
|
| 772 |
examples = load_library()
|
| 773 |
|
| 774 |
with gr.Blocks(title='LilyScript') as demo:
|
| 775 |
+
gr.Markdown(T('app_title'))
|
| 776 |
store = gr.State(examples)
|
| 777 |
|
| 778 |
with gr.Row(elem_id='main-row'):
|
|
|
|
| 780 |
with gr.Column(scale=5, elem_id='compose-col'):
|
| 781 |
# (1) compose params, with (2) the collapsible run log stacked below
|
| 782 |
with gr.Group(elem_classes=['lp-fixed']):
|
| 783 |
+
gr.Markdown(T('compose'))
|
| 784 |
with gr.Group():
|
| 785 |
+
gr.Markdown(T('style_options'))
|
| 786 |
with gr.Row():
|
| 787 |
+
composer = gr.Dropdown(label=T('composer'), choices=COMPOSERS, value='',
|
| 788 |
allow_custom_value=True)
|
| 789 |
+
period = gr.Dropdown(label=T('period'), choices=PERIODS, value='',
|
| 790 |
allow_custom_value=True)
|
| 791 |
+
genre = gr.Dropdown(label=T('genre'), choices=GENRES, value='',
|
| 792 |
allow_custom_value=True)
|
| 793 |
+
prompt = gr.Textbox(label=T('metadata_prompt'), lines=3, value='',
|
| 794 |
+
placeholder=T('metadata_placeholder'))
|
| 795 |
+
gr.Markdown(T('length'))
|
| 796 |
with gr.Row():
|
| 797 |
+
measures = gr.Number(label=T('measures'), value=0, precision=0)
|
| 798 |
+
max_patches = gr.Number(label=T('max_patches'), value=1024, precision=0)
|
| 799 |
+
gr.Markdown(T('sampler'))
|
| 800 |
with gr.Row():
|
| 801 |
+
temperature = gr.Slider(0.0, 2.0, value=1.0, step=0.05, label=T('temperature'))
|
| 802 |
+
seed = gr.Slider(0, 2147483647, value=42, step=1, label=T('seed'))
|
| 803 |
with gr.Row():
|
| 804 |
+
gen_btn = gr.Button(T('generate'), variant='primary', elem_id='gen-btn')
|
| 805 |
+
stop_btn = gr.Button(T('stop'), variant='stop', elem_id='stop-btn')
|
| 806 |
|
| 807 |
+
with gr.Accordion(T('logs'), open=True, elem_classes=['lp-fixed']):
|
| 808 |
log = gr.Textbox(show_label=False, lines=10, max_lines=10,
|
| 809 |
autoscroll=True, interactive=False, container=False)
|
| 810 |
|
|
|
|
| 812 |
with gr.Row(equal_height=True, elem_classes=['lp-fill']):
|
| 813 |
with gr.Column(scale=2, min_width=160):
|
| 814 |
with gr.Group():
|
| 815 |
+
gr.Markdown(T('score_list'))
|
| 816 |
file_list = gr.Radio(show_label=False, choices=list(examples.keys()),
|
| 817 |
value=None, interactive=True, container=False,
|
| 818 |
elem_classes=['score-list'])
|
| 819 |
with gr.Column(scale=5, elem_id='editor-col'):
|
| 820 |
with gr.Group():
|
| 821 |
+
gr.Markdown(T('lilylet_editor'))
|
| 822 |
with gr.Row(elem_id='ls-editor-actions'):
|
| 823 |
# Share button: copies the current deep-link URL (#score=<file>)
|
| 824 |
# to the clipboard. js-only handler (_JS_SHARE) — see its comment
|
| 825 |
# for the iframe URL/clipboard caveats. A transient hint span next
|
| 826 |
# to it shows the copy result.
|
| 827 |
+
share_btn = gr.Button(T('share_link'), elem_id='ls-share-btn',
|
| 828 |
size='sm', scale=0, min_width=110)
|
| 829 |
# Open the current editor text in lilylet-live-editor: builds a
|
| 830 |
# ?code=<pako+base64> URL (matching the editor's share.ts) and
|
| 831 |
# opens it in a new tab. Hidden via CSS until the editor has text
|
| 832 |
# (toggled by _JS_RENDER, which receives the full text each change).
|
| 833 |
+
live_btn = gr.Button(T('open_in_live_editor'), elem_id='ls-live-btn',
|
| 834 |
size='sm', scale=0, min_width=150, elem_classes=['ls-hidden'])
|
| 835 |
# Our own CodeMirror 6 editor (lyl-editor.bundle.js) mounts into
|
| 836 |
# this div and bridges to the hidden textbox below — Gradio's
|
|
|
|
| 848 |
# ---------------- RIGHT ----------------
|
| 849 |
with gr.Column(scale=6, elem_id='sheet-col'):
|
| 850 |
with gr.Group():
|
| 851 |
+
gr.Markdown(T('sheet_music'))
|
| 852 |
gr.HTML(SHEET_PLACEHOLDER)
|
| 853 |
|
| 854 |
# ---- wiring ----
|
|
|
|
| 884 |
# run never reaches its final yield, so it'd otherwise stay "Generating…"),
|
| 885 |
# then lift the gate (js) so the player returns + button colors revert.
|
| 886 |
stop_btn.click(
|
| 887 |
+
lambda: gr.update(value=T('generate')), None, outputs=[gen_btn], cancels=[gen_event],
|
| 888 |
).then(None, None, None, js=_JS_GEN_END)
|
| 889 |
file_list.select(load_file, inputs=[file_list, store], outputs=[editor])
|
| 890 |
# separate js-only listener: mirror the selected file into location.hash for deep-linking
|
lilyscript/lang.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Server-side i18n for LilyScript's static UI text.
|
| 2 |
+
|
| 3 |
+
Language is fixed at startup by an environment variable (`zh` or `en`, default
|
| 4 |
+
`en`). Unlike gr.I18n (which switches on the browser locale in the frontend),
|
| 5 |
+
this resolves once on the server, so every visitor sees the language the
|
| 6 |
+
deployment was configured with. Only static UI strings are covered — generated
|
| 7 |
+
scores, logs, and JS-driven status text are not translated.
|
| 8 |
+
|
| 9 |
+
Which env var: `LANG_UI` takes precedence over the standard POSIX `LANG`. Use
|
| 10 |
+
`LANG_UI` to set the UI language explicitly without being affected by the
|
| 11 |
+
system locale (e.g. on a zh_CN host where `LANG=zh_CN.UTF-8` would otherwise
|
| 12 |
+
flip the UI to Chinese). `LANG` is honored as a fallback for convenience.
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
from lilyscript.lang import T
|
| 16 |
+
gr.Markdown(T('compose')) # -> '## Compose' or '## 编排'
|
| 17 |
+
|
| 18 |
+
Missing keys fall back to English, then to the key itself (and log a warning),
|
| 19 |
+
so a typo is visible rather than silently blank.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import os
|
| 23 |
+
import logging
|
| 24 |
+
|
| 25 |
+
LOG = logging.getLogger('lilyscript')
|
| 26 |
+
|
| 27 |
+
# Supported languages; the first is the default when LANG is unset/unknown.
|
| 28 |
+
SUPPORTED = ('en', 'zh')
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _resolve_lang ():
|
| 32 |
+
raw = (os.environ.get('LANG_UI') or os.environ.get('LANG') or '').strip().lower()
|
| 33 |
+
# LANG often looks like "en_US.UTF-8" / "zh_CN.UTF-8"; take the language subtag.
|
| 34 |
+
code = raw.split('.')[0].split('_')[0]
|
| 35 |
+
if code in SUPPORTED:
|
| 36 |
+
return code
|
| 37 |
+
return SUPPORTED[0]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
LANG = _resolve_lang()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# Translation tables. Keys are stable identifiers; values are the rendered text
|
| 44 |
+
# (Markdown prefixes like '## ' / '- ' are part of the value so call sites stay
|
| 45 |
+
# identical to the original literals).
|
| 46 |
+
_STRINGS = {
|
| 47 |
+
'en': {
|
| 48 |
+
'app_title': '## 🎼 LilyScript — symbolic music generation with Lilylet',
|
| 49 |
+
'compose': '## Compose',
|
| 50 |
+
'style_options': '- Style Options',
|
| 51 |
+
'composer': 'composer',
|
| 52 |
+
'period': 'period',
|
| 53 |
+
'genre': 'genre',
|
| 54 |
+
'metadata_prompt': 'Metadata prompt',
|
| 55 |
+
'metadata_placeholder': 'extra metadata lines, e.g.\n[key "C major"]\n(optional)',
|
| 56 |
+
'length': '- Length',
|
| 57 |
+
'measures': 'Measures (0 = let model decide)',
|
| 58 |
+
'max_patches': 'max patches',
|
| 59 |
+
'sampler': '- Sampler',
|
| 60 |
+
'temperature': 'temperature',
|
| 61 |
+
'seed': 'seed',
|
| 62 |
+
'generate': 'Generate',
|
| 63 |
+
'generating': 'Generating… %d/%d',
|
| 64 |
+
'stop': 'Stop',
|
| 65 |
+
'logs': 'Logs',
|
| 66 |
+
'score_list': '## Score List',
|
| 67 |
+
'lilylet_editor': '## Lilylet editor',
|
| 68 |
+
'share_link': '🔗 Share link',
|
| 69 |
+
'open_in_live_editor': '🎹 Open in live-editor',
|
| 70 |
+
'sheet_music': '## Sheet music',
|
| 71 |
+
'loading_renderer': 'Loading score renderer…',
|
| 72 |
+
},
|
| 73 |
+
'zh': {
|
| 74 |
+
'app_title': '## 🎼 LilyScript — 基于 Lilylet 的符号音乐生成',
|
| 75 |
+
'compose': '## 创作',
|
| 76 |
+
'style_options': '- 风格选项',
|
| 77 |
+
'composer': '作曲家',
|
| 78 |
+
'period': '时期',
|
| 79 |
+
'genre': '体裁',
|
| 80 |
+
'metadata_prompt': '元数据提示',
|
| 81 |
+
'metadata_placeholder': '额外的元数据行,例如\n[key "C major"]\n(可选)',
|
| 82 |
+
'length': '- 长度',
|
| 83 |
+
'measures': '小节数(0 = 由模型决定)',
|
| 84 |
+
'max_patches': '最大 patch 数',
|
| 85 |
+
'sampler': '- 采样器',
|
| 86 |
+
'temperature': '温度',
|
| 87 |
+
'seed': '随机种子',
|
| 88 |
+
'generate': '生成',
|
| 89 |
+
'generating': '生成中… %d/%d',
|
| 90 |
+
'stop': '停止',
|
| 91 |
+
'logs': '日志',
|
| 92 |
+
'score_list': '## 乐谱列表',
|
| 93 |
+
'lilylet_editor': '## Lilylet 编辑器',
|
| 94 |
+
'share_link': '🔗 分享链接',
|
| 95 |
+
'open_in_live_editor': '🎹 在 live-editor 中打开',
|
| 96 |
+
'sheet_music': '## 乐谱',
|
| 97 |
+
'loading_renderer': '正在加载乐谱渲染器…',
|
| 98 |
+
},
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def T (key):
|
| 103 |
+
"""Translate a UI string key into the configured language."""
|
| 104 |
+
table = _STRINGS.get(LANG, _STRINGS['en'])
|
| 105 |
+
if key in table:
|
| 106 |
+
return table[key]
|
| 107 |
+
# fall back to English, then to the raw key (visible, so typos surface)
|
| 108 |
+
if key in _STRINGS['en']:
|
| 109 |
+
return _STRINGS['en'][key]
|
| 110 |
+
LOG.warning('i18n: missing key %r (lang=%s)', key, LANG)
|
| 111 |
+
return key
|
requirements.txt
CHANGED
|
@@ -2,3 +2,6 @@ gradio==6.18.0
|
|
| 2 |
onnxruntime
|
| 3 |
numpy
|
| 4 |
huggingface-hub
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
onnxruntime
|
| 3 |
numpy
|
| 4 |
huggingface-hub
|
| 5 |
+
# Optional: only needed when LILYSCRIPT_MODEL_REPO is set to a `modelscope:owner/name`
|
| 6 |
+
# repo (downloads weights from ModelScope instead of HuggingFace). Uncomment to enable.
|
| 7 |
+
# modelscope
|
web/score-player.css
CHANGED
|
@@ -134,6 +134,36 @@
|
|
| 134 |
#ls-score .ls-btn:hover:not(:disabled) { border-color: #8aa; background: #f0f6ff; }
|
| 135 |
#ls-score .ls-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
#ls-score .ls-time {
|
| 138 |
font-family: ui-monospace, Consolas, monospace;
|
| 139 |
font-size: 12px;
|
|
|
|
| 134 |
#ls-score .ls-btn:hover:not(:disabled) { border-color: #8aa; background: #f0f6ff; }
|
| 135 |
#ls-score .ls-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 136 |
|
| 137 |
+
/* Loading state on the play button: while the sound library / MIDI player is still
|
| 138 |
+
loading, hide the ▶ glyph and spin a small ring in its place. Kept fully opaque
|
| 139 |
+
(overriding :disabled's dim) so the animation reads as "working", not just greyed.
|
| 140 |
+
The button stays disabled (cursor: wait) — clicking does nothing until ready. */
|
| 141 |
+
#ls-score .ls-btn.ls-loading {
|
| 142 |
+
opacity: 1;
|
| 143 |
+
cursor: wait;
|
| 144 |
+
color: transparent; /* hide the ▶ text glyph without reflow */
|
| 145 |
+
position: relative;
|
| 146 |
+
}
|
| 147 |
+
#ls-score .ls-btn.ls-loading::after {
|
| 148 |
+
content: '';
|
| 149 |
+
position: absolute;
|
| 150 |
+
width: 14px;
|
| 151 |
+
height: 14px;
|
| 152 |
+
border: 2px solid #c9c9d2;
|
| 153 |
+
border-top-color: #7c5cff; /* accent matches the progress fill */
|
| 154 |
+
border-radius: 50%;
|
| 155 |
+
animation: ls-spin 0.7s linear infinite;
|
| 156 |
+
}
|
| 157 |
+
@keyframes ls-spin {
|
| 158 |
+
to { transform: rotate(360deg); }
|
| 159 |
+
}
|
| 160 |
+
/* Respect reduced-motion: pulse the ring opacity instead of spinning. */
|
| 161 |
+
@media (prefers-reduced-motion: reduce) {
|
| 162 |
+
#ls-score .ls-btn.ls-loading::after {
|
| 163 |
+
animation: ls-sf-pulse 1.4s ease-in-out infinite;
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
#ls-score .ls-time {
|
| 168 |
font-family: ui-monospace, Consolas, monospace;
|
| 169 |
font-size: 12px;
|
web/score-player.js
CHANGED
|
@@ -134,6 +134,12 @@
|
|
| 134 |
// Render lyl -> SVG. Returns true on success. Does NOT touch the MIDI player
|
| 135 |
// (that is gated separately on generation state).
|
| 136 |
async function render (code) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
const tk = await initVerovio();
|
| 138 |
if (!tk) { setStatus('Verovio not ready', 'err'); return false; }
|
| 139 |
code = (code || '').trim();
|
|
@@ -477,6 +483,11 @@
|
|
| 477 |
}
|
| 478 |
els.playBtn.disabled = !(audible && state.player && state.midiData);
|
| 479 |
els.playBtn.title = audible ? 'Play' : 'Loading sound library…';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
}
|
| 481 |
syncPlayEnabled();
|
| 482 |
// keep polling until BOTH a backend is audible and the player is built (covers
|
|
@@ -501,12 +512,14 @@
|
|
| 501 |
if (show) {
|
| 502 |
// disable play until a backend is audible AND the player is built; the
|
| 503 |
// sf-status poller (startSfStatus) keeps this in sync as soundfonts load.
|
| 504 |
-
if (els.playBtn) els.playBtn.disabled = true;
|
| 505 |
buildPlayer().then(function (ok) {
|
| 506 |
if (ok) updateProgress();
|
| 507 |
var F = state.audio && state.audio.MidiAudio;
|
| 508 |
var audible = (F && F.empty) ? !F.empty() : true;
|
| 509 |
-
|
|
|
|
|
|
|
| 510 |
startSfStatus(); // keep enabling/badge in sync while soundfonts finish loading
|
| 511 |
});
|
| 512 |
}
|
|
|
|
| 134 |
// Render lyl -> SVG. Returns true on success. Does NOT touch the MIDI player
|
| 135 |
// (that is gated separately on generation state).
|
| 136 |
async function render (code) {
|
| 137 |
+
// Guard: render may be called (e.g. by the page's initial-render poll on
|
| 138 |
+
// reload/deep-link) before mount() has built the player DOM, so els.svg is
|
| 139 |
+
// still undefined. Bail quietly — the caller's poll retries until mount is
|
| 140 |
+
// done. (Without this, injectSvg's `els.svg.innerHTML = ''` throws
|
| 141 |
+
// "Cannot set properties of undefined".)
|
| 142 |
+
if (!els.svg) { log('render before mount — skipping'); return false; }
|
| 143 |
const tk = await initVerovio();
|
| 144 |
if (!tk) { setStatus('Verovio not ready', 'err'); return false; }
|
| 145 |
code = (code || '').trim();
|
|
|
|
| 483 |
}
|
| 484 |
els.playBtn.disabled = !(audible && state.player && state.midiData);
|
| 485 |
els.playBtn.title = audible ? 'Play' : 'Loading sound library…';
|
| 486 |
+
// While no backend can sound yet (or the player is still building), show an
|
| 487 |
+
// animated spinner on the play button instead of ▶, so the loading state
|
| 488 |
+
// reads clearly rather than as a plain greyed button. (CSS: .ls-btn.ls-loading)
|
| 489 |
+
var loading = !(audible && state.player && state.midiData);
|
| 490 |
+
els.playBtn.classList.toggle('ls-loading', loading);
|
| 491 |
}
|
| 492 |
syncPlayEnabled();
|
| 493 |
// keep polling until BOTH a backend is audible and the player is built (covers
|
|
|
|
| 512 |
if (show) {
|
| 513 |
// disable play until a backend is audible AND the player is built; the
|
| 514 |
// sf-status poller (startSfStatus) keeps this in sync as soundfonts load.
|
| 515 |
+
if (els.playBtn) { els.playBtn.disabled = true; els.playBtn.classList.add('ls-loading'); }
|
| 516 |
buildPlayer().then(function (ok) {
|
| 517 |
if (ok) updateProgress();
|
| 518 |
var F = state.audio && state.audio.MidiAudio;
|
| 519 |
var audible = (F && F.empty) ? !F.empty() : true;
|
| 520 |
+
var ready = ok && audible;
|
| 521 |
+
els.playBtn.disabled = !ready;
|
| 522 |
+
els.playBtn.classList.toggle('ls-loading', !ready);
|
| 523 |
startSfStatus(); // keep enabling/badge in sync while soundfonts finish loading
|
| 524 |
});
|
| 525 |
}
|