victor HF Staff commited on
Commit
a1df431
Β·
1 Parent(s): dc5fc4b

ace-step-jam: AI music generation studio

Browse files

Describe any song in plain English β€” an LLM writes tags and lyrics,
ACE-Step v1.5 generates the audio, and Z-Image-Turbo creates a
thumbnail. Community feed for sharing songs.

Built on ACE-Step/acestep-v15-xl-turbo + Tongyi-MAI/Z-Image-Turbo.

Files changed (4) hide show
  1. README.md +9 -12
  2. app.py +319 -58
  3. index.html +519 -675
  4. requirements.txt +1 -1
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Ace-Step Studio
3
  emoji: 🎡
4
  colorFrom: gray
5
  colorTo: gray
@@ -8,24 +8,21 @@ sdk_version: 6.12.0
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
- short_description: Minimalist dark UI for ACE-Step music generation
12
  models:
13
  - ACE-Step/Ace-Step1.5
14
  - ACE-Step/acestep-v15-xl-turbo
15
- preload_from_hub:
16
- - ACE-Step/Ace-Step1.5
17
- - ACE-Step/acestep-v15-xl-turbo
18
  ---
19
 
20
- # ACE-Step Studio
21
 
22
- A minimalist, dark-themed interface for generating music with [ACE-Step](https://github.com/ace-step/ACE-Step).
23
 
24
- **Model**: `ACE-Step/acestep-v15-xl-turbo` β€” generates 1 minute of audio in ~2 seconds (8-step turbo distillation).
25
 
26
  ## Usage
27
 
28
- 1. Enter style tags (e.g. `lo-fi, chill, piano, female vocals`)
29
- 2. Write lyrics with `[verse]`, `[chorus]`, `[bridge]` section markers
30
- 3. Hit **Generate** β€” a waveform appears when ready
31
- 4. Use **✨ Inspire me** to auto-generate lyrics via LLM
 
1
  ---
2
+ title: ace-step-jam
3
  emoji: 🎡
4
  colorFrom: gray
5
  colorTo: gray
 
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
+ short_description: Describe any song β€” AI writes & produces it
12
  models:
13
  - ACE-Step/Ace-Step1.5
14
  - ACE-Step/acestep-v15-xl-turbo
15
+ - Tongyi-MAI/Z-Image-Turbo
 
 
16
  ---
17
 
18
+ # ace-step-jam
19
 
20
+ Describe any song in plain English β€” an LLM writes the tags and lyrics, then [ACE-Step v1.5](https://github.com/ace-step/ACE-Step) generates the audio.
21
 
22
+ **Model**: [`ACE-Step/acestep-v15-xl-turbo`](https://huggingface.co/ACE-Step/acestep-v15-xl-turbo) β€” 8-step turbo distillation, ~2 seconds per minute of audio on GPU.
23
 
24
  ## Usage
25
 
26
+ 1. Describe the song you want (genre, mood, story β€” anything)
27
+ 2. Hit **Generate** β€” the AI composes lyrics, picks tags, and produces audio
28
+ 3. Click the waveform to seek, download the WAV, or share to the community feed
 
app.py CHANGED
@@ -1,13 +1,16 @@
1
  import os
2
  import sys
 
 
3
  import base64
 
4
  import tempfile
5
  import traceback
 
6
  import numpy as np
7
  import soundfile as sf
8
 
9
  # ── CRITICAL: import spaces BEFORE torch and acestep ─────────────────────────
10
- # spaces monkey-patches CUDA init hooks so ZeroGPU can intercept GPU allocation.
11
  try:
12
  import spaces
13
  HAS_SPACES = True
@@ -20,43 +23,69 @@ for _v in ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"
20
  os.environ["GRADIO_ANALYTICS_ENABLED"] = "False"
21
 
22
  # Fix PermissionError on ZeroGPU: /home/user/.cache is not writable.
23
- # Redirect HF module cache + matplotlib config to /tmp.
24
  os.environ.setdefault("HF_MODULES_CACHE", "/tmp/hf_modules")
25
  os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib")
26
 
27
- # Add bundled nano-vllm to path (needed by LLM handler; do this before any acestep import)
28
  _current_dir = os.path.dirname(os.path.abspath(__file__))
29
  _nano_vllm = os.path.join(_current_dir, "acestep", "third_parts", "nano-vllm")
30
  if os.path.exists(_nano_vllm):
31
  sys.path.insert(0, _nano_vllm)
32
 
 
 
33
  import torch
 
34
  from acestep.handler import AceStepHandler
35
  from gradio import Server
36
  from fastapi.responses import HTMLResponse
37
  from openai import OpenAI
38
 
39
  # ── Model Loading ─────────────────────────────────────────────────────────────
40
- # AceStepHandler downloads both ACE-Step/Ace-Step1.5 (VAE + text encoder)
41
- # and ACE-Step/acestep-v15-xl-turbo (transformer) into persistent_storage_path.
42
- # preload_from_hub in README pre-downloads them at build time so startup is fast.
43
 
44
  def _get_storage_path():
45
- if os.path.exists("/data"):
46
- try:
47
- t = "/data/.write_test"
48
- with open(t, "w") as f:
49
- f.write("x")
50
- os.remove(t)
51
- return "/data"
52
- except OSError:
53
- pass
54
- p = os.path.join(_current_dir, "data")
55
  os.makedirs(p, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  return p
57
 
58
  _storage = _get_storage_path()
59
- print(f"[startup] Storage: {_storage}")
 
60
 
61
  handler = AceStepHandler(persistent_storage_path=_storage)
62
  _status, _ready = handler.initialize_service(
@@ -70,8 +99,149 @@ _status, _ready = handler.initialize_service(
70
  )
71
  print(f"[startup] Handler: ready={_ready} β€” {_status}")
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  # ── GPU Inference Function ────────────────────────────────────────────────────
74
- # RULE: @spaces.GPU and @app.api MUST be on SEPARATE functions. Never stack.
75
 
76
  if HAS_SPACES:
77
  @spaces.GPU(duration=120)
@@ -103,17 +273,15 @@ def _run_inference(prompt, lyrics, audio_duration, infer_steps, seed) -> str:
103
  raise RuntimeError(result.get("error", "generation failed"))
104
 
105
  audio_dict = result["audios"][0]
106
- tensor = audio_dict["tensor"] # [channels, samples], CPU float32
107
  sr = audio_dict["sample_rate"]
108
 
109
- # Convert to numpy [samples, channels] or [samples] for soundfile
110
  data = tensor.cpu().float().numpy()
111
  if data.ndim == 2:
112
- data = data.T # β†’ [samples, channels]
113
  if data.shape[1] == 1:
114
- data = data[:, 0] # collapse mono
115
 
116
- # Peak-normalize as safety net (v1.5 DCAE should already be scaled correctly)
117
  peak = np.abs(data).max()
118
  if peak > 1e-4:
119
  data = (data / peak * 0.95).astype(np.float32)
@@ -124,19 +292,109 @@ def _run_inference(prompt, lyrics, audio_duration, infer_steps, seed) -> str:
124
 
125
 
126
  # ── gr.Server App ─────────────────────────────────────────────────────────────
127
- app = Server(title="ACE-Step Studio")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
 
 
 
 
129
 
130
- # ── API: Generate music ───────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  @app.api(name="generate", concurrency_limit=1, time_limit=180)
132
  def generate(
133
  prompt: str,
134
  lyrics: str,
135
  audio_duration: float = 60.0,
136
  infer_step: int = 8,
 
137
  seed: int = -1,
 
 
138
  ) -> str:
139
- """Generate music from style tags and lyrics. Returns a base64 WAV data URL."""
140
  try:
141
  wav_path = _generate_gpu(prompt, lyrics, audio_duration, infer_step, seed)
142
  with open(wav_path, "rb") as f:
@@ -148,38 +406,42 @@ def generate(
148
  raise
149
 
150
 
151
- # ── API: LLM lyrics inspiration ───────────────────────────────────────────────
152
- @app.api(name="inspire", concurrency_limit=4)
153
- def inspire(genre_hint: str = "") -> str:
154
- """Generate lyrics via HF Inference Router. Token is server-side only."""
155
- token = os.environ.get("HF_TOKEN", "")
156
- if not token:
157
- return "[Error: HF_TOKEN not configured in Space secrets]"
158
 
159
- client = OpenAI(base_url="https://router.huggingface.co/v1", api_key=token)
160
- genre = genre_hint.strip() or "original"
161
- try:
162
- import re
163
- resp = client.chat.completions.create(
164
- model="Qwen/Qwen3-8B",
165
- messages=[
166
- {
167
- "role": "system",
168
- "content": (
169
- "You are a lyricist. Write song lyrics using "
170
- "[verse], [chorus], and optionally [bridge] markers. "
171
- "Return ONLY the lyrics."
172
- ),
173
- },
174
- {"role": "user", "content": f"Write lyrics for a {genre} song. /no_think"},
175
- ],
176
- max_tokens=600,
177
- temperature=0.9,
178
- )
179
- content = resp.choices[0].message.content
180
- return re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL).strip()
181
- except Exception as e:
182
- return f"[Error: {e}]"
 
 
 
 
 
 
 
183
 
184
 
185
  # ── Serve custom HTML frontend ────────────────────────────────────────────────
@@ -189,7 +451,6 @@ async def homepage():
189
  return f.read()
190
 
191
 
192
- # Required by HF Spaces runtime
193
  demo = app
194
 
195
  if __name__ == "__main__":
 
1
  import os
2
  import sys
3
+ import re
4
+ import json
5
  import base64
6
+ import uuid
7
  import tempfile
8
  import traceback
9
+ from datetime import datetime, timezone
10
  import numpy as np
11
  import soundfile as sf
12
 
13
  # ── CRITICAL: import spaces BEFORE torch and acestep ─────────────────────────
 
14
  try:
15
  import spaces
16
  HAS_SPACES = True
 
23
  os.environ["GRADIO_ANALYTICS_ENABLED"] = "False"
24
 
25
  # Fix PermissionError on ZeroGPU: /home/user/.cache is not writable.
 
26
  os.environ.setdefault("HF_MODULES_CACHE", "/tmp/hf_modules")
27
  os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib")
28
 
29
+ # Add bundled nano-vllm to path
30
  _current_dir = os.path.dirname(os.path.abspath(__file__))
31
  _nano_vllm = os.path.join(_current_dir, "acestep", "third_parts", "nano-vllm")
32
  if os.path.exists(_nano_vllm):
33
  sys.path.insert(0, _nano_vllm)
34
 
35
+ import io
36
+ import random
37
  import torch
38
+ from PIL import Image
39
  from acestep.handler import AceStepHandler
40
  from gradio import Server
41
  from fastapi.responses import HTMLResponse
42
  from openai import OpenAI
43
 
44
  # ── Model Loading ─────────────────────────────────────────────────────────────
 
 
 
45
 
46
  def _get_storage_path():
47
+ """Model checkpoints β€” try to reuse preload_from_hub cache via symlinks."""
48
+ p = os.path.join(_current_dir, "model_cache")
 
 
 
 
 
 
 
 
49
  os.makedirs(p, exist_ok=True)
50
+ checkpoint_dir = os.path.join(p, "checkpoints")
51
+ os.makedirs(checkpoint_dir, exist_ok=True)
52
+
53
+ # preload_from_hub downloads to HF cache during Docker build.
54
+ # Create symlinks so the handler finds models at the expected paths
55
+ # without re-downloading 20GB on each restart.
56
+ from huggingface_hub import try_to_load_from_cache, scan_cache_dir
57
+ for model_name, repo_id in [
58
+ ("acestep-v15-xl-turbo", "ACE-Step/acestep-v15-xl-turbo"),
59
+ ]:
60
+ target = os.path.join(checkpoint_dir, model_name)
61
+ if not os.path.exists(target):
62
+ try:
63
+ from huggingface_hub import snapshot_download
64
+ cached = snapshot_download(repo_id, local_files_only=True)
65
+ os.symlink(cached, target)
66
+ print(f"[startup] Linked {model_name} β†’ {cached}")
67
+ except Exception as e:
68
+ print(f"[startup] Cache miss for {model_name}, will download: {e}")
69
+
70
+ # For the unified repo (ACE-Step/Ace-Step1.5), its subdirs (vae, Qwen3-Embedding-0.6B, etc.)
71
+ # need to appear directly in checkpoint_dir
72
+ try:
73
+ from huggingface_hub import snapshot_download
74
+ cached = snapshot_download("ACE-Step/Ace-Step1.5", local_files_only=True)
75
+ for sub in os.listdir(cached):
76
+ src = os.path.join(cached, sub)
77
+ dst = os.path.join(checkpoint_dir, sub)
78
+ if os.path.isdir(src) and not os.path.exists(dst):
79
+ os.symlink(src, dst)
80
+ print(f"[startup] Linked {sub} β†’ {src}")
81
+ except Exception as e:
82
+ print(f"[startup] Cache miss for Ace-Step1.5, will download: {e}")
83
+
84
  return p
85
 
86
  _storage = _get_storage_path()
87
+ print(f"[startup] Model storage: {_storage}")
88
+ print(f"[startup] Community bucket: /data (mounted)")
89
 
90
  handler = AceStepHandler(persistent_storage_path=_storage)
91
  _status, _ready = handler.initialize_service(
 
99
  )
100
  print(f"[startup] Handler: ready={_ready} β€” {_status}")
101
 
102
+ # ── Z-Image-Turbo (thumbnail generation) ─────────────────────────────────────
103
+ try:
104
+ from diffusers import ZImagePipeline, FlowMatchEulerDiscreteScheduler
105
+ _zimage_pipe = ZImagePipeline.from_pretrained(
106
+ "Tongyi-MAI/Z-Image-Turbo",
107
+ torch_dtype=torch.bfloat16,
108
+ )
109
+ _zimage_pipe.to("cuda")
110
+ print("[startup] Z-Image-Turbo loaded for thumbnails")
111
+ except Exception as e:
112
+ _zimage_pipe = None
113
+ print(f"[startup] Z-Image-Turbo not available: {e}")
114
+
115
+ # ── LLM Compose ──────────────────────���───────────────────────────────────────
116
+
117
+ COMPOSE_SYSTEM = """You are a Grammy-winning songwriter and music producer. The user will describe a song idea in plain English. Your job is to flesh it out into a complete song specification.
118
+
119
+ Return EXACTLY this format β€” no extra text:
120
+
121
+ ---
122
+ title: <short catchy song title>
123
+ tags: <genre and style tags, comma-separated, 3-6 tags>
124
+ bpm: <tempo as integer>
125
+ language: <vocal language: en, zh, ja, ko, or "unknown" for instrumental>
126
+ ---
127
+
128
+ <song lyrics with [Verse], [Chorus], [Bridge] markers>
129
+ <use [Instrumental] alone if the song has no vocals>"""
130
+
131
+ BUCKET_ID = "victor/ace-step-community"
132
+ BUCKET_URL = f"https://huggingface.co/buckets/{BUCKET_ID}/resolve"
133
+
134
+
135
+ def _compose(description: str) -> dict:
136
+ """Call HF Inference Router LLM to generate tags + lyrics from a description."""
137
+ key = os.environ.get("HF_TOKEN", "")
138
+ if not key:
139
+ raise RuntimeError("HF_TOKEN not configured")
140
+
141
+ client = OpenAI(base_url="https://router.huggingface.co/v1", api_key=key)
142
+ resp = client.chat.completions.create(
143
+ model="openai/gpt-oss-120b:groq",
144
+ messages=[
145
+ {"role": "system", "content": COMPOSE_SYSTEM},
146
+ {"role": "user", "content": description},
147
+ ],
148
+ max_tokens=2000,
149
+ temperature=0.9,
150
+ )
151
+ raw = resp.choices[0].message.content or ""
152
+ content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
153
+
154
+ # Parse frontmatter
155
+ title, tags, bpm, language = "Untitled", "", 120, "en"
156
+ lyrics = content
157
+ m = re.search(r"---\s*\n(.*?)\n---\s*\n(.*)", content, re.DOTALL)
158
+ if m:
159
+ header, lyrics = m.group(1), m.group(2).strip()
160
+ for line in header.strip().split("\n"):
161
+ if line.startswith("title:"):
162
+ title = line[6:].strip().strip('"\'')
163
+ elif line.startswith("tags:"):
164
+ tags = line[5:].strip()
165
+ elif line.startswith("bpm:"):
166
+ try:
167
+ bpm = int(line[4:].strip())
168
+ except ValueError:
169
+ pass
170
+ elif line.startswith("language:"):
171
+ language = line[9:].strip()
172
+
173
+ return {"title": title, "tags": tags, "lyrics": lyrics, "bpm": bpm, "language": language}
174
+
175
+
176
+ # ── Thumbnail Generation ─────────────────────────────────────────────────────
177
+
178
+ def _get_song_word(title: str, tags: str, lyrics: str, description: str) -> str:
179
+ """Ask LLM for a single evocative word to represent the song visually."""
180
+ # Fallback: first 2 words of description or title
181
+ fallback = " ".join((description or title or "music").split()[:2])
182
+ key = os.environ.get("HF_TOKEN", "")
183
+ if not key:
184
+ print(f"[thumbnail] no HF_TOKEN, using fallback: {fallback}")
185
+ return fallback
186
+ try:
187
+ client = OpenAI(base_url="https://router.huggingface.co/v1", api_key=key)
188
+ resp = client.chat.completions.create(
189
+ model="openai/gpt-oss-120b:groq",
190
+ messages=[
191
+ {"role": "system", "content": "Reply with exactly ONE concrete visual noun (a physical object, animal, or natural element) that captures the essence of this song. No explanation, no punctuation, just the single word."},
192
+ {"role": "user", "content": f"Title: {title}\nTags: {tags}\nLyrics: {lyrics[:300]}"},
193
+ ],
194
+ max_tokens=500,
195
+ temperature=0.7,
196
+ )
197
+ raw = resp.choices[0].message.content or ""
198
+ cleaned = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
199
+ word = cleaned.split()[0].strip('."\'!,') if cleaned.split() else ""
200
+ if not word:
201
+ print(f"[thumbnail] LLM returned empty, using fallback: {fallback}")
202
+ return fallback
203
+ print(f"[thumbnail] word: {word}")
204
+ return word
205
+ except Exception as e:
206
+ print(f"[thumbnail] word extraction failed: {e}, using fallback: {fallback}")
207
+ return fallback
208
+
209
+
210
+ def _generate_thumbnail_impl(word: str) -> bytes | None:
211
+ """Generate a thumbnail using Z-Image-Turbo. Returns PNG bytes or None."""
212
+ if _zimage_pipe is None:
213
+ return None
214
+ try:
215
+ prompt = f"{word} studio photography close-up black background"
216
+ print(f"[thumbnail] generating: {prompt}")
217
+ scheduler = FlowMatchEulerDiscreteScheduler(num_train_timesteps=1000, shift=3.0)
218
+ _zimage_pipe.scheduler = scheduler
219
+ image = _zimage_pipe(
220
+ prompt=prompt,
221
+ height=1024, width=1024,
222
+ guidance_scale=0.0,
223
+ num_inference_steps=9,
224
+ generator=torch.Generator("cuda").manual_seed(random.randint(1, 1000000)),
225
+ max_sequence_length=512,
226
+ ).images[0]
227
+ buf = io.BytesIO()
228
+ image.save(buf, format="PNG", optimize=True)
229
+ print(f"[thumbnail] done ({len(buf.getvalue()) // 1024}KB)")
230
+ return buf.getvalue()
231
+ except Exception as e:
232
+ print(f"[thumbnail] generation failed: {e}")
233
+ return None
234
+
235
+ if HAS_SPACES:
236
+ @spaces.GPU(duration=30)
237
+ def _generate_thumbnail(word: str) -> bytes | None:
238
+ return _generate_thumbnail_impl(word)
239
+ else:
240
+ def _generate_thumbnail(word: str) -> bytes | None:
241
+ return _generate_thumbnail_impl(word)
242
+
243
+
244
  # ── GPU Inference Function ────────────────────────────────────────────────────
 
245
 
246
  if HAS_SPACES:
247
  @spaces.GPU(duration=120)
 
273
  raise RuntimeError(result.get("error", "generation failed"))
274
 
275
  audio_dict = result["audios"][0]
276
+ tensor = audio_dict["tensor"]
277
  sr = audio_dict["sample_rate"]
278
 
 
279
  data = tensor.cpu().float().numpy()
280
  if data.ndim == 2:
281
+ data = data.T
282
  if data.shape[1] == 1:
283
+ data = data[:, 0]
284
 
 
285
  peak = np.abs(data).max()
286
  if peak > 1e-4:
287
  data = (data / peak * 0.95).astype(np.float32)
 
292
 
293
 
294
  # ── gr.Server App ─────────────────────────────────────────────────────────────
295
+ app = Server(title="ace-step-jam")
296
+
297
+
298
+ # ── API: One-box create (compose + generate) ─────────────────────────────────
299
+ @app.api(name="create", concurrency_limit=1, time_limit=300)
300
+ def create(
301
+ description: str,
302
+ audio_duration: float = 60.0,
303
+ seed: int = -1,
304
+ community: bool = False,
305
+ ) -> str:
306
+ """One-box: describe a song β†’ LLM composes tags+lyrics β†’ generates audio.
307
+ Returns JSON: {audio, title, tags, lyrics, community_url?}"""
308
+ try:
309
+ # Step 1: LLM compose (no GPU)
310
+ composed = _compose(description)
311
+ title = composed["title"]
312
+ tags = composed["tags"]
313
+ lyrics = composed["lyrics"]
314
+ print(f"[create] title={title} tags={tags[:60]}...")
315
+
316
+ # Step 2: GPU generate music
317
+ wav_path = _generate_gpu(tags, lyrics, audio_duration, 8, seed)
318
+ with open(wav_path, "rb") as f:
319
+ wav_bytes = f.read()
320
+ audio_b64 = f"data:audio/wav;base64,{base64.b64encode(wav_bytes).decode()}"
321
+
322
+ # Step 3: Generate thumbnail (separate GPU session via Z-Image-Turbo)
323
+ thumb_bytes = None
324
+ try:
325
+ word = _get_song_word(title, tags, lyrics, description)
326
+ thumb_bytes = _generate_thumbnail(word)
327
+ except Exception as e:
328
+ print(f"[create] thumbnail failed: {e}")
329
+
330
+ result = {
331
+ "audio": audio_b64,
332
+ "title": title,
333
+ "tags": tags,
334
+ "lyrics": lyrics,
335
+ }
336
+ if thumb_bytes:
337
+ result["thumbnail"] = f"data:image/png;base64,{base64.b64encode(thumb_bytes).decode()}"
338
+
339
+ # Step 3: Community upload (if checked and /data is writable)
340
+ if community:
341
+ try:
342
+ song_id = uuid.uuid4().hex[:12]
343
+ song_dir = f"/data/songs/{song_id}"
344
+ os.makedirs(song_dir, exist_ok=True)
345
 
346
+ # Save WAV
347
+ wav_name = f"{song_id}.wav"
348
+ with open(f"{song_dir}/{wav_name}", "wb") as f:
349
+ f.write(wav_bytes)
350
 
351
+ # Save thumbnail
352
+ has_thumb = False
353
+ if thumb_bytes:
354
+ with open(f"{song_dir}/thumb.png", "wb") as f:
355
+ f.write(thumb_bytes)
356
+ has_thumb = True
357
+
358
+ # Save metadata
359
+ meta = {
360
+ "id": song_id,
361
+ "title": title,
362
+ "description": description,
363
+ "tags": tags,
364
+ "lyrics": lyrics,
365
+ "duration": audio_duration,
366
+ "has_thumb": has_thumb,
367
+ "created_at": datetime.now(timezone.utc).isoformat(),
368
+ }
369
+ with open(f"{song_dir}/meta.json", "w") as f:
370
+ json.dump(meta, f, indent=2)
371
+
372
+ result["community_url"] = f"{BUCKET_URL}/songs/{song_id}/{wav_name}"
373
+ print(f"[create] Shared to community: {result['community_url']}")
374
+ _invalidate_feed()
375
+ except Exception as upload_err:
376
+ print(f"[create] Community upload failed: {upload_err}")
377
+
378
+ return json.dumps(result)
379
+ except Exception as e:
380
+ print(f"[create ERROR] {type(e).__name__}: {e}")
381
+ print(traceback.format_exc())
382
+ raise
383
+
384
+
385
+ # ── API: Direct generate (for advanced/custom mode) ──────────────────────────
386
  @app.api(name="generate", concurrency_limit=1, time_limit=180)
387
  def generate(
388
  prompt: str,
389
  lyrics: str,
390
  audio_duration: float = 60.0,
391
  infer_step: int = 8,
392
+ guidance_scale: float = 7.0,
393
  seed: int = -1,
394
+ lora_name_or_path: str = "",
395
+ lora_weight: float = 0.8,
396
  ) -> str:
397
+ """Direct generate from explicit tags + lyrics. Returns base64 WAV data URL."""
398
  try:
399
  wav_path = _generate_gpu(prompt, lyrics, audio_duration, infer_step, seed)
400
  with open(wav_path, "rb") as f:
 
406
  raise
407
 
408
 
409
+ # ── API: Community feed (cached) ──────────────────────────────────────────────
410
+ import time as _time
411
+ _feed_cache = {"data": "[]", "ts": 0}
412
+ _FEED_TTL = 10 # seconds
 
 
 
413
 
414
+ def _rebuild_feed() -> str:
415
+ songs = []
416
+ songs_dir = "/data/songs"
417
+ if os.path.isdir(songs_dir):
418
+ for song_id in os.listdir(songs_dir):
419
+ meta_path = os.path.join(songs_dir, song_id, "meta.json")
420
+ if os.path.isfile(meta_path):
421
+ try:
422
+ with open(meta_path) as f:
423
+ meta = json.load(f)
424
+ meta["audio_url"] = f"{BUCKET_URL}/songs/{song_id}/{song_id}.wav"
425
+ if meta.get("has_thumb"):
426
+ meta["thumb_url"] = f"{BUCKET_URL}/songs/{song_id}/thumb.png"
427
+ songs.append(meta)
428
+ except Exception:
429
+ pass
430
+ songs.sort(key=lambda s: s.get("created_at", ""), reverse=True)
431
+ return json.dumps(songs[:32])
432
+
433
+ def _invalidate_feed():
434
+ """Call after uploading a new song to bust the cache."""
435
+ _feed_cache["ts"] = 0
436
+
437
+ @app.api(name="community", concurrency_limit=4)
438
+ def community() -> str:
439
+ """List community songs from the bucket. Cached for 10s."""
440
+ now = _time.monotonic()
441
+ if now - _feed_cache["ts"] > _FEED_TTL:
442
+ _feed_cache["data"] = _rebuild_feed()
443
+ _feed_cache["ts"] = now
444
+ return _feed_cache["data"]
445
 
446
 
447
  # ── Serve custom HTML frontend ────────────────────────────────────────────────
 
451
  return f.read()
452
 
453
 
 
454
  demo = app
455
 
456
  if __name__ == "__main__":
index.html CHANGED
@@ -3,813 +3,657 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>ACE-Step Studio</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
9
  <style>
10
- /* ── Reset & Variables ────────────────────────────────────────────────── */
11
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
12
 
13
  :root {
14
- --bg: #080808;
15
- --surface: #111111;
16
- --surface-2: #1a1a1a;
17
- --surface-3: #222222;
18
- --border: #282828;
19
- --accent: #7c6aff;
20
- --accent-dim: #5a4dcc;
21
- --accent-glow: rgba(124, 106, 255, 0.2);
22
- --wave: #7c6aff;
23
- --progress: #4dff91;
24
- --text: #e8e8e8;
25
- --text-muted: #555;
26
- --text-faint: #333;
27
- --error: #ff4d4d;
28
- --success: #4dff91;
29
- --radius: 10px;
30
- --radius-lg: 14px;
31
- --font: 'Inter', system-ui, sans-serif;
32
  }
33
 
34
  html, body {
35
- height: 100%;
36
- background: var(--bg);
37
- color: var(--text);
38
- font-family: var(--font);
39
- font-size: 14px;
40
- line-height: 1.5;
41
  -webkit-font-smoothing: antialiased;
42
  }
43
 
44
- /* ── Layout ───────────────────────────────────────────────────────────── */
45
- body {
46
- display: flex;
47
- flex-direction: column;
48
- min-height: 100vh;
49
- }
50
 
51
- .container {
52
- width: 100%;
53
- max-width: 720px;
54
- margin: 0 auto;
55
- padding: 0 20px;
56
- }
57
 
58
  /* ── Header ───────────────────────────────────────────────────────────── */
59
- header {
60
- padding: 36px 0 28px;
61
- text-align: center;
62
- }
63
-
64
- .wordmark {
65
- display: inline-flex;
66
- align-items: center;
67
- gap: 10px;
68
- margin-bottom: 8px;
 
 
 
 
 
 
 
 
 
 
69
  }
70
-
71
- .wordmark-icon {
72
- width: 28px;
73
- height: 28px;
74
- background: var(--accent);
75
- border-radius: 8px;
76
- display: flex;
77
- align-items: center;
78
- justify-content: center;
79
- font-size: 15px;
80
- box-shadow: 0 0 18px var(--accent-glow);
81
  }
82
-
83
- .wordmark-text {
84
- font-size: 20px;
85
- font-weight: 600;
86
- letter-spacing: -0.02em;
87
- color: var(--text);
88
  }
89
-
90
- .wordmark-text span {
91
- color: var(--accent);
92
  }
93
-
94
- header p {
95
- font-size: 13px;
96
- color: var(--text-muted);
97
- letter-spacing: 0.01em;
 
98
  }
99
-
100
- /* ── Cards ────────────────────────────────────────────────────────────── */
101
- .card {
102
- background: var(--surface);
103
- border: 1px solid var(--border);
104
- border-radius: var(--radius-lg);
105
- padding: 18px;
106
- margin-bottom: 12px;
107
- }
108
-
109
- .card-header {
110
- display: flex;
111
- align-items: center;
112
- justify-content: space-between;
113
- margin-bottom: 10px;
114
- }
115
-
116
- .card-label {
117
- font-size: 11px;
118
- font-weight: 500;
119
- letter-spacing: 0.08em;
120
- text-transform: uppercase;
121
- color: var(--text-muted);
122
  }
123
 
124
  /* ── Inputs ───────────────────────────────────────────────────────────── */
125
- textarea, input[type="text"], input[type="number"] {
126
- width: 100%;
127
- background: var(--surface-2);
128
- border: 1px solid var(--border);
129
- border-radius: var(--radius);
130
- color: var(--text);
131
- font-family: var(--font);
132
- font-size: 14px;
133
- padding: 10px 12px;
134
- resize: none;
135
- outline: none;
 
 
 
 
 
 
 
 
 
 
136
  transition: border-color 0.15s;
137
  }
 
 
 
 
138
 
139
- textarea:focus, input[type="text"]:focus, input[type="number"]:focus {
140
- border-color: rgba(124, 106, 255, 0.5);
141
- }
142
-
143
- textarea::placeholder, input::placeholder {
144
- color: var(--text-faint);
145
- }
146
-
147
- #prompt-input {
148
- height: 60px;
149
- font-size: 14px;
150
- }
151
-
152
- #lyrics-input {
153
- height: 180px;
154
- font-family: 'Courier New', monospace;
155
- font-size: 13px;
156
- line-height: 1.7;
157
- }
158
-
159
- .lyrics-hint {
160
- margin-top: 7px;
161
- font-size: 11px;
162
- color: var(--text-muted);
163
- }
164
-
165
- .lyrics-hint code {
166
- background: var(--surface-3);
167
- border-radius: 4px;
168
- padding: 1px 5px;
169
- color: var(--accent);
170
- font-size: 11px;
171
- }
172
-
173
- /* ── Inspire button (inline) ──────────────────────────────────────────── */
174
- .inspire-btn {
175
- display: inline-flex;
176
- align-items: center;
177
- gap: 5px;
178
- background: transparent;
179
- border: 1px solid var(--border);
180
- border-radius: 6px;
181
- color: var(--text-muted);
182
- font-family: var(--font);
183
- font-size: 12px;
184
- font-weight: 500;
185
- padding: 5px 10px;
186
- cursor: pointer;
187
- transition: color 0.15s, border-color 0.15s;
188
- white-space: nowrap;
189
- }
190
-
191
- .inspire-btn:hover:not(:disabled) {
192
- color: var(--accent);
193
- border-color: rgba(124, 106, 255, 0.4);
194
- }
195
-
196
- .inspire-btn:disabled {
197
- opacity: 0.4;
198
- cursor: not-allowed;
199
- }
200
 
201
  /* ── Generate button ──────────────────────────────────────────────────── */
202
  .generate-btn {
203
- width: 100%;
204
- background: var(--accent);
205
- border: none;
206
- border-radius: var(--radius);
207
- color: #fff;
208
- font-family: var(--font);
209
- font-size: 15px;
210
- font-weight: 600;
211
- padding: 14px;
212
- cursor: pointer;
213
- letter-spacing: 0.01em;
214
- transition: box-shadow 0.2s, transform 0.1s, background 0.15s;
215
- box-shadow: 0 0 24px var(--accent-glow);
216
- margin-bottom: 12px;
217
- }
218
-
219
- .generate-btn:hover:not(:disabled) {
220
- box-shadow: 0 0 40px rgba(124, 106, 255, 0.35);
221
- }
222
-
223
- .generate-btn:active:not(:disabled) {
224
- transform: scale(0.99);
225
- }
226
-
227
- .generate-btn:disabled {
228
- background: var(--accent-dim);
229
- cursor: not-allowed;
230
- box-shadow: none;
231
- }
232
-
233
- .generate-btn.loading {
234
- animation: pulse-glow 1.5s ease-in-out infinite;
235
- }
236
-
237
- @keyframes pulse-glow {
238
- 0%, 100% { box-shadow: 0 0 24px var(--accent-glow); }
239
- 50% { box-shadow: 0 0 48px rgba(124, 106, 255, 0.45); }
240
- }
241
-
242
- /* ── Advanced section ─────────────────────────────────────────────────── */
243
- details {
244
- margin-bottom: 12px;
245
- }
246
-
247
- details summary {
248
- list-style: none;
249
- cursor: pointer;
250
- display: flex;
251
- align-items: center;
252
- gap: 6px;
253
- font-size: 12px;
254
- font-weight: 500;
255
- color: var(--text-muted);
256
- padding: 8px 0;
257
- user-select: none;
258
- letter-spacing: 0.03em;
259
- outline: none;
260
- }
261
-
262
- details summary::-webkit-details-marker { display: none; }
263
-
264
- details summary::before {
265
- content: 'β€Ί';
266
- font-size: 16px;
267
- line-height: 1;
268
- transition: transform 0.2s;
269
- display: inline-block;
270
- color: var(--text-faint);
271
- }
272
-
273
- details[open] summary::before {
274
- transform: rotate(90deg);
275
- }
276
-
277
- details[open] summary {
278
- color: var(--text);
279
- }
280
-
281
- .advanced-grid {
282
- display: grid;
283
- grid-template-columns: 1fr 1fr;
284
- gap: 14px;
285
- padding: 14px;
286
- background: var(--surface);
287
- border: 1px solid var(--border);
288
- border-radius: var(--radius-lg);
289
- }
290
-
291
- .field-group {
292
- display: flex;
293
- flex-direction: column;
294
- gap: 6px;
295
- }
296
-
297
- .field-group.full-width {
298
- grid-column: 1 / -1;
299
- }
300
-
301
- .field-label {
302
- font-size: 11px;
303
- font-weight: 500;
304
- color: var(--text-muted);
305
- letter-spacing: 0.05em;
306
- text-transform: uppercase;
307
- }
308
-
309
- .field-row {
310
- display: flex;
311
- align-items: center;
312
- gap: 10px;
313
- }
314
-
315
- input[type="range"] {
316
- flex: 1;
317
- -webkit-appearance: none;
318
- height: 4px;
319
- background: var(--surface-3);
320
- border-radius: 2px;
321
- outline: none;
322
- border: none;
323
- }
324
-
325
- input[type="range"]::-webkit-slider-thumb {
326
- -webkit-appearance: none;
327
- width: 14px;
328
- height: 14px;
329
- background: var(--accent);
330
- border-radius: 50%;
331
- cursor: pointer;
332
- box-shadow: 0 0 8px var(--accent-glow);
333
- }
334
-
335
- .range-value {
336
- font-size: 12px;
337
- color: var(--text);
338
- min-width: 36px;
339
- text-align: right;
340
- font-variant-numeric: tabular-nums;
341
- }
342
-
343
- input[type="number"] {
344
- width: 100%;
345
- }
346
-
347
- input[type="text"] {
348
- width: 100%;
349
- }
350
-
351
- /* ── Output section ───────────────────────────────────────────────────── */
352
- .output-section {
353
- margin-bottom: 32px;
354
- }
355
-
356
  .output-card {
357
- background: var(--surface);
358
- border: 1px solid var(--border);
359
- border-radius: var(--radius-lg);
360
- padding: 20px;
361
- animation: fade-in 0.3s ease;
362
- }
363
-
364
- @keyframes fade-in {
365
- from { opacity: 0; transform: translateY(8px); }
366
- to { opacity: 1; transform: translateY(0); }
367
  }
 
368
 
369
  #waveform {
370
- background: var(--surface-2);
371
- border-radius: var(--radius);
372
- overflow: hidden;
373
- cursor: pointer;
374
- margin-bottom: 14px;
375
  }
376
-
377
- .playback-controls {
378
- display: flex;
379
- align-items: center;
380
- gap: 14px;
381
  }
382
-
383
  .play-btn {
384
- width: 36px;
385
- height: 36px;
386
- background: var(--accent);
387
- border: none;
388
- border-radius: 50%;
389
- color: #fff;
390
- font-size: 14px;
391
- cursor: pointer;
392
- display: flex;
393
- align-items: center;
394
- justify-content: center;
395
- flex-shrink: 0;
396
- transition: box-shadow 0.15s, transform 0.1s;
397
- box-shadow: 0 0 14px var(--accent-glow);
 
 
 
 
 
 
 
 
398
  }
 
399
 
400
- .play-btn:hover { box-shadow: 0 0 22px rgba(124, 106, 255, 0.4); }
401
- .play-btn:active { transform: scale(0.95); }
402
-
403
- .time-display {
404
- font-size: 12px;
405
- color: var(--text-muted);
406
- font-variant-numeric: tabular-nums;
407
- flex: 1;
408
  }
 
 
409
 
410
- .download-btn {
411
- display: inline-flex;
412
- align-items: center;
413
- gap: 6px;
414
- background: transparent;
415
- border: 1px solid var(--border);
416
- border-radius: 6px;
417
- color: var(--text-muted);
418
- text-decoration: none;
419
- font-family: var(--font);
420
- font-size: 12px;
421
- font-weight: 500;
422
- padding: 6px 12px;
423
- transition: color 0.15s, border-color 0.15s;
424
  }
 
 
 
 
 
 
425
 
426
- .download-btn:hover {
427
- color: var(--text);
428
- border-color: rgba(255,255,255,0.2);
 
 
429
  }
 
 
430
 
431
- /* ── Status bar ───────────────────────────────────────────────────────── */
432
- .status-bar {
433
- font-size: 12px;
434
- color: var(--text-muted);
435
- text-align: center;
436
- padding: 8px 0;
437
- min-height: 30px;
438
- transition: color 0.2s;
439
  }
440
-
441
- .status-bar.error {
442
- color: var(--error);
443
  }
444
-
445
- .status-bar.success {
446
- color: var(--success);
447
  }
448
 
449
- /* ── Footer ───────────────────────────────────────────────────────────── */
450
- footer {
451
- margin-top: auto;
452
- padding: 20px 0;
453
- text-align: center;
454
- font-size: 11px;
455
- color: var(--text-faint);
456
  }
457
-
458
- footer a {
459
- color: var(--text-muted);
460
- text-decoration: none;
461
  }
462
-
463
- footer a:hover { color: var(--text); }
464
 
465
  /* ── Spinner ──────────────────────────────────────────────────────────── */
466
  .spinner {
467
- display: inline-block;
468
- width: 12px;
469
- height: 12px;
470
- border: 2px solid rgba(255,255,255,0.15);
471
- border-top-color: #fff;
472
- border-radius: 50%;
473
- animation: spin 0.7s linear infinite;
474
- vertical-align: middle;
475
  }
 
476
 
477
- @keyframes spin {
478
- to { transform: rotate(360deg); }
479
- }
 
480
 
481
  /* ── Mobile ───────────────────────────────────────────────────────────── */
482
- @media (max-width: 540px) {
483
- .advanced-grid { grid-template-columns: 1fr; }
484
- .field-group.full-width { grid-column: 1; }
485
- header { padding: 24px 0 20px; }
 
 
 
 
 
486
  }
487
  </style>
488
  </head>
489
  <body>
490
 
491
- <div class="container">
492
 
493
- <!-- Header -->
494
- <header>
495
- <div class="wordmark">
496
- <div class="wordmark-icon">β™ͺ</div>
497
- <div class="wordmark-text">ACE<span>-Step</span> Studio</div>
498
- </div>
499
- <p>Turbo music generation Β· 8 steps Β· ~2s for 60s of audio</p>
500
- </header>
501
-
502
- <!-- Style / Tags card -->
503
- <div class="card">
504
- <div class="card-header">
505
- <span class="card-label">Style &amp; Tags</span>
506
- <button class="inspire-btn" id="inspire-btn">✨ Inspire me</button>
507
- </div>
508
- <textarea
509
- id="prompt-input"
510
- placeholder="lo-fi, chill, jazzy piano, female vocals, rain ambiance..."
511
- ></textarea>
512
- </div>
513
-
514
- <!-- Lyrics card -->
515
- <div class="card">
516
- <div class="card-header">
517
- <span class="card-label">Lyrics</span>
518
- </div>
519
- <textarea
520
- id="lyrics-input"
521
- placeholder="[verse]&#10;Write your verse lyrics here...&#10;&#10;[chorus]&#10;Your chorus goes here...&#10;&#10;[bridge]&#10;Optional bridge..."
522
- ></textarea>
523
- <p class="lyrics-hint">
524
- Use <code>[verse]</code> <code>[chorus]</code> <code>[bridge]</code> section markers
525
- </p>
526
- </div>
527
 
528
- <!-- Generate button -->
529
- <button class="generate-btn" id="generate-btn">Generate</button>
530
-
531
- <!-- Status bar -->
532
- <div class="status-bar" id="status-bar"></div>
533
-
534
- <!-- Advanced options -->
535
- <details id="advanced-details">
536
- <summary>Advanced Options</summary>
537
- <div class="advanced-grid">
538
-
539
- <div class="field-group">
540
- <span class="field-label">Duration (seconds)</span>
541
- <div class="field-row">
542
- <input type="range" id="duration-slider" min="10" max="240" step="5" value="60" />
543
- <span class="range-value" id="duration-value">60s</span>
544
- </div>
545
  </div>
546
-
547
- <div class="field-group">
548
- <span class="field-label">Seed (βˆ’1 = random)</span>
549
- <input type="number" id="seed-input" value="-1" min="-1" max="2147483647" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
  </div>
551
-
552
- <div class="field-group full-width">
553
- <span class="field-label">LoRA (HF repo ID or leave blank)</span>
554
- <input type="text" id="lora-path-input" placeholder="e.g. ACE-Step/ACE-Step-v1-chinese-rap-LoRA" />
555
- </div>
556
-
557
- <div class="field-group">
558
- <span class="field-label">LoRA Weight</span>
559
- <div class="field-row">
560
- <input type="range" id="lora-weight-slider" min="0" max="2" step="0.05" value="0.8" />
561
- <span class="range-value" id="lora-weight-value">0.8</span>
562
- </div>
563
- </div>
564
-
565
  </div>
566
- </details>
567
-
568
- <!-- Output panel (hidden until generation completes) -->
569
- <div class="output-section" id="output-section" style="display:none">
570
- <div class="output-card">
571
- <div id="waveform"></div>
572
- <div class="playback-controls">
573
- <button class="play-btn" id="play-btn" title="Play / Pause">β–Ά</button>
574
- <span class="time-display" id="time-display">0:00 / 0:00</span>
575
- <a class="download-btn" id="download-btn" download="ace-step-output.wav">
576
- ⬇ Download
577
- </a>
 
 
 
 
 
 
578
  </div>
579
  </div>
 
 
 
 
 
 
 
580
  </div>
581
 
582
- </div><!-- /.container -->
583
 
584
  <footer>
585
- <div class="container">
586
- Powered by <a href="https://github.com/ace-step/ACE-Step" target="_blank">ACE-Step</a>
587
- Β·
588
- <a href="https://huggingface.co/ACE-Step/acestep-v15-xl-turbo" target="_blank">Model card</a>
589
- </div>
590
  </footer>
591
 
592
  <script type="module">
593
- // ── ZeroGPU auth handshake ─────────────────────────────────────────────────
594
- // Without this, requests in the HF iframe are treated as anonymous (2 min GPU
595
- // quota instead of 25 min for logged-in users), causing generation to fail.
596
  const ZEROGPU_HEADERS_MSG = "supports-zerogpu-headers";
597
- window.addEventListener("message", (event) => {
598
- if (event.data === ZEROGPU_HEADERS_MSG) {
599
- window.supports_zerogpu_headers = true;
600
- }
601
- });
602
  const _hn = window.location.hostname;
603
  if (_hn.endsWith(".hf.space") || _hn.includes(".dev.")) {
604
- const _origin = _hn.includes(".dev.")
605
- ? `https://moon-${_hn.split(".")[1]}.dev.spaces.huggingface.tech`
606
- : "https://huggingface.co";
607
- window.parent.postMessage(ZEROGPU_HEADERS_MSG, _origin);
608
  }
609
- // ──────────────────────────────────────────────────────────────────────────
610
 
611
  import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@1.15.2/dist/index.min.js";
612
- import WaveSurfer from "https://cdn.jsdelivr.net/npm/wavesurfer.js@7.10.0/dist/wavesurfer.esm.js";
613
 
614
- // ── DOM refs ─────────────────────────────────────────────────────────────
615
- const promptInput = document.getElementById("prompt-input");
616
- const lyricsInput = document.getElementById("lyrics-input");
 
 
617
  const generateBtn = document.getElementById("generate-btn");
618
- const inspireBtn = document.getElementById("inspire-btn");
619
  const statusBar = document.getElementById("status-bar");
620
  const outputSection = document.getElementById("output-section");
 
 
621
  const playBtn = document.getElementById("play-btn");
622
  const timeDisplay = document.getElementById("time-display");
623
  const downloadBtn = document.getElementById("download-btn");
624
- const durationSlider = document.getElementById("duration-slider");
625
- const durationValue = document.getElementById("duration-value");
626
- const loraWeightSlider = document.getElementById("lora-weight-slider");
627
- const loraWeightValue = document.getElementById("lora-weight-value");
628
- const loraPathInput = document.getElementById("lora-path-input");
629
- const seedInput = document.getElementById("seed-input");
630
-
631
- // ── Slider live-value display ────────────────────────────────────────────
632
- durationSlider.addEventListener("input", () => {
633
- durationValue.textContent = `${durationSlider.value}s`;
634
- });
635
- loraWeightSlider.addEventListener("input", () => {
636
- loraWeightValue.textContent = parseFloat(loraWeightSlider.value).toFixed(2);
637
- });
638
 
639
- // ── WaveSurfer init ──────────────────────────────────────────────────────
640
- const ws = WaveSurfer.create({
641
- container: "#waveform",
642
- waveColor: "#7c6aff",
643
- progressColor: "#4dff91",
644
- cursorColor: "rgba(255,255,255,0.15)",
645
- barWidth: 2,
646
- barGap: 1,
647
- barRadius: 2,
648
- height: 80,
649
- normalize: true,
650
- backend: "WebAudio",
651
- });
652
-
653
- ws.on("play", () => { playBtn.textContent = "⏸"; });
654
- ws.on("pause", () => { playBtn.textContent = "β–Ά"; });
655
- ws.on("finish",() => { playBtn.textContent = "β–Ά"; });
656
- ws.on("audioprocess", updateTime);
657
- ws.on("ready", updateTime);
658
-
659
- playBtn.addEventListener("click", () => ws.playPause());
660
 
 
661
  function updateTime() {
662
- const cur = ws.getCurrentTime();
663
- const dur = ws.getDuration();
664
- timeDisplay.textContent = `${fmt(cur)} / ${fmt(dur)}`;
665
- }
666
-
667
- function fmt(secs) {
668
- const m = Math.floor(secs / 60);
669
- const s = Math.floor(secs % 60).toString().padStart(2, "0");
670
- return `${m}:${s}`;
671
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
672
 
673
- // ── Gradio client (connects to THIS Space's origin) ──────────────────────
674
  let gradioClient = null;
675
-
676
  async function getClient() {
677
- if (!gradioClient) {
678
- gradioClient = await Client.connect(window.location.origin);
679
- }
680
  return gradioClient;
681
  }
682
 
683
- // ── UI state management ──────────────────────────────────────────────────
684
- function setUIState(state) {
685
- if (state === "loading") {
686
- generateBtn.disabled = true;
687
- generateBtn.textContent = "";
688
- generateBtn.innerHTML = '<span class="spinner"></span> Generating…';
689
- generateBtn.classList.add("loading");
690
- inspireBtn.disabled = true;
691
- } else {
692
- generateBtn.disabled = false;
693
- generateBtn.textContent = "Generate";
694
- generateBtn.classList.remove("loading");
695
- inspireBtn.disabled = false;
696
- }
697
  }
698
-
699
  function setStatus(msg, type = "") {
700
  statusBar.textContent = msg;
701
  statusBar.className = "status-bar" + (type ? ` ${type}` : "");
702
  }
703
 
704
- function clearStatus() {
705
- statusBar.textContent = "";
706
- statusBar.className = "status-bar";
707
- }
708
-
709
- // ── Load audio into WaveSurfer ───────────────────────────────────────────
710
- async function loadAudio(fileData) {
711
- // Gradio returns { url: "...", ... } or a plain string URL
712
- const url = typeof fileData === "string" ? fileData
713
- : (fileData?.url || fileData?.path || String(fileData));
714
-
715
- const res = await fetch(url);
716
- if (!res.ok) throw new Error(`Failed to fetch audio: ${res.status}`);
717
  const blob = await res.blob();
718
- const objectUrl = URL.createObjectURL(blob);
719
 
720
- await ws.load(objectUrl);
721
- downloadBtn.href = objectUrl;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
722
  outputSection.style.display = "block";
723
- outputSection.scrollIntoView({ behavior: "smooth", block: "nearest" });
724
- ws.play();
725
  }
726
 
727
- // ── Generate ─────────────────────────────────────────────────────────────
728
- generateBtn.addEventListener("click", async () => {
729
- const prompt = promptInput.value.trim();
730
- const lyrics = lyricsInput.value.trim();
731
 
732
- if (!prompt) {
733
- setStatus("Please enter some style tags (e.g. lo-fi, chill, piano)", "error");
734
- promptInput.focus();
735
- return;
 
 
 
736
  }
737
- if (!lyrics) {
738
- setStatus("Please write some lyrics (use [verse], [chorus] markers)", "error");
739
- lyricsInput.focus();
 
 
 
 
 
 
 
 
 
 
740
  return;
741
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
 
743
- setUIState("loading");
744
- clearStatus();
745
-
746
  try {
747
  const client = await getClient();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
 
749
- const job = client.submit("/generate", {
750
- prompt: prompt,
751
- lyrics: lyrics,
752
- audio_duration: parseFloat(durationSlider.value),
753
- infer_step: 8,
754
- guidance_scale: 0.0,
755
- seed: parseInt(seedInput.value, 10),
756
- lora_name_or_path: loraPathInput.value.trim(),
757
- lora_weight: parseFloat(loraWeightSlider.value),
758
  });
759
 
760
  for await (const msg of job) {
761
  if (msg.type === "status") {
762
- const s = msg;
763
- if (s.queue_size > 0) {
764
- const eta = s.eta != null ? ` Β· ETA ~${Math.round(s.eta)}s` : "";
765
- setStatus(`Queue position ${s.position}/${s.queue_size}${eta}`);
766
- } else if (s.stage === "generating" || s.status === "generating") {
767
- setStatus("Generating…");
768
  }
769
  } else if (msg.type === "data") {
770
- const fileData = msg.data?.[0];
771
- if (!fileData) throw new Error("No audio data returned");
772
- await loadAudio(fileData);
773
- setStatus("Done!", "success");
774
- setTimeout(clearStatus, 3000);
 
 
 
 
 
 
 
 
775
  }
776
  }
777
  } catch (err) {
778
- console.error("Generate error:", err);
779
  setStatus(`Error: ${err.message}`, "error");
780
  } finally {
781
- setUIState("idle");
 
782
  }
783
  });
784
 
785
- // ── Inspire me ───────────────────────────────────────────────────────────
786
- inspireBtn.addEventListener("click", async () => {
787
- inspireBtn.disabled = true;
788
- inspireBtn.textContent = "…";
789
-
790
- try {
791
- const client = await getClient();
792
- const result = await client.predict("/inspire", {
793
- genre_hint: promptInput.value.trim(),
794
- });
795
- const lyrics = result?.data?.[0] || "";
796
- if (lyrics.startsWith("[Error")) {
797
- setStatus(lyrics, "error");
798
- } else {
799
- lyricsInput.value = lyrics;
800
- clearStatus();
801
- }
802
- } catch (err) {
803
- setStatus(`Inspire failed: ${err.message}`, "error");
804
- } finally {
805
- inspireBtn.disabled = false;
806
- inspireBtn.textContent = "✨ Inspire me";
807
- }
808
  });
809
 
810
- // ── Initial ready state ──────────────────────────────────────────────────
811
- // Pre-connect client eagerly so first generate is faster
812
- getClient().catch(console.error);
813
  </script>
814
 
815
  </body>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>ace-step-jam</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600&display=swap" rel="stylesheet" />
9
  <style>
 
10
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
 
12
  :root {
13
+ --bg: oklch(0.13 0.006 260);
14
+ --surface: rgba(255, 255, 255, 0.04);
15
+ --surface-2: rgba(255, 255, 255, 0.06);
16
+ --surface-3: rgba(255, 255, 255, 0.09);
17
+ --border: rgba(255, 255, 255, 0.08);
18
+ --accent: oklch(0.90 0.005 260);
19
+ --accent-dim: oklch(0.40 0.005 260);
20
+ --accent-glow: rgba(255, 255, 255, 0.08);
21
+ --progress: oklch(0.72 0.14 155);
22
+ --text: rgba(255, 255, 255, 0.87);
23
+ --text-muted: rgba(255, 255, 255, 0.40);
24
+ --text-faint: rgba(255, 255, 255, 0.25);
25
+ --error: oklch(0.68 0.19 22);
26
+ --success: oklch(0.72 0.14 155);
27
+ --radius: 8px;
28
+ --radius-lg: 10px;
29
+ --font: 'Hanken Grotesk', system-ui, sans-serif;
 
30
  }
31
 
32
  html, body {
33
+ height: 100%; background: var(--bg); color: var(--text);
34
+ font-family: var(--font); font-size: 13.5px; line-height: 1.5;
 
 
 
 
35
  -webkit-font-smoothing: antialiased;
36
  }
37
 
38
+ body { display: flex; flex-direction: column; min-height: 100vh; }
 
 
 
 
 
39
 
40
+ /* ── Layout ───────────────────────────────────────────────────────────── */
41
+ .page { display: flex; flex: 1; max-width: 1100px; width: 100%; margin: 0 auto; padding: 28px 20px 0; gap: 20px; align-items: flex-start; }
42
+ .col-left { flex: 1; min-width: 0; max-width: 480px; position: sticky; top: 28px; background: var(--bg); }
43
+ .col-right { flex: 1; min-width: 0; }
 
 
44
 
45
  /* ── Header ───────────────────────────────────────────────────────────── */
46
+ header { padding: 0 0 20px; text-align: left; }
47
+ .wordmark { display: inline-flex; align-items: center; gap: 8px; margin-bottom: 4px; }
48
+ .wordmark-logo {
49
+ height: 21px; width: auto; color: var(--text); flex-shrink: 0;
50
+ }
51
+ .version {
52
+ font-family: ui-monospace, 'SF Mono', monospace;
53
+ font-size: 10px; font-weight: 500; letter-spacing: 0.03em;
54
+ color: var(--text); border: 1px solid var(--border);
55
+ border-radius: 4px; padding: 1px 5px;
56
+ text-decoration: none; transition: border-color 0.15s, color 0.15s;
57
+ }
58
+ .version:hover { color: var(--text); border-color: rgba(255, 255, 255, 0.15); }
59
+ header p { font-size: 14px; color: var(--text-muted); }
60
+
61
+ /* ── Compose box ─────────────────────────────────────────────────────── */
62
+ .compose-box {
63
+ background: var(--surface); border: 1px solid var(--border);
64
+ border-radius: var(--radius-lg); margin-bottom: 10px;
65
+ transition: border-color 0.15s;
66
  }
67
+ .compose-box:focus-within { border-color: rgba(255, 255, 255, 0.14); }
68
+ .compose-box #description-input {
69
+ background: transparent; border: none; border-radius: var(--radius-lg) var(--radius-lg) 0 0;
70
+ padding: 14px 14px 6px; height: 80px;
 
 
 
 
 
 
 
71
  }
72
+ .compose-box #description-input:focus,
73
+ .compose-box #description-input:focus-visible { border: none; outline: none; }
74
+ .compose-controls {
75
+ display: flex; gap: 4px; padding: 4px 10px 10px;
76
+ align-items: center; flex-wrap: wrap;
 
77
  }
78
+ .compose-controls .pill {
79
+ background: transparent; border: none; padding: 4px 8px;
80
+ font-size: 11px; border-radius: 6px;
81
  }
82
+ .compose-controls .pill:hover { background: var(--surface-2); }
83
+ .compose-controls .pill select { font-size: 11px; min-width: 44px; background: transparent; border: none; }
84
+ .compose-controls .pill input[type="checkbox"] { width: 11px; height: 11px; }
85
+ .compose-controls .seed-input {
86
+ width: 48px !important; font-size: 11px !important; padding: 3px 6px !important;
87
+ background: transparent !important; border-radius: 6px !important;
88
  }
89
+ .compose-controls .generate-btn {
90
+ width: auto; padding: 5px 14px; font-size: 12px;
91
+ border-radius: 6px; letter-spacing: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  }
93
 
94
  /* ── Inputs ───────────────────────────────────────────────────────────── */
95
+ textarea, input[type="text"], input[type="number"], select {
96
+ width: 100%; background: var(--surface-2); border: 1px solid var(--border);
97
+ border-radius: var(--radius); color: var(--text); font-family: var(--font);
98
+ font-size: 13.5px; padding: 9px 11px; outline: none; transition: border-color 0.15s;
99
+ }
100
+ textarea:focus, input:focus, select:focus { border-color: rgba(255, 255, 255, 0.18); }
101
+ textarea:focus-visible, input:focus-visible, select:focus-visible { outline: 1px solid rgba(255, 255, 255, 0.08); outline-offset: 1px; }
102
+ textarea::placeholder, input::placeholder { color: var(--text-faint); }
103
+ textarea { resize: none; }
104
+
105
+ #description-input { height: 76px; font-size: 14px; }
106
+ .prompt-hint { margin-top: 6px; font-size: 11px; color: var(--text-faint); }
107
+
108
+ /* ── Controls row ─────────────────────────────────────────────────────── */
109
+ .controls-row { display: flex; gap: 6px; margin-bottom: 10px; align-items: center; flex-wrap: wrap; }
110
+
111
+ .pill {
112
+ display: inline-flex; align-items: center; gap: 5px;
113
+ font-size: 12px; color: var(--text-muted); cursor: pointer; user-select: none;
114
+ background: var(--surface); border: 1px solid var(--border);
115
+ border-radius: var(--radius); padding: 6px 10px; white-space: nowrap;
116
  transition: border-color 0.15s;
117
  }
118
+ .pill:hover { border-color: rgba(255, 255, 255, 0.12); }
119
+ .pill input[type="checkbox"] { width: 12px; height: 12px; accent-color: var(--accent); cursor: pointer; }
120
+ .pill:has(input:checked) span { color: var(--text); }
121
+ .pill select { background: transparent; border: none; color: var(--text); font-size: 12px; padding: 0; min-width: 56px; }
122
 
123
+ .seed-input { width: 72px !important; font-size: 12px !important; padding: 6px 8px !important; }
124
+ .spacer { flex: 1; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
  /* ── Generate button ──────────────────────────────────────────────────── */
127
  .generate-btn {
128
+ width: 100%; background: var(--accent); border: none;
129
+ border-radius: var(--radius); color: oklch(0.13 0.006 260); font-family: var(--font);
130
+ font-size: 14px; font-weight: 600; padding: 11px; cursor: pointer;
131
+ transition: opacity 0.15s, transform 0.1s; letter-spacing: -0.01em;
132
+ }
133
+ .generate-btn:hover:not(:disabled) { opacity: 0.88; }
134
+ .generate-btn:active:not(:disabled) { transform: scale(0.99); }
135
+ .generate-btn:disabled { background: var(--accent-dim); cursor: not-allowed; opacity: 0.5; }
136
+ .generate-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
137
+ .generate-btn.loading { animation: btn-pulse 2s ease-in-out infinite; }
138
+ @keyframes btn-pulse {
139
+ 0%, 100% { opacity: 1; }
140
+ 50% { opacity: 0.7; }
141
+ }
142
+
143
+ /* ── Status ───────────────────────────────────────────────────────────── */
144
+ .status-bar { font-size: 11.5px; color: var(--text-muted); text-align: center; padding: 0; min-height: 0; display: none; }
145
+ .status-bar.error { display: block; color: var(--error); padding: 6px 0; }
146
+ .status-bar.success { display: none; }
147
+
148
+ /* ── Output card ──────────────────────────────────────────────────────── */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  .output-card {
150
+ background: var(--surface); border: 1px solid var(--border);
151
+ border-radius: var(--radius-lg); padding: 14px; margin-bottom: 10px;
152
+ animation: fade-in 0.25s ease;
 
 
 
 
 
 
 
153
  }
154
+ @keyframes fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
155
 
156
  #waveform {
157
+ display: flex; align-items: end; gap: 1px; height: 56px;
158
+ background: var(--surface-2); border-radius: var(--radius);
159
+ padding: 8px 10px; cursor: pointer; margin-bottom: 10px;
 
 
160
  }
161
+ #waveform .bar {
162
+ flex: 1; background: var(--text-faint); border-radius: 1px;
163
+ transition: background 0.15s;
 
 
164
  }
165
+ .playback-controls { display: flex; align-items: center; gap: 10px; }
166
  .play-btn {
167
+ width: 30px; height: 30px; background: var(--accent); border: none;
168
+ border-radius: 50%; color: oklch(0.13 0.006 260); font-size: 12px; cursor: pointer;
169
+ display: flex; align-items: center; justify-content: center; flex-shrink: 0;
170
+ transition: opacity 0.15s;
171
+ }
172
+ .play-btn:hover { opacity: 0.85; }
173
+ .play-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
174
+ .time-display { font-size: 11.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex: 1; }
175
+ .download-btn {
176
+ display: inline-flex; align-items: center; gap: 4px; background: transparent;
177
+ border: 1px solid var(--border); border-radius: 6px; color: var(--text-muted);
178
+ text-decoration: none; font-family: var(--font); font-size: 11px;
179
+ font-weight: 500; padding: 4px 9px; transition: border-color 0.15s, color 0.15s;
180
+ }
181
+ .download-btn:hover { color: var(--text); border-color: rgba(255, 255, 255, 0.15); }
182
+ .download-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
183
+
184
+ /* ── Community feed ───────────────────────────────────────────────────── */
185
+ .feed-header {
186
+ font-size: 11px; font-weight: 600; color: var(--text-muted);
187
+ text-transform: uppercase; letter-spacing: 0.06em;
188
+ margin-bottom: 10px;
189
  }
190
+ .feed-empty { font-size: 12px; color: var(--text-faint); text-align: center; padding: 32px 0; }
191
 
192
+ .song-card {
193
+ background: transparent; border: 1px solid transparent;
194
+ border-radius: var(--radius); padding: 8px 10px; margin-bottom: 6px;
195
+ cursor: pointer; transition: background 0.15s;
 
 
 
 
196
  }
197
+ .song-card:hover { background: var(--surface); }
198
+ .song-card.playing { background: var(--surface); }
199
 
200
+ .song-card-top { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
201
+ .song-play-btn {
202
+ width: 26px; height: 26px; background: var(--surface-3); border: 1px solid var(--border);
203
+ border-radius: 50%; color: var(--text-muted); font-size: 10px; cursor: pointer;
204
+ display: flex; align-items: center; justify-content: center; flex-shrink: 0;
205
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
 
 
 
 
 
 
 
 
206
  }
207
+ .song-play-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
208
+ .song-card:hover .song-play-btn { background: rgba(255, 255, 255, 0.08); color: var(--text); border-color: rgba(255, 255, 255, 0.12); }
209
+ .song-card.playing .song-play-btn { background: var(--accent); color: oklch(0.13 0.006 260); border-color: var(--accent); }
210
+ .song-title { font-size: 13px; font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
211
+ .song-tags { font-size: 11px; color: var(--text-muted); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
212
+ .song-duration { font-size: 11px; color: var(--text-muted); flex-shrink: 0; font-variant-numeric: tabular-nums; margin-left: 8px; }
213
 
214
+ /* ── Placeholder (loading state) ──────────────────────────────────────── */
215
+ .placeholder-card {
216
+ background: var(--surface); border: 1px solid rgba(255, 255, 255, 0.12);
217
+ border-radius: var(--radius-lg); padding: 14px;
218
+ margin-bottom: 10px; animation: fade-in 0.25s ease;
219
  }
220
+ .placeholder-title { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 3px; }
221
+ .placeholder-sub { font-size: 11px; color: var(--text-muted); margin-bottom: 10px; }
222
 
223
+ .fake-waveform {
224
+ display: flex; align-items: end; gap: 2px; height: 44px;
225
+ background: var(--surface-2); border-radius: var(--radius); padding: 8px 10px;
 
 
 
 
 
226
  }
227
+ .fake-waveform .bar {
228
+ flex: 1; background: var(--accent); border-radius: 1px; opacity: 0.3;
229
+ animation: wave-pulse 1.2s ease-in-out infinite;
230
  }
231
+ @keyframes wave-pulse {
232
+ 0%, 100% { transform: scaleY(0.3); opacity: 0.15; }
233
+ 50% { transform: scaleY(1); opacity: 0.4; }
234
  }
235
 
236
+ /* ── Mini waveform ────────────────────────────────────────────────────── */
237
+ .mini-waveform {
238
+ display: flex; align-items: end; gap: 1px; height: 22px; margin-top: 5px;
239
+ cursor: pointer;
 
 
 
240
  }
241
+ .mini-waveform .bar {
242
+ flex: 1; background: var(--text-faint); border-radius: 1px;
243
+ transition: background 0.2s;
 
244
  }
245
+ .song-card.playing .mini-waveform .bar { background: var(--accent); }
246
+ .song-card:hover .mini-waveform .bar { background: rgba(255, 255, 255, 0.22); }
247
 
248
  /* ── Spinner ──────────────────────────────────────────────────────────── */
249
  .spinner {
250
+ display: inline-block; width: 12px; height: 12px;
251
+ border: 2px solid rgba(0, 0, 0, 0.12); border-top-color: oklch(0.13 0.006 260);
252
+ border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: middle;
 
 
 
 
 
253
  }
254
+ @keyframes spin { to { transform: rotate(360deg); } }
255
 
256
+ /* ── Footer ───────────────────────────────────────────────────────────── */
257
+ footer { padding: 20px; text-align: center; font-size: 11px; color: var(--text-faint); }
258
+ footer a { color: var(--text-muted); text-decoration: none; }
259
+ footer a:hover { color: var(--text); }
260
 
261
  /* ── Mobile ───────────────────────────────────────────────────────────── */
262
+ .share-label-short { display: none; }
263
+ @media (max-width: 800px) {
264
+ .page { flex-direction: column; padding: 20px 16px 0; gap: 16px; }
265
+ .col-left, .col-right { max-width: none; width: 100%; }
266
+ .song-card-top .song-tags { display: none; }
267
+ .controls-row { gap: 5px; }
268
+ .seed-input { width: 60px !important; }
269
+ .share-label-full { display: none; }
270
+ .share-label-short { display: inline; }
271
  }
272
  </style>
273
  </head>
274
  <body>
275
 
276
+ <div class="page">
277
 
278
+ <!-- ════ LEFT: Create ════ -->
279
+ <div class="col-left">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
281
+ <header>
282
+ <div class="wordmark">
283
+ <svg class="wordmark-logo" viewBox="0 0 1357 390" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M429.554 121.056c25.685-2.701 56.59 8.361 65.05 35.015 6.4 20.237-1.839 45.271-23.479 51.909-10.535 3.243-26.295 1.729-29.56-10.892-1.465-7.657 4.81-15.286 7.43-22.246 2.915-7.759 2.54-20.084-5.83-24.234-12.865-6.234-25.751 10.698-30.446 20.741-11.515 24.623-18.194 66.318-9.654 92.045 15.96 48.099 79.485 21.441 93.715-13.469 1.85-4.537.905-13.069 1.885-18.054 4.24-45.958 30.43-90.954 75.74-106.514 34.455-11.835 78.87-.084 91.26 37.146 4.43 13.542 2.515 29.945-3.84 42.61-14.065 26.676-46.055 36.676-72.945 45.039-6.48 2.225-18.736 5.131-24.076 7.796q.107 1.334.326 2.656c5.275 31.502 38.935 36.362 64.115 25.899 23.91-9.935 38.415-24.933 53.5-45.365 5.055-5.088 11.455-3.266 11.695 4.253.285 5.284-2.34 10.701-4.915 15.32-17.715 31.762-47.551 61.527-83.131 71.447-37.425 10.435-79.034-3.97-97.779-38.678-1.09-2.018-3.645-8.147-5.085-9.36-3.25 2.494-10.835 11.736-14.61 15.398-18.56 17.995-38.876 31.53-64.996 34.715a81.2 81.2 0 0 1-60.209-16.81c-17.921-14.293-27.46-36.077-29.707-58.502-6.195-61.85 28.272-131.158 95.546-137.865m181.456 70.616c5.125-13.489 7.57-43.481-14.165-42.209-24.88 6.91-34.32 59.526-33.31 81.763 5.11-1.34 8.85-2.705 13.775-4.704 15.59-7.135 27.52-18.594 33.7-34.85"/><path fill="currentColor" d="M252.566 31.328c13.489.15 28.638-1.107 40.747 5.829 14.378 8.235 13.358 26.043 14.32 40.355l2.323 33.361 7.744 101.628c1.854 26.297 3.277 52.889 7.327 78.938 1.466 9.43 5.535 20.237 13.799 25.7 2.658 1.75 6.034 4.031 6.681 7.386 2.157 11.18-15.813 8.85-23.014 8.805l-27.869-.166q-14.718-.014-29.435.166c-5.378.055-19.803 1.139-23.314-1.986-6.732-6 9.234-15.974 10.313-24.989 2.273-19.001.231-40.745-1.328-59.509-7.726-2.792-16.639-4.369-24.684-6.138-13.149-2.89-26.221-5.506-39.576-7.317-26.129 49.575-50.203 122.044-116.567 125.219-30.024-.73-51.661-17.32-43.553-49.833 2.48-9.949 16.708-26.894 26.499-15.203 3.194 3.815.678 17.184 3.565 22.031 5.484 10.919 20.75 10.089 29.943 5.274 26.071-13.664 41.425-42.9 54.985-67.806 3.709-6.846 8.069-15.671 10.602-22.698q-2.555-.012-5.109.052c-19.691.564-40.684 9.808-55.07 23.351-4.01 3.776-11.3 15.404-16.445 15.651-6.447.31-8.223-6.662-8.054-12.163.398-13.022 6.724-26.658 16.043-35.764 23.204-21.692 54.274-24.541 84.477-24.073 13.373-27.212 25.61-57.864 37.877-85.805 6.466-14.728 13.866-31.519 18.076-47.027 1.006-3.702.114-6.804-.832-10.394-4.37-4.675-25.115-13.02-5.264-19.847 6.059-2.084 17.892-2.365 24.562-2.782 3.149-.28 7.046-.282 10.231-.246m-11.933 74.358c-2.056 2.836-4.866 10.582-6.357 14.227l-12.001 29.498c-7.084 17.143-15.606 35.261-22.304 52.253 4.745.982 9.285 1.633 14.067 2.339 10.719 1.694 23.779 4.18 34.381 4.855-.456-12.059-4.748-99.339-7.786-103.172M751.392 71.823c-.34-9.52 19.635-7.66 25.921-7.67l26.78-.04c12.231-.03 25.492-.374 37.717.53 1.879.316 5.482 1.365 6.766 2.88 6.272 7.415-6.264 12.926-9.123 19.746-2.577 6.15-3.914 8.925-4.523 15.455-1.488 15.925-1.144 31.855-1.169 47.79l-.324 68.059c-.121 34.375 1.189 67.59-24.984 94.135-36.711 37.23-117.356 35.785-141.56-15.145-4.124-8.675-4.715-18.09-4.655-27.495a47.1 47.1 0 0 1 10.969-26.95c12.532-14.62 37.444-18.11 52.064-4.965a27.12 27.12 0 0 1 8.988 19.43c.401 14.01-6.404 17.145-14.45 25.775-12.126 13.465 3.685 27.82 18.108 25.8 34.753-4.875 30.803-53.81 30.832-79.675l.022-51.27-.083-45.14c-.062-14.645 1.538-35.665-6.982-47.905-2.659-3.745-10.142-8.515-10.314-13.345m195.843-9.245c12.179.095 24.785 2.39 33.048 12.225 8.54 10.165 15.095 43.36 18.885 57.315a4784 4784 0 0 0 33.182 114.901c4.95 16.344 9.74 35.189 16.79 50.634 2.01 4.405 6.36 8.5 10.52 11.01 10.66-9.845 9.61-34.86 9.84-48.38.27-16.265.11-32.69.37-48.98.38-21.295.57-42.59.58-63.89.14-16.01 1.85-34.3-1.62-50.005-1.39-6.29-3.63-11.715-8.4-16.22-1.79-1.68-3.86-3.26-5.09-5.405-.93-1.605-1.16-3.165-.56-4.955.73-2.195 2.16-3.65 4.24-4.62 2.31-1.075 4.81-1.395 7.32-1.61 9.48-.82 64.09-1.63 70.82 1.13 3.59 1.476 7.11 3.465 9.04 6.985 4.07 7.45 7.19 22.705 9.93 31.675 6.89 22.14 13.89 44.245 21.01 66.305 1.7 5.266 9.36 32.903 12.28 34.155 3.93-2.421 13.66-31.838 16.01-38.289l22.7-63.086c2.97-8.45 8.02-25.404 12.63-32.664 6.54-10.29 38.43-5.77 50.05-6.66 7.74-.595 17.31.319 25.1.439 1.84.03 5.8 1.49 7.28 2.775 6.32 6.505-8.06 15.936-10.21 24.03-4.25 16.02-4 33.681-4.07 50.001-.21 46.014-.95 91.855.43 137.839.23 7.98.77 17.025 3.85 24.4 1.8 5.35 11.31 10.78 11.29 16.415-.04 8.814-19.17 6.56-23.92 6.53l-24.93-.05c-11.57.01-23.48.55-34.81.225-13.23-.37-10.68-8.34-3.9-15.13 6.19-6.2 7.38-12.235 8.82-20.59 1.49-12.225 1.48-27.015 1.52-39.31l.08-43.34c.01-4.711.82-35.563-1.29-36.875-3.11 1.92-5.99 12.945-7.48 16.735-2.81 7.115-5.56 14.42-8.26 21.59l-28.89 77.345c-3.79 10.19-7.2 20.715-11.81 30.535-4.6 9.945-21.68 7.62-26.25-.93-6.24-11.665-10.05-27.12-14.24-39.845l-25.75-78.195c-1.55-4.689-8.26-28.116-11.64-28.965-1.32 1.15-1.96 4.495-1.99 6.17-.75 34.63-.79 69.4-.67 104.045.03 11.2 1.09 22.405 9.76 30.435 1.59 1.63 5.91 5.895 6.31 8.04 2.11 11.404-22.14 8.28-29.52 8.205l-35.05-.27-61.452-.04c-7.77.02-15.615.57-23.372.37-2.238-.06-6.194.01-8.107-1.214-1.381-.885-2.384-2.471-2.7-4.071-.959-4.86 5.28-9.225 7.673-12.98 6.088-9.545.193-28.165-2.33-38.38-.971-3.935-2.246-9.53-6.087-11.63-6.617-3.62-51.072-4.499-59.105-2.185-2.513.725-3.717 2.395-4.872 4.63-4.31 8.35-10.534 31.55-9.323 40.77.252 1.92 1.094 3.74 2.121 5.365 2.362 3.735 7.774 6.501 8.939 10.775.49 1.8.036 3.635-.991 5.16-1.309 1.939-2.962 2.64-5.191 3.11-7.781 1.635-27.562.18-36.554.245-6.542.045-13.637 1.1-20.092.515-2.462-.225-5.183-.915-7.202-2.4-1.51-1.11-2.693-2.71-2.759-4.645-.174-5.1 6.625-9.225 9.585-12.93 3.854-4.82 6.477-10.545 8.774-16.23 13.148-32.555 23.034-66.76 34.557-99.94l20.711-60.995c3.423-10.25 7.955-21.985 9.888-32.605 3.471-8.28-9.124-18.955-9.128-22.945-.009-9.225 38.877-11.295 43.682-11.575m-9.769 79.41c-2.886 3.83-24.901 73.496-24.944 78.31 3.007 2.44 15.374 1.64 19.785 1.575 4.813 0 21.568 1.005 24.674-1.604.802-.676.618-3.005.565-3.965-.305-5.51-15.823-71.054-17.593-72.785-.732-.715-1.577-1.106-2.487-1.531"/></svg>
284
+ <a href="https://huggingface.co/ACE-Step/acestep-v15-xl-turbo" target="_blank" class="version">ACE-Step v1.5</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  </div>
286
+ <p>Describe any song. AI writes &amp; produces it.</p>
287
+ </header>
288
+
289
+ <div class="compose-box">
290
+ <textarea id="description-input"
291
+ placeholder="A chill lo-fi track for studying, ninja battle anthem, love song about AI..."
292
+ ></textarea>
293
+ <div class="compose-controls">
294
+ <label class="pill">
295
+ <select id="duration-select">
296
+ <option value="30">30s</option>
297
+ <option value="60" selected>1 min</option>
298
+ <option value="120">2 min</option>
299
+ <option value="180">3 min</option>
300
+ </select>
301
+ </label>
302
+ <label class="pill"><input type="checkbox" id="instrumental-check" /><span>Instrumental</span></label>
303
+ <label class="pill"><input type="checkbox" id="community-check" checked /><span class="share-label-full">Share in community</span><span class="share-label-short">Share</span></label>
304
+ <span class="spacer"></span>
305
+ <button class="generate-btn" id="generate-btn">Generate</button>
306
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  </div>
308
+ <div class="status-bar" id="status-bar"></div>
309
+
310
+ <!-- Output -->
311
+ <div id="output-section" style="display:none">
312
+ <div class="output-card">
313
+ <div style="display:flex;gap:10px;margin-bottom:8px;">
314
+ <img id="song-thumb" style="width:48px;height:48px;border-radius:6px;object-fit:cover;display:none;" />
315
+ <div style="min-width:0;flex:1;">
316
+ <div id="song-title" style="font-size:13px;font-weight:600;margin-bottom:3px;"></div>
317
+ <div id="song-tags" style="font-size:11px;color:var(--text-muted);"></div>
318
+ </div>
319
+ </div>
320
+ <div id="waveform"></div>
321
+ <div class="playback-controls">
322
+ <button class="play-btn" id="play-btn" title="Play / Pause">β–Ά</button>
323
+ <span class="time-display" id="time-display">0:00 / 0:00</span>
324
+ <a class="download-btn" id="download-btn" download="output.wav">⬇ Download</a>
325
+ </div>
326
  </div>
327
  </div>
328
+
329
+ </div>
330
+
331
+ <!-- ════ RIGHT: Community feed ════ -->
332
+ <div class="col-right">
333
+ <div class="feed-header">Community Songs</div>
334
+ <div id="feed"></div>
335
  </div>
336
 
337
+ </div>
338
 
339
  <footer>
340
+ Powered by <a href="https://github.com/ace-step/ACE-Step" target="_blank">ACE-Step</a> Β·
341
+ <a href="https://huggingface.co/ACE-Step/acestep-v15-xl-turbo" target="_blank">Model card</a>
 
 
 
342
  </footer>
343
 
344
  <script type="module">
345
+ // ── ZeroGPU auth ──────────────────────────────────────────────────────────
 
 
346
  const ZEROGPU_HEADERS_MSG = "supports-zerogpu-headers";
347
+ window.addEventListener("message", (e) => { if (e.data === ZEROGPU_HEADERS_MSG) window.supports_zerogpu_headers = true; });
 
 
 
 
348
  const _hn = window.location.hostname;
349
  if (_hn.endsWith(".hf.space") || _hn.includes(".dev.")) {
350
+ const _o = _hn.includes(".dev.") ? `https://moon-${_hn.split(".")[1]}.dev.spaces.huggingface.tech` : "https://huggingface.co";
351
+ window.parent.postMessage(ZEROGPU_HEADERS_MSG, _o);
 
 
352
  }
 
353
 
354
  import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@1.15.2/dist/index.min.js";
355
+ // WaveSurfer removed β€” using lightweight bar waveform with real audio peaks
356
 
357
+ // ── DOM ──────────────────────────────────────────────────────────────────
358
+ const descInput = document.getElementById("description-input");
359
+ const durationSelect = document.getElementById("duration-select");
360
+ const instrumentalChk = document.getElementById("instrumental-check");
361
+ const communityChk = document.getElementById("community-check");
362
  const generateBtn = document.getElementById("generate-btn");
 
363
  const statusBar = document.getElementById("status-bar");
364
  const outputSection = document.getElementById("output-section");
365
+ const songTitleEl = document.getElementById("song-title");
366
+ const songTagsEl = document.getElementById("song-tags");
367
  const playBtn = document.getElementById("play-btn");
368
  const timeDisplay = document.getElementById("time-display");
369
  const downloadBtn = document.getElementById("download-btn");
370
+ const feedEl = document.getElementById("feed");
 
 
 
 
 
 
 
 
 
 
 
 
 
371
 
372
+ // ── Main player (plain Audio + bar waveform) ─────────────────────────────
373
+ const mainAudio = new Audio();
374
+ const waveformEl = document.getElementById("waveform");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
 
376
+ function fmt(s) { return `${Math.floor(s/60)}:${Math.floor(s%60).toString().padStart(2,"0")}`; }
377
  function updateTime() {
378
+ const c = mainAudio.currentTime || 0, d = mainAudio.duration || 0;
379
+ timeDisplay.textContent = `${fmt(c)} / ${fmt(d)}`;
380
+ }
381
+ function updateMainProgress() {
382
+ if (mainAudio.paused && mainAudio.currentTime === 0) return;
383
+ const pct = mainAudio.duration ? mainAudio.currentTime / mainAudio.duration : 0;
384
+ waveformEl.querySelectorAll(".bar").forEach((b, i, arr) => {
385
+ b.style.background = (i / arr.length) <= pct ? "var(--accent)" : "";
386
+ });
387
+ updateTime();
388
+ if (!mainAudio.paused) requestAnimationFrame(updateMainProgress);
389
+ }
390
+ mainAudio.addEventListener("play", () => { playBtn.textContent = "⏸"; requestAnimationFrame(updateMainProgress); });
391
+ mainAudio.addEventListener("pause", () => { playBtn.textContent = "β–Ά"; });
392
+ mainAudio.addEventListener("ended", () => { playBtn.textContent = "β–Ά"; waveformEl.querySelectorAll(".bar").forEach(b => b.style.background = ""); });
393
+ mainAudio.addEventListener("timeupdate", updateTime);
394
+
395
+ playBtn.addEventListener("click", () => { stopFeedAudio(); mainAudio.paused ? mainAudio.play() : mainAudio.pause(); });
396
+
397
+ waveformEl.addEventListener("click", (e) => {
398
+ const rect = waveformEl.getBoundingClientRect();
399
+ const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
400
+ if (mainAudio.duration) {
401
+ stopFeedAudio();
402
+ mainAudio.currentTime = mainAudio.duration * ratio;
403
+ if (mainAudio.paused) mainAudio.play();
404
+ requestAnimationFrame(updateMainProgress);
405
+ }
406
+ });
407
 
408
+ // ── Gradio client ───────────────────────────────────────────────────────
409
  let gradioClient = null;
 
410
  async function getClient() {
411
+ if (!gradioClient) gradioClient = await Client.connect(window.location.origin);
 
 
412
  return gradioClient;
413
  }
414
 
415
+ // ── UI helpers ──────────────────────────────────────────────────────────
416
+ function setLoading(on) {
417
+ generateBtn.disabled = on;
418
+ if (on) { generateBtn.innerHTML = '<span class="spinner"></span> Creating…'; generateBtn.classList.add("loading"); }
419
+ else { generateBtn.textContent = "Generate"; generateBtn.classList.remove("loading"); }
 
 
 
 
 
 
 
 
 
420
  }
 
421
  function setStatus(msg, type = "") {
422
  statusBar.textContent = msg;
423
  statusBar.className = "status-bar" + (type ? ` ${type}` : "");
424
  }
425
 
426
+ async function loadAudio(dataUrl) {
427
+ const res = await fetch(dataUrl);
 
 
 
 
 
 
 
 
 
 
 
428
  const blob = await res.blob();
429
+ const url = URL.createObjectURL(blob);
430
 
431
+ // Extract real peaks for bar waveform
432
+ const numBars = 80;
433
+ try {
434
+ const buf = await blob.arrayBuffer();
435
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
436
+ const decoded = await ctx.decodeAudioData(buf);
437
+ const raw = decoded.getChannelData(0);
438
+ const block = Math.floor(raw.length / numBars);
439
+ const peaks = [];
440
+ for (let i = 0; i < numBars; i++) {
441
+ let sum = 0;
442
+ for (let j = 0; j < block; j++) sum += Math.abs(raw[i * block + j]);
443
+ peaks.push(sum / block);
444
+ }
445
+ const mx = Math.max(...peaks);
446
+ waveformEl.innerHTML = peaks.map(p => {
447
+ const h = mx > 0 ? 15 + (p / mx) * 85 : 50;
448
+ return `<div class="bar" style="height:${h}%"></div>`;
449
+ }).join("");
450
+ } catch {
451
+ waveformEl.innerHTML = Array.from({length: numBars}, () =>
452
+ `<div class="bar" style="height:${15 + Math.random() * 85}%"></div>`
453
+ ).join("");
454
+ }
455
+
456
+ mainAudio.src = url;
457
+ downloadBtn.href = url;
458
  outputSection.style.display = "block";
459
+ stopFeedAudio();
460
+ mainAudio.play();
461
  }
462
 
463
+ // ── Community feed player ───────────────────────────────────────────────
464
+ let feedAudio = null;
465
+ let playingCard = null;
 
466
 
467
+ function stopFeedAudio() {
468
+ if (feedAudio) { feedAudio.pause(); feedAudio = null; }
469
+ if (playingCard) {
470
+ playingCard.querySelectorAll(".mini-waveform .bar").forEach(b => b.style.background = "");
471
+ playingCard.classList.remove("playing");
472
+ playingCard.querySelector(".song-play-btn").textContent = "β–Ά";
473
+ playingCard = null;
474
  }
475
+ }
476
+
477
+ function playFeedSong(card, audioUrl, seekRatio) {
478
+ // If same card and already playing, just seek (or pause if clicking play btn)
479
+ if (playingCard === card && feedAudio) {
480
+ if (seekRatio != null) {
481
+ feedAudio.currentTime = feedAudio.duration * seekRatio;
482
+ if (feedAudio.paused) feedAudio.play();
483
+ } else {
484
+ // Toggle play/pause (clicked the play button)
485
+ if (feedAudio.paused) { feedAudio.play(); }
486
+ else { feedAudio.pause(); }
487
+ }
488
  return;
489
  }
490
+ stopFeedAudio();
491
+ mainAudio.pause();
492
+ feedAudio = new Audio(audioUrl);
493
+ playingCard = card;
494
+ card.classList.add("playing");
495
+ card.querySelector(".song-play-btn").textContent = "⏸";
496
+ feedAudio.play();
497
+ if (seekRatio != null) {
498
+ feedAudio.addEventListener("loadedmetadata", () => {
499
+ feedAudio.currentTime = feedAudio.duration * seekRatio;
500
+ }, { once: true });
501
+ }
502
+ // Update waveform progress during playback
503
+ const waveEl = card.querySelector(".mini-waveform");
504
+ const bars = waveEl ? waveEl.querySelectorAll(".bar") : [];
505
+ function updateProgress() {
506
+ if (playingCard !== card || !feedAudio || feedAudio.paused) return;
507
+ const pct = feedAudio.currentTime / feedAudio.duration;
508
+ bars.forEach((b, i) => {
509
+ b.style.background = (i / bars.length) <= pct ? "var(--accent)" : "var(--text-faint)";
510
+ });
511
+ requestAnimationFrame(updateProgress);
512
+ }
513
+ if (bars.length) requestAnimationFrame(updateProgress);
514
+ feedAudio.addEventListener("ended", () => {
515
+ bars.forEach(b => b.style.background = "");
516
+ stopFeedAudio();
517
+ });
518
+ feedAudio.addEventListener("error", () => stopFeedAudio());
519
+ feedAudio.addEventListener("pause", () => {
520
+ if (playingCard === card) card.querySelector(".song-play-btn").textContent = "β–Ά";
521
+ });
522
+ feedAudio.addEventListener("play", () => {
523
+ if (playingCard === card) {
524
+ card.querySelector(".song-play-btn").textContent = "⏸";
525
+ requestAnimationFrame(updateProgress);
526
+ }
527
+ });
528
+ }
529
 
530
+ // ── Load community feed ─────────────────────────────────────────────────
531
+ async function loadFeed() {
 
532
  try {
533
  const client = await getClient();
534
+ const res = await client.predict("/community");
535
+ // Gradio JS client may return {data: [...]} or the value directly
536
+ const raw = res?.data?.[0] ?? res?.data ?? res;
537
+ const songs = JSON.parse(typeof raw === "string" ? raw : JSON.stringify(raw) || "[]");
538
+ if (songs.length === 0) {
539
+ feedEl.innerHTML = '<div class="feed-empty">No community songs yet.<br>Be the first β€” check "Share" and generate!</div>';
540
+ return;
541
+ }
542
+ feedEl.innerHTML = "";
543
+ for (const song of songs) {
544
+ const card = document.createElement("div");
545
+ card.className = "song-card";
546
+ const dur = song.duration ? `${Math.round(song.duration)}s` : "";
547
+ // Generate random mini waveform bars (seeded from title for consistency)
548
+ const miniBars = Array.from({length: 80}, () =>
549
+ `<div class="bar" style="height:${15 + Math.random() * 85}%"></div>`
550
+ ).join("");
551
+ const thumbHtml = song.thumb_url
552
+ ? `<img src="${esc(song.thumb_url)}" style="width:24px;height:24px;border-radius:4px;object-fit:cover;flex-shrink:0;" loading="lazy" />`
553
+ : '';
554
+ card.innerHTML = `
555
+ <div class="song-card-top">
556
+ <button class="song-play-btn">β–Ά</button>
557
+ ${thumbHtml}
558
+ <div class="song-title">${esc(song.title || "Untitled")}</div>
559
+ <div class="song-tags">${esc(song.tags || "")}</div>
560
+ <div class="song-duration">${dur}</div>
561
+ </div>
562
+ <div class="mini-waveform">${miniBars}</div>
563
+ `;
564
+ // Play button: toggle play/pause
565
+ card.querySelector(".song-play-btn").addEventListener("click", (e) => {
566
+ e.stopPropagation();
567
+ playFeedSong(card, song.audio_url);
568
+ });
569
+ // Waveform: seek to click position
570
+ card.querySelector(".mini-waveform").addEventListener("click", (e) => {
571
+ e.stopPropagation();
572
+ const rect = e.currentTarget.getBoundingClientRect();
573
+ const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
574
+ playFeedSong(card, song.audio_url, ratio);
575
+ });
576
+ feedEl.appendChild(card);
577
+ }
578
+ } catch (e) {
579
+ console.error("Feed load error:", e);
580
+ feedEl.innerHTML = '<div class="feed-empty">Could not load community songs</div>';
581
+ }
582
+ }
583
+
584
+ function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
585
+
586
+ // ── Generate ─────────────────────────────────────────────────────────────
587
+ generateBtn.addEventListener("click", async () => {
588
+ let desc = descInput.value.trim();
589
+ if (!desc) { setStatus("Describe the song you want to create", "error"); descInput.focus(); return; }
590
+ if (instrumentalChk.checked) desc += " (instrumental, no vocals)";
591
+
592
+ stopFeedAudio();
593
+ setLoading(true);
594
+ setStatus("");
595
+
596
+ // Show animated placeholder on the left, above output
597
+ outputSection.style.display = "none";
598
+ const placeholder = document.createElement("div");
599
+ placeholder.className = "placeholder-card";
600
+ placeholder.id = "gen-placeholder";
601
+ const bars = Array.from({length: 40}, (_, i) =>
602
+ `<div class="bar" style="height:100%;animation-delay:${(i * 0.06).toFixed(2)}s"></div>`
603
+ ).join("");
604
+ placeholder.innerHTML = `
605
+ <div class="placeholder-title">${esc(desc.slice(0, 80))}</div>
606
+ <div class="placeholder-sub">Composing &amp; generating…</div>
607
+ <div class="fake-waveform">${bars}</div>
608
+ `;
609
+ outputSection.parentNode.insertBefore(placeholder, outputSection);
610
 
611
+ try {
612
+ const client = await getClient();
613
+ const job = client.submit("/create", {
614
+ description: desc,
615
+ audio_duration: parseFloat(durationSelect.value),
616
+ seed: -1,
617
+ community: communityChk.checked,
 
 
618
  });
619
 
620
  for await (const msg of job) {
621
  if (msg.type === "status") {
622
+ if (msg.queue_size > 0) {
623
+ const eta = msg.eta != null ? ` Β· ~${Math.round(msg.eta)}s` : "";
624
+ setStatus(`Queue ${msg.position}/${msg.queue_size}${eta}`);
 
 
 
625
  }
626
  } else if (msg.type === "data") {
627
+ const raw = msg.data?.[0];
628
+ if (!raw) throw new Error("No data returned");
629
+ const result = JSON.parse(raw);
630
+ songTitleEl.textContent = result.title || "";
631
+ songTagsEl.textContent = result.tags || "";
632
+ const thumbEl = document.getElementById("song-thumb");
633
+ if (result.thumbnail) { thumbEl.src = result.thumbnail; thumbEl.style.display = "block"; }
634
+ else { thumbEl.style.display = "none"; }
635
+ const safeName = (result.title || "output").replace(/[^a-zA-Z0-9 -]/g, "").trim().replace(/\s+/g, "-");
636
+ downloadBtn.download = `${safeName}.wav`;
637
+ await loadAudio(result.audio);
638
+ // Refresh feed if shared
639
+ if (communityChk.checked) loadFeed();
640
  }
641
  }
642
  } catch (err) {
643
+ console.error(err);
644
  setStatus(`Error: ${err.message}`, "error");
645
  } finally {
646
+ placeholder.remove();
647
+ setLoading(false);
648
  }
649
  });
650
 
651
+ descInput.addEventListener("keydown", (e) => {
652
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); generateBtn.click(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
  });
654
 
655
+ // ── Init ─────────────────────────────────────────────────────────────────
656
+ getClient().then(() => loadFeed()).catch(console.error);
 
657
  </script>
658
 
659
  </body>
requirements.txt CHANGED
@@ -2,7 +2,7 @@ torch==2.9.1
2
  torchaudio==2.9.1
3
  torchvision==0.24.1
4
  transformers>=4.51.0,<4.58.0
5
- diffusers
6
  gradio==6.12.0
7
  matplotlib>=3.7.5
8
  scipy>=1.10.1
 
2
  torchaudio==2.9.1
3
  torchvision==0.24.1
4
  transformers>=4.51.0,<4.58.0
5
+ diffusers>=0.37.0
6
  gradio==6.12.0
7
  matplotlib>=3.7.5
8
  scipy>=1.10.1