eloigil6 commited on
Commit
2916784
·
1 Parent(s): dda4750

Refactor audio handling to return base64 WAV data URIs instead of writing to disk. Updated generate_song function to streamline audio response and removed list_songs functionality, enhancing session management by keeping tapes in memory. Adjusted frontend to handle new audio data format and removed unused functions for a cleaner codebase.

Browse files
Files changed (4) hide show
  1. .claude/launch.json +1 -1
  2. app.py +17 -46
  3. frontend/main.js +8 -14
  4. frontend/ui.js +7 -14
.claude/launch.json CHANGED
@@ -4,7 +4,7 @@
4
  {
5
  "name": "lofinity",
6
  "runtimeExecutable": "/bin/sh",
7
- "runtimeArgs": ["-c", "GRADIO_SERVER_PORT=$PORT exec .venv/bin/python app.py"],
8
  "port": 7860,
9
  "autoPort": true
10
  }
 
4
  {
5
  "name": "lofinity",
6
  "runtimeExecutable": "/bin/sh",
7
+ "runtimeArgs": ["-c", "GRADIO_SERVER_PORT=$PORT LOFINITY_ENGINE=stub exec .venv/bin/python app.py"],
8
  "port": 7860,
9
  "autoPort": true
10
  }
app.py CHANGED
@@ -16,24 +16,21 @@ Env knobs:
16
  OLLAMA_MODEL default llama3.2:3b
17
  """
18
 
 
 
19
  import json
20
  import os
21
  import threading
22
- import time
23
- import uuid
24
  import wave
25
  from pathlib import Path
26
 
27
  import httpx
28
  from fastapi.responses import FileResponse
29
  from fastapi.staticfiles import StaticFiles
30
- from gradio import FileData
31
  from gradio.server import Server
32
 
33
  ROOT = Path(__file__).parent
34
  FRONTEND = ROOT / "frontend"
35
- SONGS_DIR = ROOT / ".cache" / "songs"
36
- SONGS_DIR.mkdir(parents=True, exist_ok=True)
37
 
38
  ENGINE = os.getenv("LOFINITY_ENGINE", "musicgen")
39
  # 30s is musicgen-small's single-shot max (1500 tokens of context)
@@ -142,7 +139,12 @@ def load_musicgen():
142
  return _musicgen
143
 
144
 
145
- def write_wav(samples, rate: int) -> Path:
 
 
 
 
 
146
  import numpy as np
147
 
148
  # MusicGen can exceed [-1, 1]; normalize instead of hard-clipping
@@ -150,13 +152,14 @@ def write_wav(samples, rate: int) -> Path:
150
  if peak > 0.95:
151
  samples = samples * (0.95 / peak)
152
  pcm = (samples * 32767).astype("<i2")
153
- out = SONGS_DIR / f"{uuid.uuid4().hex}.wav"
154
- with wave.open(str(out), "wb") as w:
155
  w.setnchannels(1)
156
  w.setsampwidth(2)
157
  w.setframerate(rate)
158
  w.writeframes(pcm.tobytes())
159
- return out
 
160
 
161
 
162
  def musicgen_engine(music_prompt: str) -> tuple:
@@ -214,42 +217,10 @@ def generate_song(prompt: str) -> dict:
214
  samples = ambience.mix(samples, rate, bed)
215
  except Exception as e: # noqa: BLE001 — a dry tape beats a failed vend
216
  print(f"[lofinity] ambience mix failed ({e!r}), vending without the bed")
217
- out = write_wav(samples, rate)
218
- # sidecar metadata so the tape shows up in the collection later
219
- out.with_suffix(".json").write_text(
220
- json.dumps(
221
- {"title": title, "prompt": prompt, "ambience": bed, "created": time.time()}
222
- )
223
- )
224
- return {
225
- "title": title,
226
- # url is what browser clients play from; the path alone only works
227
- # for the python client, which builds the file URL itself
228
- "audio": FileData(path=str(out), url=f"/gradio_api/file={out}"),
229
- }
230
-
231
-
232
- @app.api(name="list_songs")
233
- def list_songs() -> list:
234
- """Every tape actually vended, newest first. A wav without its sidecar
235
- label is not a tape the user owns (stub tones, dev leftovers) — skip it."""
236
- songs = []
237
- for wav in SONGS_DIR.glob("*.wav"):
238
- meta_path = wav.with_suffix(".json")
239
- if not meta_path.exists():
240
- continue
241
- title, created = "Untitled Tape", wav.stat().st_mtime
242
- try:
243
- meta = json.loads(meta_path.read_text())
244
- title = str(meta.get("title") or title)
245
- created = float(meta.get("created") or created)
246
- except (ValueError, OSError):
247
- pass # a torn label, but the tape was vended — still plays fine
248
- songs.append(
249
- {"title": title, "created": created, "url": f"/gradio_api/file={wav}"}
250
- )
251
- songs.sort(key=lambda s: s["created"], reverse=True)
252
- return songs
253
 
254
 
255
  @app.get("/")
@@ -260,4 +231,4 @@ async def homepage():
260
  app.mount("/static", StaticFiles(directory=FRONTEND), name="static")
261
 
262
  if __name__ == "__main__":
263
- app.launch(show_error=True, allowed_paths=[str(SONGS_DIR)])
 
16
  OLLAMA_MODEL default llama3.2:3b
17
  """
18
 
19
+ import base64
20
+ import io
21
  import json
22
  import os
23
  import threading
 
 
24
  import wave
25
  from pathlib import Path
26
 
27
  import httpx
28
  from fastapi.responses import FileResponse
29
  from fastapi.staticfiles import StaticFiles
 
30
  from gradio.server import Server
31
 
32
  ROOT = Path(__file__).parent
33
  FRONTEND = ROOT / "frontend"
 
 
34
 
35
  ENGINE = os.getenv("LOFINITY_ENGINE", "musicgen")
36
  # 30s is musicgen-small's single-shot max (1500 tokens of context)
 
139
  return _musicgen
140
 
141
 
142
+ def encode_wav(samples, rate: int) -> str:
143
+ """Encode mono float samples as a base64 WAV data URI, entirely in memory.
144
+
145
+ Nothing is written to disk: on a shared HF Space a songs directory is
146
+ visible to every visitor and grows without bound. Returning the tape
147
+ inline keeps it private to the one request that asked for it."""
148
  import numpy as np
149
 
150
  # MusicGen can exceed [-1, 1]; normalize instead of hard-clipping
 
152
  if peak > 0.95:
153
  samples = samples * (0.95 / peak)
154
  pcm = (samples * 32767).astype("<i2")
155
+ buf = io.BytesIO()
156
+ with wave.open(buf, "wb") as w:
157
  w.setnchannels(1)
158
  w.setsampwidth(2)
159
  w.setframerate(rate)
160
  w.writeframes(pcm.tobytes())
161
+ b64 = base64.b64encode(buf.getvalue()).decode("ascii")
162
+ return f"data:audio/wav;base64,{b64}"
163
 
164
 
165
  def musicgen_engine(music_prompt: str) -> tuple:
 
217
  samples = ambience.mix(samples, rate, bed)
218
  except Exception as e: # noqa: BLE001 — a dry tape beats a failed vend
219
  print(f"[lofinity] ambience mix failed ({e!r}), vending without the bed")
220
+ # The tape rides back inline as a base64 data URI — no disk write, so it is
221
+ # never cached on the Space nor shared with other visitors. The frontend
222
+ # keeps the collection client-side, per browser session.
223
+ return {"title": title, "audio": encode_wav(samples, rate)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
 
225
 
226
  @app.get("/")
 
231
  app.mount("/static", StaticFiles(directory=FRONTEND), name="static")
232
 
233
  if __name__ == "__main__":
234
+ app.launch(show_error=True)
frontend/main.js CHANGED
@@ -480,17 +480,12 @@ try {
480
  // ---------------------------------------------------------------------------
481
 
482
  let generateFn = null;
483
- let listSongsFn = null;
484
 
485
  const ui = initUI({
486
  generate: (prompt) => {
487
  if (!generateFn) return Promise.reject(new Error("backend not connected"));
488
  return generateFn(prompt);
489
  },
490
- listSongs: () => {
491
- if (!listSongsFn) return Promise.reject(new Error("backend not connected"));
492
- return listSongsFn();
493
- },
494
  onRequestClose: () => {
495
  if (view.mode === "machine") closeMachineView();
496
  },
@@ -516,17 +511,16 @@ import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js")
516
  generateFn = async (prompt) => {
517
  const result = await client.predict("/generate_song", { prompt });
518
  const data = result.data[0];
519
- const url =
520
- data.audio?.url ??
521
- (data.audio?.path ? `/gradio_api/file=${data.audio.path}` : null);
522
- if (!url) throw new Error("no audio in response");
 
 
 
523
  return { title: data.title, url };
524
  };
525
- listSongsFn = async () => {
526
- const result = await client.predict("/list_songs", {});
527
- return result.data[0] ?? [];
528
- };
529
- window.lofinity = { generate: generateFn, listSongs: listSongsFn };
530
  console.log("[LoFinity] backend connected");
531
  })
532
  .catch((err) => console.warn("[LoFinity] backend not reachable:", err));
 
480
  // ---------------------------------------------------------------------------
481
 
482
  let generateFn = null;
 
483
 
484
  const ui = initUI({
485
  generate: (prompt) => {
486
  if (!generateFn) return Promise.reject(new Error("backend not connected"));
487
  return generateFn(prompt);
488
  },
 
 
 
 
489
  onRequestClose: () => {
490
  if (view.mode === "machine") closeMachineView();
491
  },
 
511
  generateFn = async (prompt) => {
512
  const result = await client.predict("/generate_song", { prompt });
513
  const data = result.data[0];
514
+ const dataUri = data.audio;
515
+ if (!dataUri) throw new Error("no audio in response");
516
+ // The tape comes back as an inline base64 WAV; pull it into a Blob URL —
517
+ // lighter for <audio> than a multi-MB data URI, and the blob's uuid path
518
+ // gives the collection a stable per-tape key. Nothing touches disk.
519
+ const blob = await (await fetch(dataUri)).blob();
520
+ const url = URL.createObjectURL(blob);
521
  return { title: data.title, url };
522
  };
523
+ window.lofinity = { generate: generateFn };
 
 
 
 
524
  console.log("[LoFinity] backend connected");
525
  })
526
  .catch((err) => console.warn("[LoFinity] backend not reachable:", err));
frontend/ui.js CHANGED
@@ -8,7 +8,6 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
8
 
9
  export function initUI({
10
  generate,
11
- listSongs,
12
  onRequestClose,
13
  onRequestCloseCollection,
14
  onRequestCloseGameboy,
@@ -65,6 +64,9 @@ export function initUI({
65
  let gameboyOpen = false;
66
  let garden = null; // the Game Boy garden mini-game (lazily started)
67
  let currentSong = null; // { title, url } of the tape in the deck
 
 
 
68
 
69
  function setStage(stage) {
70
  controlsRow.classList.toggle("hidden", stage !== "prompt");
@@ -109,6 +111,7 @@ export function initUI({
109
  try {
110
  // hold the brewing moment even if the backend is fast
111
  const [result] = await Promise.all([generate(prompt), delay(2600)]);
 
112
  loadCassette(result);
113
  busy = false;
114
  setStage("cassette");
@@ -327,28 +330,18 @@ export function initUI({
327
  layoutCarousel();
328
  }
329
 
330
- async function openCollection() {
331
  collectionOpen = true;
332
  collectionPanel.classList.remove("hidden");
333
  deck.classList.remove("hidden");
334
  syncPill();
335
  carousel.innerHTML = "";
336
- setCollectionStatus("rummaging through the tapes…");
337
- let songs;
338
- try {
339
- songs = await listSongs();
340
- } catch (err) {
341
- console.warn("[LoFinity] couldn't list tapes:", err);
342
- if (collectionOpen) setCollectionStatus("the tape shelf is unreachable — try again later");
343
- return;
344
- }
345
- if (!collectionOpen) return; // closed while we were rummaging
346
- if (!songs.length) {
347
  setCollectionStatus("no tapes yet — go vend a vibe at the machine ♪");
348
  return;
349
  }
350
  setCollectionStatus(null);
351
- renderCarousel(songs);
352
  }
353
 
354
  function closeCollection() {
 
8
 
9
  export function initUI({
10
  generate,
 
11
  onRequestClose,
12
  onRequestCloseCollection,
13
  onRequestCloseGameboy,
 
64
  let gameboyOpen = false;
65
  let garden = null; // the Game Boy garden mini-game (lazily started)
66
  let currentSong = null; // { title, url } of the tape in the deck
67
+ // The collection lives in memory, per browser session — never on the server,
68
+ // so tapes are private and a reload starts the shelf empty. Newest first.
69
+ const sessionTapes = [];
70
 
71
  function setStage(stage) {
72
  controlsRow.classList.toggle("hidden", stage !== "prompt");
 
111
  try {
112
  // hold the brewing moment even if the backend is fast
113
  const [result] = await Promise.all([generate(prompt), delay(2600)]);
114
+ sessionTapes.unshift(result); // newest first, kept only for this session
115
  loadCassette(result);
116
  busy = false;
117
  setStage("cassette");
 
330
  layoutCarousel();
331
  }
332
 
333
+ function openCollection() {
334
  collectionOpen = true;
335
  collectionPanel.classList.remove("hidden");
336
  deck.classList.remove("hidden");
337
  syncPill();
338
  carousel.innerHTML = "";
339
+ if (!sessionTapes.length) {
 
 
 
 
 
 
 
 
 
 
340
  setCollectionStatus("no tapes yet — go vend a vibe at the machine ♪");
341
  return;
342
  }
343
  setCollectionStatus(null);
344
+ renderCarousel(sessionTapes);
345
  }
346
 
347
  function closeCollection() {