k-l-lambda commited on
Commit
8565c15
·
1 Parent(s): e5a3bec

added i18n.

Browse files
Files changed (6) hide show
  1. README.md +12 -2
  2. app.py +61 -43
  3. lilyscript/lang.py +111 -0
  4. requirements.txt +3 -0
  5. web/score-player.css +30 -0
  6. 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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
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 the HuggingFace model repo `k-l-lambda/LilyNota`
33
- # at first use (the int8 + KV-cache ONNX bundle lives under its `onnx/` dir).
34
- # For local development, point LILYSCRIPT_MODEL_DIR at a local onnx dir to skip
35
- # the download.
 
 
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 HF model repo.
 
 
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
- from huggingface_hub import snapshot_download
125
- LOG.info('downloading model weights from hf:%s (%s/) ...', HF_MODEL_REPO, HF_MODEL_SUBDIR)
126
- local = snapshot_download(
127
- repo_id=HF_MODEL_REPO,
128
- allow_patterns=[f'{HF_MODEL_SUBDIR}/patch_kv_int8.onnx', f'{HF_MODEL_SUBDIR}/token_kv_int8.onnx',
129
- f'{HF_MODEL_SUBDIR}/wte.npy', f'{HF_MODEL_SUBDIR}/geometry.json'],
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 = 'Generating… %d/%d' % (min(raw.count('|'), meas), meas)
272
  else:
273
- btn_label = 'Generating… %d/%d' % (max(0, n_yields - 1), mp)
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='Generate')
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='Generate')
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;">&#127932;</div>
320
- <div>Loading score renderer…</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('## 🎼 LilyScript — symbolic music generation with Lilylet')
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('## Compose')
766
  with gr.Group():
767
- gr.Markdown('- Style Options')
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='Metadata prompt', lines=3, value='',
776
- placeholder='extra metadata lines, e.g.\n[key "C major"]\n(optional)')
777
- gr.Markdown('- Length')
778
  with gr.Row():
779
- measures = gr.Number(label='Measures (0 = let model decide)', value=0, precision=0)
780
- max_patches = gr.Number(label='max patches', value=1024, precision=0)
781
- gr.Markdown('- Sampler')
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('Generate', variant='primary', elem_id='gen-btn')
787
- stop_btn = gr.Button('Stop', variant='stop', elem_id='stop-btn')
788
 
789
- with gr.Accordion('Logs', open=True, elem_classes=['lp-fixed']):
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('## Score List')
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('## Lilylet editor')
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('🔗 Share link', elem_id='ls-share-btn',
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('🎹 Open in live-editor', elem_id='ls-live-btn',
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('## Sheet music')
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='Generate'), None, outputs=[gen_btn], cancels=[gen_event],
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;">&#127932;</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
- els.playBtn.disabled = !(ok && audible);
 
 
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
  }