Spaces:
Sleeping
Sleeping
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- .claude/launch.json +1 -1
- app.py +17 -46
- frontend/main.js +8 -14
- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 154 |
-
with wave.open(
|
| 155 |
w.setnchannels(1)
|
| 156 |
w.setsampwidth(2)
|
| 157 |
w.setframerate(rate)
|
| 158 |
w.writeframes(pcm.tobytes())
|
| 159 |
-
|
|
|
|
| 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 |
-
|
| 218 |
-
#
|
| 219 |
-
|
| 220 |
-
|
| 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
|
|
|
|
| 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
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
|
|
|
|
|
|
|
|
|
| 523 |
return { title: data.title, url };
|
| 524 |
};
|
| 525 |
-
|
| 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 |
-
|
| 331 |
collectionOpen = true;
|
| 332 |
collectionPanel.classList.remove("hidden");
|
| 333 |
deck.classList.remove("hidden");
|
| 334 |
syncPill();
|
| 335 |
carousel.innerHTML = "";
|
| 336 |
-
|
| 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(
|
| 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() {
|