eloigil6 Claude Opus 4.8 commited on
Commit
af047f2
Β·
1 Parent(s): 53feffb

Add MiniCPM5-1B prompt enrichment for ZeroGPU Spaces (Stage 2)

Browse files

A Space has no Ollama daemon, so enrichment fell back to the plain non-LLM path (bland titles). Add a MiniCPM5-1B backend (standard LlamaForCausalLM, no trust_remote_code, thinking mode off, robust JSON parse) loaded on cuda at startup and run INSIDE the @spaces.GPU call alongside MusicGen - one GPU acquisition per vend. enrich_prompt now dispatches by environment: MiniCPM on ZeroGPU, Ollama locally, plain fallback on any failure (a bad enricher can't crash the app - startup load is guarded). No new dependencies (transformers already covers it). Local mps/cpu/stub paths and progress are unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Files changed (2) hide show
  1. app.py +170 -66
  2. requirements.txt +4 -2
app.py CHANGED
@@ -3,17 +3,19 @@
3
  Gradio Server backend: serves the Three.js frontend and exposes the
4
  generation API.
5
 
6
- Pipeline: user vibe -> Ollama (small LLM) enriches it into a MusicGen
7
- prompt + cassette title + ambience pick -> MusicGen renders the music ->
8
- ambience.py loops a background bed (waves, crackle, rain…) underneath.
9
- MusicGen ignores texture words in prompts, hence the separate bed.
 
10
 
11
  Env knobs:
12
  LOFINITY_ENGINE musicgen (default) | stub
13
  LOFINITY_DURATION clip length in seconds (default 30, the single-shot max)
14
  LOFINITY_DEVICE cuda | mps | cpu (default: cuda on ZeroGPU, else mps if available)
15
- OLLAMA_URL default http://localhost:11434
16
- OLLAMA_MODEL default llama3.2:3b
 
17
  """
18
 
19
  import base64
@@ -87,7 +89,7 @@ app = Server(title="LoFinity")
87
  # frontend polls /api/progress to fill its brewing bar.
88
  _PROGRESS = {"done": 0, "total": 1}
89
 
90
- # --- prompt enrichment (Ollama) ----------------------------------------------
91
 
92
  ENRICH_SYSTEM = """\
93
  You are the creative brain of LoFinity, a magical vending machine that sells
@@ -116,47 +118,138 @@ user: studying at midnight
116
  {"music_prompt": "lofi chill, rhodes piano, muted guitar, soft bass, focused and calm, slow tempo, 75 bpm, instrumental", "title": "Midnight Study Session", "ambience": "vinyl_crackle"}"""
117
 
118
 
119
- def enrich_prompt(prompt: str) -> tuple[str, str, str]:
120
- """Vibe -> (music_prompt, cassette title, ambience slug), with a plain
121
- fallback if the local LLM is unreachable or returns junk."""
122
- import ambience
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
 
 
 
 
 
 
 
 
 
 
124
  try:
125
- r = httpx.post(
126
- f"{OLLAMA_URL}/api/chat",
127
- json={
128
- "model": OLLAMA_MODEL,
129
- "messages": [
130
- {"role": "system", "content": ENRICH_SYSTEM},
131
- {"role": "user", "content": prompt},
132
- ],
133
- "format": "json",
134
- "stream": False,
135
- "options": {"temperature": 0.8, "num_predict": 220},
136
- },
137
- timeout=45,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  )
139
- r.raise_for_status()
140
- data = json.loads(r.json()["message"]["content"])
141
- music_prompt = str(data.get("music_prompt") or "").strip()
142
- title = str(data.get("title") or "").strip()[:48]
143
- if music_prompt and title:
144
- # belt and suspenders: the genre must lead even if the LLM drifts
145
- if "lofi" not in music_prompt.lower():
146
- music_prompt = f"lofi chill, {music_prompt}"
147
- # whatever the LLM picked, snap it to a bed we can actually render
148
- return music_prompt, title, ambience.normalize_slug(data.get("ambience"))
149
- except Exception as e: # noqa: BLE001 β€” any failure means "use fallback"
150
- print(f"[lofinity] ollama enrichment failed ({e!r}), using fallback")
151
- fallback_title = f"{prompt[:28].title()} Tape" if prompt.strip() else "Untitled Tape"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  return (
153
- f"lofi chill, {prompt}, mellow and warm, soft drums, "
154
- "slow tempo, instrumental",
155
- fallback_title,
156
  ambience.DEFAULT,
157
  )
158
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  # --- audio engines ------------------------------------------------------------
161
 
162
  _musicgen = None
@@ -199,6 +292,11 @@ def load_musicgen():
199
  # placements done at startup are far more efficient than per-call transfers.
200
  if IS_ZEROGPU and ENGINE != "stub":
201
  load_musicgen()
 
 
 
 
 
202
 
203
 
204
  def encode_wav(samples, rate: int) -> str:
@@ -308,22 +406,25 @@ def musicgen_engine(music_prompt: str, seconds: int = CHUNK_S, progress_cb=None)
308
  return samples, rate
309
 
310
 
311
- def _gpu_budget(music_prompt: str, seconds: int = CHUNK_S) -> int:
312
- """GPU seconds to request from ZeroGPU for a brew of this length: per-chunk
313
- render time plus headroom. Tighter budgets earn better queue priority, and
314
- the signature must mirror gpu_musicgen so ZeroGPU can pass it the same args."""
 
315
  chunks = max(1, round(int(seconds) / CHUNK_S))
316
- return 20 + 25 * chunks # 30s->45, 60s->70, 90s->95
317
 
318
 
319
  @spaces.GPU(duration=_gpu_budget)
320
- def gpu_musicgen(music_prompt: str, seconds: int = CHUNK_S) -> tuple:
321
- """ZeroGPU entry point β€” runs MusicGen on the real GPU and returns the audio
322
- to the web process. No progress_cb on purpose: this body executes in a
323
- separate GPU worker, so _PROGRESS updates can't reach /api/progress yet (the
324
- brewing garden is wired up in a later stage); on the Space the brew jumps to
325
- done. Locally @spaces.GPU is a no-op, but this path is only taken on Spaces."""
326
- return musicgen_engine(music_prompt, seconds)
 
 
327
 
328
 
329
  def stub_engine(_music_prompt: str, seconds: int = CHUNK_S, progress_cb=None) -> tuple:
@@ -356,26 +457,29 @@ def generate_song(prompt: str, seconds: int = DEFAULT_SECONDS) -> dict:
356
 
357
  # snap whatever the slider sends to a length we can actually build
358
  seconds = min(ALLOWED_SECONDS, key=lambda s: abs(s - int(seconds)))
359
- # reset progress up front, BEFORE the (sometimes slow) Ollama enrich step, so
360
- # a poll arriving early sees this brew at 0% rather than the last one at 100%
361
  chunks = max(1, round(seconds / CHUNK_S))
362
  _PROGRESS.update(done=0, total=chunks)
363
- music_prompt, title, bed = enrich_prompt(prompt)
364
- print(f"[lofinity] brewing {title!r} ({seconds}s) :: {music_prompt} [+ {bed}]")
365
- if ENGINE == "stub":
366
- samples, rate = stub_engine(
367
- music_prompt, seconds,
368
- progress_cb=lambda d, t: _PROGRESS.update(done=d, total=t),
369
- )
370
- elif IS_ZEROGPU:
371
- # the GPU body runs in a separate worker process, so progress can't
372
- # stream back here yet (Stage 3); the brewing bar jumps 0->100% on Space
373
- samples, rate = gpu_musicgen(music_prompt, seconds)
374
  else:
375
- samples, rate = musicgen_engine(
 
 
 
 
 
376
  music_prompt, seconds,
377
  progress_cb=lambda d, t: _PROGRESS.update(done=d, total=t),
378
  )
 
379
  _PROGRESS.update(done=chunks, total=chunks)
380
  try:
381
  samples = ambience.mix(samples, rate, bed)
 
3
  Gradio Server backend: serves the Three.js frontend and exposes the
4
  generation API.
5
 
6
+ Pipeline: user vibe -> a small LLM enriches it into a MusicGen prompt +
7
+ cassette title + ambience pick -> MusicGen renders the music -> ambience.py
8
+ loops a background bed (waves, crackle, rain…) underneath. MusicGen ignores
9
+ texture words in prompts, hence the separate bed. The enrichment LLM is
10
+ MiniCPM (on cuda) on a ZeroGPU Space, or a local Ollama daemon in dev.
11
 
12
  Env knobs:
13
  LOFINITY_ENGINE musicgen (default) | stub
14
  LOFINITY_DURATION clip length in seconds (default 30, the single-shot max)
15
  LOFINITY_DEVICE cuda | mps | cpu (default: cuda on ZeroGPU, else mps if available)
16
+ LOFINITY_ENRICHER MiniCPM model id for ZeroGPU enrichment (default MiniCPM5-1B)
17
+ OLLAMA_URL default http://localhost:11434 (local enrichment)
18
+ OLLAMA_MODEL default llama3.2:3b (local enrichment)
19
  """
20
 
21
  import base64
 
89
  # frontend polls /api/progress to fill its brewing bar.
90
  _PROGRESS = {"done": 0, "total": 1}
91
 
92
+ # --- prompt enrichment --------------------------------------------------------
93
 
94
  ENRICH_SYSTEM = """\
95
  You are the creative brain of LoFinity, a magical vending machine that sells
 
118
  {"music_prompt": "lofi chill, rhodes piano, muted guitar, soft bass, focused and calm, slow tempo, 75 bpm, instrumental", "title": "Midnight Study Session", "ambience": "vinyl_crackle"}"""
119
 
120
 
121
+ # MiniCPM enrichment LLM (ZeroGPU only β€” a Space has no Ollama daemon).
122
+ # MiniCPM5-1B is a standard LlamaForCausalLM (no trust_remote_code, fast
123
+ # tokenizer) with a switchable <think> mode we keep OFF so the reply is direct
124
+ # JSON. Needs transformers>=5.6 (the Space's latest satisfies it); no extra deps.
125
+ ENRICHER_MODEL = os.getenv("LOFINITY_ENRICHER", "openbmb/MiniCPM5-1B")
126
+ _enricher = None
127
+ _enricher_lock = threading.Lock()
128
+ _enricher_disabled = False # set if the model can't load; forces the fallback
129
+
130
+
131
+ def load_enricher():
132
+ """Lazy-load the MiniCPM enrichment LLM on cuda (ZeroGPU). Like MusicGen it is
133
+ placed on cuda at module level; standard Llama arch, so no remote code."""
134
+ global _enricher
135
+ with _enricher_lock:
136
+ if _enricher is None:
137
+ import torch # noqa: F401 β€” needed so the .to('cuda') below resolves
138
+ from transformers import AutoModelForCausalLM, AutoTokenizer
139
+
140
+ print(f"[lofinity] loading enricher {ENRICHER_MODEL} on cuda…")
141
+ tok = AutoTokenizer.from_pretrained(ENRICHER_MODEL)
142
+ model = AutoModelForCausalLM.from_pretrained(ENRICHER_MODEL, torch_dtype="auto")
143
+ model.to("cuda")
144
+ model.eval()
145
+ _enricher = (tok, model)
146
+ print("[lofinity] enricher ready")
147
+ return _enricher
148
+
149
 
150
+ def _parse_enrich_json(text: str) -> dict:
151
+ """Pull the first {...} object out of an LLM reply (it may wrap the JSON in
152
+ prose or ```json fences, or leak a <think> block); {} if nothing parses."""
153
+ import re
154
+
155
+ if "</think>" in text: # belt-and-suspenders if thinking ever leaks through
156
+ text = text.rsplit("</think>", 1)[1]
157
+ m = re.search(r"\{.*\}", text, re.DOTALL)
158
+ if not m:
159
+ return {}
160
  try:
161
+ return json.loads(m.group(0))
162
+ except Exception: # noqa: BLE001
163
+ return {}
164
+
165
+
166
+ def _finalize_enrichment(data: dict):
167
+ """Shared post-processing for any backend: validate, force the genre to lead,
168
+ snap the ambience to a renderable bed. Returns a tuple, or None if unusable."""
169
+ import ambience
170
+
171
+ music_prompt = str(data.get("music_prompt") or "").strip()
172
+ title = str(data.get("title") or "").strip()[:48]
173
+ if not (music_prompt and title):
174
+ return None
175
+ # belt and suspenders: the genre must lead even if the LLM drifts
176
+ if "lofi" not in music_prompt.lower():
177
+ music_prompt = f"lofi chill, {music_prompt}"
178
+ # whatever the LLM picked, snap it to a bed we can actually render
179
+ return music_prompt, title, ambience.normalize_slug(data.get("ambience"))
180
+
181
+
182
+ def _enrich_minicpm(prompt: str):
183
+ """Enrich via MiniCPM on cuda. MUST run inside @spaces.GPU. Returns a tuple or
184
+ None (caller falls back). Thinking mode off so the reply is direct JSON."""
185
+ if _enricher_disabled:
186
+ return None
187
+ import torch
188
+
189
+ tok, model = load_enricher()
190
+ messages = [
191
+ {"role": "system", "content": ENRICH_SYSTEM},
192
+ {"role": "user", "content": prompt},
193
+ ]
194
+ inputs = tok.apply_chat_template(
195
+ messages, tokenize=True, add_generation_prompt=True,
196
+ enable_thinking=False, return_dict=True, return_tensors="pt",
197
+ ).to(model.device)
198
+ with torch.no_grad():
199
+ out = model.generate(
200
+ **inputs, max_new_tokens=220, do_sample=True, temperature=0.7, top_p=0.95
201
  )
202
+ reply = tok.decode(out[0][inputs["input_ids"].shape[-1]:], skip_special_tokens=True)
203
+ return _finalize_enrichment(_parse_enrich_json(reply))
204
+
205
+
206
+ def _enrich_ollama(prompt: str):
207
+ """Enrich via a local Ollama daemon. Returns a tuple or None on failure."""
208
+ r = httpx.post(
209
+ f"{OLLAMA_URL}/api/chat",
210
+ json={
211
+ "model": OLLAMA_MODEL,
212
+ "messages": [
213
+ {"role": "system", "content": ENRICH_SYSTEM},
214
+ {"role": "user", "content": prompt},
215
+ ],
216
+ "format": "json",
217
+ "stream": False,
218
+ "options": {"temperature": 0.8, "num_predict": 220},
219
+ },
220
+ timeout=45,
221
+ )
222
+ r.raise_for_status()
223
+ return _finalize_enrichment(json.loads(r.json()["message"]["content"]))
224
+
225
+
226
+ def _enrich_fallback(prompt: str) -> tuple[str, str, str]:
227
+ """Plain, LLM-free enrichment β€” used whenever the chosen backend fails."""
228
+ import ambience
229
+
230
+ title = f"{prompt[:28].title()} Tape" if prompt.strip() else "Untitled Tape"
231
  return (
232
+ f"lofi chill, {prompt}, mellow and warm, soft drums, slow tempo, instrumental",
233
+ title,
 
234
  ambience.DEFAULT,
235
  )
236
 
237
 
238
+ def enrich_prompt(prompt: str) -> tuple[str, str, str]:
239
+ """Vibe -> (music_prompt, cassette title, ambience slug). Backend is chosen by
240
+ environment: MiniCPM on ZeroGPU, Ollama locally; a plain fallback covers any
241
+ failure. On ZeroGPU this MUST be called inside @spaces.GPU (MiniCPM is cuda)."""
242
+ backend = _enrich_minicpm if IS_ZEROGPU else _enrich_ollama
243
+ try:
244
+ result = backend(prompt)
245
+ if result:
246
+ return result
247
+ print("[lofinity] enrichment returned junk, using fallback")
248
+ except Exception as e: # noqa: BLE001 β€” any failure means "use fallback"
249
+ print(f"[lofinity] enrichment failed ({e!r}), using fallback")
250
+ return _enrich_fallback(prompt)
251
+
252
+
253
  # --- audio engines ------------------------------------------------------------
254
 
255
  _musicgen = None
 
292
  # placements done at startup are far more efficient than per-call transfers.
293
  if IS_ZEROGPU and ENGINE != "stub":
294
  load_musicgen()
295
+ try:
296
+ load_enricher()
297
+ except Exception as e: # noqa: BLE001 β€” a bad enricher must not kill the app
298
+ _enricher_disabled = True
299
+ print(f"[lofinity] enricher load failed ({e!r}); vends use the plain fallback")
300
 
301
 
302
  def encode_wav(samples, rate: int) -> str:
 
406
  return samples, rate
407
 
408
 
409
+ def _gpu_budget(prompt: str, seconds: int = CHUNK_S) -> int:
410
+ """GPU seconds to request from ZeroGPU for a brew of this length: MiniCPM
411
+ enrichment + per-chunk MusicGen render plus headroom. Tighter budgets earn
412
+ better queue priority; the signature must mirror gpu_brew so ZeroGPU can pass
413
+ it the same args."""
414
  chunks = max(1, round(int(seconds) / CHUNK_S))
415
+ return 25 + 25 * chunks # enrichment + 30s->50, 60s->75, 90s->100
416
 
417
 
418
  @spaces.GPU(duration=_gpu_budget)
419
+ def gpu_brew(prompt: str, seconds: int = CHUNK_S) -> tuple:
420
+ """ZeroGPU entry point β€” enrichment (MiniCPM) AND MusicGen on the real GPU in
421
+ a single acquisition. Takes the raw vibe and returns
422
+ (music_prompt, title, bed, samples, rate). No progress_cb: this body runs in
423
+ a separate GPU worker, so _PROGRESS can't reach /api/progress yet (Stage 3) β€”
424
+ the brewing garden jumps to done on the Space. This path is Space-only."""
425
+ music_prompt, title, bed = enrich_prompt(prompt)
426
+ samples, rate = musicgen_engine(music_prompt, seconds)
427
+ return music_prompt, title, bed, samples, rate
428
 
429
 
430
  def stub_engine(_music_prompt: str, seconds: int = CHUNK_S, progress_cb=None) -> tuple:
 
457
 
458
  # snap whatever the slider sends to a length we can actually build
459
  seconds = min(ALLOWED_SECONDS, key=lambda s: abs(s - int(seconds)))
460
+ # reset progress up front, BEFORE the (sometimes slow) enrich step, so a poll
461
+ # arriving early sees this brew at 0% rather than the last one at 100%
462
  chunks = max(1, round(seconds / CHUNK_S))
463
  _PROGRESS.update(done=0, total=chunks)
464
+
465
+ if IS_ZEROGPU and ENGINE != "stub":
466
+ # On ZeroGPU both enrichment (MiniCPM) and MusicGen need the real GPU, so
467
+ # they share ONE @spaces.GPU acquisition. That worker runs in a separate
468
+ # process, so progress can't stream back yet (Stage 3): the bar jumps.
469
+ print(f"[lofinity] brewing on GPU :: {prompt!r} ({seconds}s)")
470
+ music_prompt, title, bed, samples, rate = gpu_brew(prompt, seconds)
471
+ print(f"[lofinity] brewed {title!r} :: {music_prompt} [+ {bed}]")
 
 
 
472
  else:
473
+ # Local / stub: enrich in-process (Ollama or fallback), then render with
474
+ # live per-chunk progress for the brewing garden.
475
+ music_prompt, title, bed = enrich_prompt(prompt)
476
+ print(f"[lofinity] brewing {title!r} ({seconds}s) :: {music_prompt} [+ {bed}]")
477
+ engine = stub_engine if ENGINE == "stub" else musicgen_engine
478
+ samples, rate = engine(
479
  music_prompt, seconds,
480
  progress_cb=lambda d, t: _PROGRESS.update(done=d, total=t),
481
  )
482
+
483
  _PROGRESS.update(done=chunks, total=chunks)
484
  try:
485
  samples = ambience.mix(samples, rate, bed)
requirements.txt CHANGED
@@ -3,8 +3,10 @@
3
  # a no-op when it's absent, so it's only actually required on the Space.
4
  spaces
5
  torch # ZeroGPU requires torch >=2.8; the Space runtime supplies the CUDA build
6
- transformers
7
- # Ollama must be running locally with the model below pulled:
 
 
8
  # ollama pull llama3.2:3b
9
  # The app runtime needs nothing more (ambience.py reads beds via stdlib `wave`).
10
  # To (re)populate the sampled ambience beds in assets/ambience/, pick one:
 
3
  # a no-op when it's absent, so it's only actually required on the Space.
4
  spaces
5
  torch # ZeroGPU requires torch >=2.8; the Space runtime supplies the CUDA build
6
+ transformers # MusicGen + (on ZeroGPU) the MiniCPM5-1B enricher; needs >=5.6 for MiniCPM5
7
+ # Enrichment LLM: on a ZeroGPU Space it's openbmb/MiniCPM5-1B, pulled from the Hub
8
+ # at startup (no extra deps β€” standard Llama arch + fast tokenizer). For LOCAL dev
9
+ # instead, run Ollama with the model below pulled:
10
  # ollama pull llama3.2:3b
11
  # The app runtime needs nothing more (ambience.py reads beds via stdlib `wave`).
12
  # To (re)populate the sampled ambience beds in assets/ambience/, pick one: