File size: 8,748 Bytes
b69de73
 
 
 
 
 
2fa4e1a
b69de73
 
 
 
 
 
 
 
 
2fa4e1a
b69de73
2fa4e1a
b69de73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2fa4e1a
 
 
 
 
 
 
 
b69de73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2fa4e1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b69de73
 
 
 
2fa4e1a
 
b69de73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2fa4e1a
b69de73
 
 
 
 
 
2fa4e1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b69de73
2fa4e1a
b69de73
 
 
 
 
 
 
 
2fa4e1a
 
 
b69de73
2fa4e1a
b69de73
2fa4e1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b69de73
 
 
2fa4e1a
 
 
 
 
 
 
 
 
 
 
b69de73
 
 
2fa4e1a
 
 
b69de73
 
2fa4e1a
 
 
 
b69de73
 
2fa4e1a
b69de73
 
 
 
 
 
 
 
 
2fa4e1a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
"""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); models load from a local dir for now.
"""

import os
import re
import time
import json

import gradio as gr

from lilyscript.generator import StreamingLilyletGenerator

HERE = os.path.dirname(os.path.abspath(__file__))
# TODO: swap for huggingface_hub.snapshot_download(repo_id=...) to pull the int8
# ONNX weights from the hub instead of a local dir.
MODEL_DIR = os.environ.get('LILYSCRIPT_MODEL_DIR', os.path.join(HERE, 'models'))
ASSET_DIR = os.path.join(HERE, 'assets')
EXAMPLES_DIR = os.path.join(HERE, 'examples')
OUTPUT_DIR = os.path.join(HERE, 'outputs')

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']
GENRES = _STYLES['genres']
INSTRUMENTS = _STYLES['instruments']

_GEN = None


def get_generator ():
	'''Lazily build the (heavy) ONNX generator on first use.'''
	global _GEN
	if _GEN is None:
		_GEN = StreamingLilyletGenerator(MODEL_DIR, ASSET_DIR)
	return _GEN


def load_examples ():
	'''Read built-in example .lyl files into a {label: text} dict.'''
	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] = 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 = {}
	if os.path.isdir(OUTPUT_DIR):
		for name in sorted(os.listdir(OUTPUT_DIR)):
			if name.endswith('.lyl'):
				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()}


_STYLE_LINE_RE = re.compile(r'^\[(composer|genre|instrument)\s+".*"\]\s*$')


def sync_prompt (composer, genre, instrument, current):
	'''Rewrite the metadata-prompt text from the three style dropdowns.

	The `[composer/genre/instrument "..."]` lines are regenerated from the
	dropdowns and placed at the top; any other lines the user typed (e.g.
	`[key "..."]`) are preserved below in their original order.
	'''
	lines = []
	for field, value in (('composer', composer), ('genre', genre), ('instrument', instrument)):
		value = (value or '').strip()
		if value:
			lines.append(f'[{field} "{value}"]')
	# keep every line that isn't one of the three managed style lines
	for ln in (current or '').splitlines():
		if not _STYLE_LINE_RE.match(ln.strip()):
			if ln.strip():
				lines.append(ln)
	return '\n'.join(lines)


def run_generation (prompt, measures, temperature, max_patches, seed, store, top_k=0, top_p=0.9):
	'''Streaming generate callback. Yields updates for (log, editor, file_list, store).

	store: {label: lyl_text} dict held in gr.State; the produced document is added
	to it under a timestamped label once generation finishes.

	top_k / top_p have fixed defaults (no UI controls); pass them explicitly to override.
	'''
	gen = get_generator()
	meas = int(measures) if measures and int(measures) > 0 else None
	store = dict(store or {})

	raw = pretty = ''
	for raw, pretty, done in gen.generate_stream(
		prompt_text=prompt or '', max_patches=int(max_patches), temperature=float(temperature),
		top_k=int(top_k), top_p=float(top_p), measures=meas, seed=int(seed)):
		# stream: update log + editor, keep file list / store unchanged
		yield raw, pretty, gr.update(), store

	# finished: persist the document, refresh the file list, select the new entry
	label = OUTPUT_PREFIX + time.strftime('%H%M%S') + ('_m%d' % meas if meas else '') + '_s%d' % int(seed)
	store[label] = pretty
	os.makedirs(OUTPUT_DIR, exist_ok=True)
	with open(os.path.join(OUTPUT_DIR, label.replace(OUTPUT_PREFIX, '') + '.lyl'), 'w', encoding='utf-8') as f:
		f.write(pretty)
	yield raw, pretty, gr.update(choices=list(store.keys()), value=label), store


def load_file (label, store):
	'''File-list selection -> load that document into the editor.'''
	return (store or {}).get(label, '')


SHEET_PLACEHOLDER = '''
<div style="height:100%;min-height:600px;display:flex;align-items:center;
	justify-content:center;border:1px dashed #c9c9c9;border-radius:8px;
	color:#999;font-family:sans-serif;text-align:center;">
	<div>
		<div style="font-size:42px;margin-bottom:8px;">&#127932;</div>
		<div>Sheet-music preview</div>
		<div style="font-size:12px;margin-top:4px;">
			(coming soon)
		</div>
	</div>
</div>
'''


CUSTOM_CSS = '''
/* Score List: truncate long file names to a single line with an ellipsis. */
.score-list label {
	max-width: 100%;
}
.score-list label > span {
	display: inline-block;
	max-width: 100%;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
	vertical-align: middle;
}
'''


def build_ui ():
	examples = load_library()

	with gr.Blocks(title='LilyScript') as demo:
		gr.Markdown('## 🎼 LilyScript — symbolic music generation with Lilylet')
		store = gr.State(examples)

		with gr.Row(equal_height=True):
			# ---------------- LEFT ----------------
			with gr.Column(scale=5):
				# (1) compose params, with (2) the collapsible run log stacked below
				with gr.Group():
					gr.Markdown('## Compose')
					with gr.Group():
						gr.Markdown('- Style Options')
						with gr.Row():
							composer = gr.Dropdown(label='composer', choices=COMPOSERS, value='',
								allow_custom_value=True)
							genre = gr.Dropdown(label='genre', choices=GENRES, value='',
								allow_custom_value=True)
							instrument = gr.Dropdown(label='instrument', choices=INSTRUMENTS, value='',
								allow_custom_value=True)
					prompt = gr.Textbox(label='Metadata prompt', lines=3, value='',
						placeholder='extra metadata lines, e.g.\n[key "C major"]\n(optional)')
					measures = gr.Number(label='Measures (0 = let model decide)', value=0, precision=0)
					with gr.Row():
						temperature = gr.Slider(0.0, 2.0, value=1.0, step=0.05, label='temperature')
						max_patches = gr.Number(label='max patches', value=1024, precision=0)
						seed = gr.Slider(0, 2147483647, value=42, step=1, label='seed')
					with gr.Row():
						gen_btn = gr.Button('Generate', variant='primary')
						stop_btn = gr.Button('Stop', variant='stop')

				with gr.Accordion('Logs', open=True):
					log = gr.Textbox(show_label=False, lines=10, max_lines=10,
						autoscroll=True, interactive=False, container=False)

				# bottom row: (3) file list | (4) editor
				with gr.Row(equal_height=True):
					with gr.Column(scale=2, min_width=160):
						with gr.Group():
							gr.Markdown('## Score List')
							file_list = gr.Radio(show_label=False, choices=list(examples.keys()),
								value=None, interactive=True, container=False,
								elem_classes=['score-list'])
					with gr.Column(scale=5):
						with gr.Group():
							gr.Markdown('## Lilylet editor')
							editor = gr.Code(show_label=False, language=None, lines=18,
								max_lines=18, interactive=True)

			# ---------------- RIGHT ----------------
			with gr.Column(scale=6):
				with gr.Group():
					gr.Markdown('## Sheet music')
					gr.HTML(SHEET_PLACEHOLDER)

		# ---- wiring ----
		# style dropdowns -> keep the metadata-prompt text box in sync
		for field in (composer, genre, instrument):
			field.change(sync_prompt, inputs=[composer, genre, instrument, prompt], outputs=[prompt])

		gen_event = gen_btn.click(
			run_generation,
			inputs=[prompt, measures, temperature, max_patches, seed, store],
			outputs=[log, editor, file_list, store],
		)
		stop_btn.click(None, None, None, cancels=[gen_event])
		file_list.select(load_file, inputs=[file_list, store], outputs=[editor])

	return demo


if __name__ == '__main__':
	build_ui().queue().launch(theme=gr.themes.Soft(), css=CUSTOM_CSS)