ozipoetra commited on
Commit
5cd697f
Β·
1 Parent(s): 5d081a4

feat: add jobs tracking and reverb effect

Browse files

- Add Jobs tab with real-time job status display
- Show job ID, model, status, elapsed time, and download link
- Add queue status summary (waiting, running, done, failed)
- Add refresh and clear completed jobs buttons
- Display download link as clickable emoji (⬇️) in markdown format
- Add reverb effect using pedalboard library
- Add UI controls for reverb (enable checkbox, mix slider)
- Remove in-memory log buffer, use file logs instead

Files changed (1) hide show
  1. app.py +219 -159
app.py CHANGED
@@ -4,7 +4,6 @@ Simple, fast, GPU/CPU auto-detected.
4
  """
5
  from __future__ import annotations
6
 
7
- import collections
8
  import logging
9
  import os
10
  import queue
@@ -31,28 +30,11 @@ OUTPUT_DIR.mkdir(exist_ok=True)
31
 
32
  os.environ.setdefault("URVC_MODELS_DIR", str(MODELS_DIR / "urvc"))
33
 
34
- # ── In-memory log buffer (feeds the Logs tab) ────────────────────────────────
35
- _LOG_BUFFER: collections.deque[str] = collections.deque(maxlen=200)
36
-
37
-
38
- class _UILogHandler(logging.Handler):
39
- def emit(self, record: logging.LogRecord) -> None:
40
- _LOG_BUFFER.append(self.format(record))
41
-
42
-
43
- _ui_handler = _UILogHandler()
44
- _ui_handler.setLevel(logging.INFO)
45
- _ui_handler.setFormatter(logging.Formatter(
46
- fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
47
- datefmt="%H:%M:%S",
48
- ))
49
-
50
  logging.basicConfig(
51
  level=logging.INFO,
52
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
53
  datefmt="%H:%M:%S",
54
  )
55
- logging.getLogger().addHandler(_ui_handler)
56
 
57
  for _noisy in ("httpx", "httpcore", "faiss", "faiss.loader", "transformers", "torch"):
58
  logging.getLogger(_noisy).setLevel(logging.WARNING)
@@ -271,22 +253,40 @@ def toggle_autotune(enabled):
271
  return gr.update(visible=enabled)
272
 
273
 
274
- # ── Reverb IR generator ────────────────────────────────────────────────────────
275
- def _generate_reverb_ir(sr: int, decay: float = 0.5, length_ms: int = 500) -> np.ndarray:
276
- """Generate a simple reverb impulse response."""
277
- import numpy as np
278
- length_samples = int(sr * length_ms / 1000)
279
- t = np.arange(length_samples) / sr
280
- ir = np.exp(-t / (decay * 0.5)) * np.random.randn(length_samples)
281
- ir = ir / np.max(np.abs(ir))
282
- return ir
283
-
284
-
285
  # ── ffmpeg is pre-installed on HuggingFace Spaces ────────────────────────────
286
  def _ffmpeg_bin() -> str:
287
  return "ffmpeg"
288
 
289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  # ── Upload to temp.sh ────────────────────────────────────────────────────────
291
  def _upload_to_tempsh(file_path: str) -> str | None:
292
  """Upload a file to temp.sh and return the download URL, or None on failure."""
@@ -324,6 +324,7 @@ def _worker() -> None:
324
  job = _job_queue.get()
325
  job_id = job["id"]
326
  try:
 
327
  with _jobs_lock:
328
  _jobs[job_id]["status"] = "⏳ Converting…"
329
 
@@ -341,41 +342,25 @@ def _worker() -> None:
341
  else f"output-{ts}.{job['output_format'].lower()}"
342
  )
343
 
344
- vc = _get_vc()
345
- vc.convert_audio(
346
- audio_input_path=job["audio_input"],
347
- audio_output_path=str(wav_path),
348
- model_path=model_path,
349
- index_path=index_path,
350
- pitch=job["pitch"],
351
- f0_method=job["f0_method"],
352
- index_rate=job["index_rate"],
353
- volume_envelope=job["volume_envelope"],
354
- protect=job["protect"],
355
- split_audio=job["split_audio"],
356
- f0_autotune=job["autotune"],
357
- f0_autotune_strength=job["autotune_strength"],
358
- clean_audio=job["clean_audio"],
359
- clean_strength=job["clean_strength"],
360
- export_format=engine_format,
361
- filter_radius=job["filter_radius"],
362
- )
363
-
364
- if job["reverb"]:
365
- import numpy as np
366
- import soundfile as sf
367
- from scipy.signal import convolve
368
-
369
- audio_data, sr = sf.read(str(wav_path))
370
- if audio_data.ndim > 1:
371
- audio_data = audio_data.mean(axis=1)
372
-
373
- reverb_ir = _generate_reverb_ir(sr, decay=job["reverb_mix"])
374
- reverb_signal = convolve(audio_data, reverb_ir, mode="same")
375
- mix = job["reverb_mix"]
376
- audio_data = audio_data * (1 - mix) + reverb_signal * mix
377
-
378
- sf.write(str(wav_path), audio_data, sr)
379
 
380
  if is_opus:
381
  import subprocess
@@ -393,25 +378,40 @@ def _worker() -> None:
393
  )
394
  wav_path.unlink(missing_ok=True)
395
 
 
 
 
 
 
 
 
 
 
396
  # Upload to temp.sh
397
  temp_url = _upload_to_tempsh(str(out_path))
398
 
 
 
399
  with _jobs_lock:
 
400
  if temp_url:
401
- _jobs[job_id]["status"] = f"βœ… Done!"
402
  _jobs[job_id]["url"] = temp_url
403
  _jobs[job_id]["file"] = str(out_path)
404
- logger.info("[Job %s] Complete β†’ %s", job_id, temp_url)
405
  else:
406
- _jobs[job_id]["status"] = "βœ… Done! (temp.sh unavailable β€” download from Logs)"
407
  _jobs[job_id]["file"] = str(out_path)
408
- logger.info("[Job %s] Complete (no temp.sh URL)", job_id)
409
 
410
  except Exception as exc:
411
- logger.exception("[Job %s] Failed: %s", job_id, exc)
 
 
412
  with _jobs_lock:
413
- _jobs[job_id]["status"] = f"❌ Failed: {exc}"
414
- _jobs[job_id]["file"] = None
 
415
  finally:
416
  _job_queue.task_done()
417
 
@@ -431,7 +431,10 @@ def convert(
431
  split_audio, autotune, autotune_strength,
432
  filter_radius,
433
  output_format,
434
- reverb, reverb_mix,
 
 
 
435
  ):
436
  """Submit a job to the background worker and return immediately."""
437
  audio_input = audio_mic or audio_file
@@ -462,23 +465,25 @@ def convert(
462
 
463
  job_id = uuid.uuid4().hex[:8]
464
  job = {
465
- "id": job_id,
466
- "audio_input": audio_input,
467
- "model_name": model_name,
468
- "pitch": pitch,
469
- "f0_method": f0_method,
470
- "index_rate": index_rate,
471
- "volume_envelope": volume_envelope,
472
- "protect": protect,
473
- "split_audio": split_audio,
474
- "autotune": autotune,
475
  "autotune_strength": autotune_strength,
476
- "clean_audio": clean_audio,
477
- "clean_strength": clean_strength,
478
- "filter_radius": filter_radius,
479
- "output_format": output_format,
480
- "reverb": reverb,
481
- "reverb_mix": reverb_mix,
 
 
482
  }
483
 
484
  with _jobs_lock:
@@ -490,9 +495,9 @@ def convert(
490
  logger.info("[Job %s] Queued (model: %s, queue depth: %d)", job_id, model_name, queue_size)
491
 
492
  msg = (
493
- f"πŸ• Job **{job_id}** queued β€” you can close this tab. "
494
- f"Check the **πŸ“‹ Logs** tab for your temp.sh download link when done. "
495
- f"_(Queue position: {queue_size})_"
496
  )
497
  return msg, None
498
 
@@ -528,8 +533,42 @@ _initial_value = _default_model if _default_model in _initial_models else (
528
 
529
 
530
  # ── Log helpers ───────────────────────────────────────────────────────────────
531
- def get_logs() -> str:
532
- return "\n".join(_LOG_BUFFER) if _LOG_BUFFER else "(no logs yet)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
 
534
 
535
  # ── Gradio UI ─────────────────────────────────────────────────────────────────
@@ -625,42 +664,50 @@ with gr.Blocks(title="RVC Voice Conversion", css=_CSS, delete_cache=(3600, 3600)
625
  0.0, 1.0, value=0.5, step=0.05,
626
  label="Reduction Strength",
627
  )
628
- with gr.Row():
629
- split_cb = gr.Checkbox(value=False, label="Split Long Audio")
630
- autotune_cb = gr.Checkbox(value=False, label="Autotune")
631
- autotune_sl = gr.Slider(
632
- 0.0, 1.0, value=1.0, step=0.05,
633
- label="Autotune Strength",
634
- visible=False,
635
- )
636
- autotune_cb.change(
637
- fn=toggle_autotune,
638
- inputs=autotune_cb,
639
- outputs=autotune_sl,
640
- )
641
- with gr.Row():
642
- reverb_cb = gr.Checkbox(value=False, label="Reverb Effect")
643
- reverb_sl = gr.Slider(
644
- 0.0, 1.0, value=0.5, step=0.05,
645
- label="Reverb Mix",
646
- visible=False,
647
- )
648
- reverb_cb.change(
649
- fn=lambda x: gr.Slider(visible=x),
650
- inputs=reverb_cb,
651
- outputs=reverb_sl,
652
- )
653
  autotune_cb.change(
654
  fn=toggle_autotune,
655
  inputs=autotune_cb,
656
  outputs=autotune_sl,
657
  )
658
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
  fmt_radio = gr.Radio(
660
- choices=["WAV", "MP3", "FLAC", "OPUS"],
661
- value="WAV",
662
- label="Output Format",
663
- )
 
664
  convert_btn = gr.Button(
665
  "πŸš€ Convert Voice",
666
  variant="primary",
@@ -721,26 +768,35 @@ with gr.Blocks(title="RVC Voice Conversion", css=_CSS, delete_cache=(3600, 3600)
721
  outputs=[models_table, model_dd],
722
  )
723
 
724
- # ── TAB 3: Logs ───────────────────────────────────────────────────────
725
- with gr.Tab("πŸ“‹ Logs"):
726
- gr.Markdown("Live log output from the conversion engine. Click **Refresh** to update.")
727
- logs_box = gr.Textbox(
728
- value=get_logs,
729
- label="",
730
- lines=20,
731
- max_lines=20,
732
  interactive=False,
 
 
733
  )
734
  with gr.Row():
735
- refresh_logs_btn = gr.Button("πŸ”„ Refresh Logs")
736
- clear_logs_btn = gr.Button("πŸ—‘οΈ Clear")
737
 
738
- def clear_logs():
739
- _LOG_BUFFER.clear()
740
- return ""
741
 
742
- refresh_logs_btn.click(fn=get_logs, outputs=logs_box)
743
- clear_logs_btn.click(fn=clear_logs, outputs=logs_box)
 
 
 
 
 
 
 
 
744
 
745
  # ── TAB 4: Help ───────────────────────────────────────────────────────
746
  with gr.Tab("ℹ️ Help"):
@@ -812,36 +868,40 @@ with gr.Blocks(title="RVC Voice Conversion", css=_CSS, delete_cache=(3600, 3600)
812
  Engine: [Ultimate RVC](https://github.com/JackismyShephard/ultimate-rvc)
813
  """)
814
 
815
- # Wire convert button after all tabs so logs_box is defined
816
  def _submit_and_extract_id(*args):
817
  status, audio = convert(*args)
818
- # Extract job ID from status message for auto-populating the poll box
819
  import re
820
- match = re.search(r"\*\*([a-f0-9]{8})\*\*", status or "")
821
- job_id = match.group(1) if match else ""
822
- return status, audio, job_id, get_logs()
823
-
824
- convert_btn.click(
825
- fn=_submit_and_extract_id,
826
- inputs=[
827
- inp_mic, inp_file, model_dd,
828
- pitch_sl, f0_radio,
829
- index_rate_sl, protect_sl, vol_env_sl,
830
- clean_cb, clean_sl,
831
- split_cb, autotune_cb, autotune_sl,
832
- filter_radius_sl,
833
- fmt_radio,
834
- reverb_cb, reverb_sl,
835
- ],
836
- outputs=[out_status, out_audio, job_id_box, logs_box],
837
- )
 
 
 
 
838
 
839
  poll_btn.click(
840
- fn=poll_job,
841
  inputs=[job_id_box],
842
- outputs=[poll_status, poll_audio],
843
  )
844
 
 
845
  # ── Launch ────────────────────────────────────────────────────────────────────
846
  if __name__ == "__main__":
847
  demo.queue(default_concurrency_limit=5)
 
4
  """
5
  from __future__ import annotations
6
 
 
7
  import logging
8
  import os
9
  import queue
 
30
 
31
  os.environ.setdefault("URVC_MODELS_DIR", str(MODELS_DIR / "urvc"))
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  logging.basicConfig(
34
  level=logging.INFO,
35
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
36
  datefmt="%H:%M:%S",
37
  )
 
38
 
39
  for _noisy in ("httpx", "httpcore", "faiss", "faiss.loader", "transformers", "torch"):
40
  logging.getLogger(_noisy).setLevel(logging.WARNING)
 
253
  return gr.update(visible=enabled)
254
 
255
 
 
 
 
 
 
 
 
 
 
 
 
256
  # ── ffmpeg is pre-installed on HuggingFace Spaces ────────────────────────────
257
  def _ffmpeg_bin() -> str:
258
  return "ffmpeg"
259
 
260
 
261
+ # ── Reverb effect via pedalboard ─────────────────────────────────────────────
262
+ def _apply_reverb(audio_path: str, room_size: float, damping: float, wet_level: float) -> None:
263
+ """Apply reverb in-place to a WAV file using pedalboard."""
264
+ try:
265
+ from pedalboard import Pedalboard, Reverb
266
+ from pedalboard.io import AudioFile
267
+ import tempfile, shutil
268
+
269
+ tmp = audio_path + ".reverb.tmp.wav"
270
+ board = Pedalboard([
271
+ Reverb(
272
+ room_size=room_size,
273
+ damping=damping,
274
+ wet_level=wet_level,
275
+ dry_level=1.0 - wet_level,
276
+ width=1.0,
277
+ )
278
+ ])
279
+ with AudioFile(audio_path) as f:
280
+ with AudioFile(tmp, "w", f.samplerate, f.num_channels) as out:
281
+ while f.tell() < f.frames:
282
+ chunk = f.read(f.samplerate)
283
+ out.write(board(chunk, f.samplerate, reset=False))
284
+ shutil.move(tmp, audio_path)
285
+ logger.info("Reverb applied (room=%.2f, damp=%.2f, wet=%.2f)", room_size, damping, wet_level)
286
+ except Exception as exc:
287
+ logger.warning("Reverb failed: %s", exc)
288
+
289
+
290
  # ── Upload to temp.sh ────────────────────────────────────────────────────────
291
  def _upload_to_tempsh(file_path: str) -> str | None:
292
  """Upload a file to temp.sh and return the download URL, or None on failure."""
 
324
  job = _job_queue.get()
325
  job_id = job["id"]
326
  try:
327
+ _start_time = time.time()
328
  with _jobs_lock:
329
  _jobs[job_id]["status"] = "⏳ Converting…"
330
 
 
342
  else f"output-{ts}.{job['output_format'].lower()}"
343
  )
344
 
345
+ vc = _get_vc()
346
+ vc.convert_audio(
347
+ audio_input_path=job["audio_input"],
348
+ audio_output_path=str(wav_path),
349
+ model_path=model_path,
350
+ index_path=index_path,
351
+ pitch=job["pitch"],
352
+ f0_method=job["f0_method"],
353
+ index_rate=job["index_rate"],
354
+ volume_envelope=job["volume_envelope"],
355
+ protect=job["protect"],
356
+ split_audio=job["split_audio"],
357
+ f0_autotune=job["autotune"],
358
+ f0_autotune_strength=job["autotune_strength"],
359
+ clean_audio=job["clean_audio"],
360
+ clean_strength=job["clean_strength"],
361
+ export_format=engine_format,
362
+ filter_radius=job["filter_radius"],
363
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
 
365
  if is_opus:
366
  import subprocess
 
378
  )
379
  wav_path.unlink(missing_ok=True)
380
 
381
+ # Apply reverb if enabled (operates on the final output file)
382
+ if job.get("reverb"):
383
+ _apply_reverb(
384
+ str(out_path),
385
+ room_size=job.get("reverb_room_size", 0.15),
386
+ damping=job.get("reverb_damping", 0.7),
387
+ wet_level=job.get("reverb_wet_level", 0.15),
388
+ )
389
+
390
  # Upload to temp.sh
391
  temp_url = _upload_to_tempsh(str(out_path))
392
 
393
+ _elapsed = time.time() - _start_time
394
+ _elapsed_str = f"{_elapsed:.0f}s" if _elapsed < 60 else f"{_elapsed/60:.1f}m"
395
  with _jobs_lock:
396
+ _jobs[job_id]["elapsed"] = _elapsed_str
397
  if temp_url:
398
+ _jobs[job_id]["status"] = "βœ… Done"
399
  _jobs[job_id]["url"] = temp_url
400
  _jobs[job_id]["file"] = str(out_path)
401
+ logger.info("[Job %s] Complete in %s β†’ %s", job_id, _elapsed_str, temp_url)
402
  else:
403
+ _jobs[job_id]["status"] = "βœ… Done"
404
  _jobs[job_id]["file"] = str(out_path)
405
+ logger.info("[Job %s] Complete in %s (no temp.sh URL)", job_id, _elapsed_str)
406
 
407
  except Exception as exc:
408
+ _elapsed = time.time() - _start_time if "_start_time" in dir() else 0
409
+ _elapsed_str = f"{_elapsed:.0f}s" if _elapsed < 60 else f"{_elapsed/60:.1f}m"
410
+ logger.exception("[Job %s] Failed after %s: %s", job_id, _elapsed_str, exc)
411
  with _jobs_lock:
412
+ _jobs[job_id]["status"] = f"❌ Failed"
413
+ _jobs[job_id]["elapsed"] = _elapsed_str
414
+ _jobs[job_id]["file"] = None
415
  finally:
416
  _job_queue.task_done()
417
 
 
431
  split_audio, autotune, autotune_strength,
432
  filter_radius,
433
  output_format,
434
+ reverb=False,
435
+ reverb_room_size=0.15,
436
+ reverb_damping=0.7,
437
+ reverb_wet_level=0.15,
438
  ):
439
  """Submit a job to the background worker and return immediately."""
440
  audio_input = audio_mic or audio_file
 
465
 
466
  job_id = uuid.uuid4().hex[:8]
467
  job = {
468
+ "id": job_id,
469
+ "audio_input": audio_input,
470
+ "model_name": model_name,
471
+ "pitch": pitch,
472
+ "f0_method": f0_method,
473
+ "index_rate": index_rate,
474
+ "volume_envelope": volume_envelope,
475
+ "protect": protect,
476
+ "split_audio": split_audio,
477
+ "autotune": autotune,
478
  "autotune_strength": autotune_strength,
479
+ "clean_audio": clean_audio,
480
+ "clean_strength": clean_strength,
481
+ "filter_radius": filter_radius,
482
+ "output_format": output_format,
483
+ "reverb": reverb,
484
+ "reverb_room_size": reverb_room_size,
485
+ "reverb_damping": reverb_damping,
486
+ "reverb_wet_level": reverb_wet_level,
487
  }
488
 
489
  with _jobs_lock:
 
495
  logger.info("[Job %s] Queued (model: %s, queue depth: %d)", job_id, model_name, queue_size)
496
 
497
  msg = (
498
+ "πŸ• Job **" + job_id + "** queued β€” you can close this tab.\n\n"
499
+ "Check the **πŸ“‹ Logs** tab for your temp.sh link when done.\n\n"
500
+ "_(Queue position: " + str(queue_size) + ")_"
501
  )
502
  return msg, None
503
 
 
533
 
534
 
535
  # ── Log helpers ───────────────────────────────────────────────────────────────
536
+
537
+
538
+ def get_jobs_table() -> list[list]:
539
+ """Return job list as rows: [ID, Model, Status, Time, Download Link]."""
540
+ with _jobs_lock:
541
+ jobs = list(_jobs.items())
542
+ if not jobs:
543
+ return [["β€”", "β€”", "No jobs yet", "β€”", "β€”"]]
544
+ rows = []
545
+ for job_id, info in reversed(jobs):
546
+ url = info.get("url")
547
+ link = f"[⬇️]({url})" if url else "β€”"
548
+ rows.append([
549
+ job_id,
550
+ info.get("model", ""),
551
+ info.get("status", ""),
552
+ info.get("elapsed", "β€”"),
553
+ link,
554
+ ])
555
+ return rows
556
+
557
+
558
+ def get_queue_info() -> str:
559
+ """Return a short queue status string."""
560
+ qs = _job_queue.qsize()
561
+ total = len(_jobs)
562
+ running = sum(1 for j in _jobs.values() if j.get("status", "").startswith("⏳"))
563
+ done = sum(1 for j in _jobs.values() if j.get("status", "").startswith("βœ…"))
564
+ failed = sum(1 for j in _jobs.values() if j.get("status", "").startswith("❌"))
565
+ return (
566
+ f"**Queue:** {qs} waiting Β· "
567
+ f"**Running:** {running} Β· "
568
+ f"**Done:** {done} Β· "
569
+ f"**Failed:** {failed} Β· "
570
+ f"**Total:** {total}"
571
+ )
572
 
573
 
574
  # ── Gradio UI ─────────────────────────────────────────────────────────────────
 
664
  0.0, 1.0, value=0.5, step=0.05,
665
  label="Reduction Strength",
666
  )
667
+ with gr.Row():
668
+ split_cb = gr.Checkbox(value=False, label="Split Long Audio")
669
+ autotune_cb = gr.Checkbox(value=False, label="Autotune")
670
+ autotune_sl = gr.Slider(
671
+ 0.0, 1.0, value=1.0, step=0.05,
672
+ label="Autotune Strength",
673
+ visible=False,
674
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
675
  autotune_cb.change(
676
  fn=toggle_autotune,
677
  inputs=autotune_cb,
678
  outputs=autotune_sl,
679
  )
680
 
681
+ gr.Markdown("**πŸŽ›οΈ Reverb**")
682
+ reverb_cb = gr.Checkbox(value=False, label="Enable Reverb")
683
+ with gr.Group(visible=False) as reverb_group:
684
+ reverb_room_sl = gr.Slider(
685
+ 0.0, 1.0, value=0.15, step=0.05,
686
+ label="Room Size",
687
+ info="Larger = bigger sounding space",
688
+ )
689
+ reverb_damp_sl = gr.Slider(
690
+ 0.0, 1.0, value=0.7, step=0.05,
691
+ label="Damping",
692
+ info="Higher = more absorption, less echo tail",
693
+ )
694
+ reverb_wet_sl = gr.Slider(
695
+ 0.0, 1.0, value=0.15, step=0.05,
696
+ label="Wet Level",
697
+ info="How much reverb is mixed in (0.15 = subtle)",
698
+ )
699
+ reverb_cb.change(
700
+ fn=lambda v: gr.update(visible=v),
701
+ inputs=reverb_cb,
702
+ outputs=reverb_group,
703
+ )
704
+
705
  fmt_radio = gr.Radio(
706
+ choices=["WAV", "MP3", "FLAC", "OPUS"],
707
+ value="WAV",
708
+ label="Output Format",
709
+ info="OPUS = small file (~64 kbps, Telegram/Discord quality)",
710
+ )
711
  convert_btn = gr.Button(
712
  "πŸš€ Convert Voice",
713
  variant="primary",
 
768
  outputs=[models_table, model_dd],
769
  )
770
 
771
+ # ── TAB 3: Jobs ───────────────────────────────────────────────────────
772
+ with gr.Tab("πŸ“‹ Jobs"):
773
+ gr.Markdown("All submitted jobs, newest first. Click **Refresh** to update.")
774
+ queue_status = gr.Markdown(value=get_queue_info)
775
+ jobs_table = gr.Dataframe(
776
+ headers=["Job ID", "Model", "Status", "Time", "Download"],
777
+ col_count=(5, "fixed"),
778
+ value=get_jobs_table,
779
  interactive=False,
780
+ wrap=True,
781
+ datatype=["str", "str", "str", "str", "markdown"],
782
  )
783
  with gr.Row():
784
+ refresh_jobs_btn = gr.Button("πŸ”„ Refresh")
785
+ clear_jobs_btn = gr.Button("πŸ—‘οΈ Clear Done")
786
 
787
+ def _refresh_jobs():
788
+ return get_queue_info(), get_jobs_table()
 
789
 
790
+ def _clear_done_jobs():
791
+ with _jobs_lock:
792
+ done_ids = [jid for jid, j in _jobs.items()
793
+ if j.get("status", "").startswith(("βœ…", "❌"))]
794
+ for jid in done_ids:
795
+ del _jobs[jid]
796
+ return get_queue_info(), get_jobs_table()
797
+
798
+ refresh_jobs_btn.click(fn=_refresh_jobs, outputs=[queue_status, jobs_table])
799
+ clear_jobs_btn.click(fn=_clear_done_jobs, outputs=[queue_status, jobs_table])
800
 
801
  # ── TAB 4: Help ───────────────────────────────────────────────────────
802
  with gr.Tab("ℹ️ Help"):
 
868
  Engine: [Ultimate RVC](https://github.com/JackismyShephard/ultimate-rvc)
869
  """)
870
 
871
+ # Wire convert button after all tabs so jobs_table is defined
872
  def _submit_and_extract_id(*args):
873
  status, audio = convert(*args)
 
874
  import re
875
+ match = re.search(r"[a-f0-9]{8}", status or "")
876
+ job_id = match.group(0) if match else ""
877
+ return status, audio, job_id, get_queue_info(), get_jobs_table()
878
+
879
+ convert_btn.click(
880
+ fn=_submit_and_extract_id,
881
+ inputs=[
882
+ inp_mic, inp_file, model_dd,
883
+ pitch_sl, f0_radio,
884
+ index_rate_sl, protect_sl, vol_env_sl,
885
+ clean_cb, clean_sl,
886
+ split_cb, autotune_cb, autotune_sl,
887
+ filter_radius_sl,
888
+ fmt_radio,
889
+ reverb_cb, reverb_room_sl, reverb_damp_sl, reverb_wet_sl,
890
+ ],
891
+ outputs=[out_status, out_audio, job_id_box, queue_status, jobs_table],
892
+ )
893
+
894
+ def _poll_and_refresh(job_id):
895
+ status, file = poll_job(job_id)
896
+ return status, file, get_queue_info(), get_jobs_table()
897
 
898
  poll_btn.click(
899
+ fn=_poll_and_refresh,
900
  inputs=[job_id_box],
901
+ outputs=[poll_status, poll_audio, queue_status, jobs_table],
902
  )
903
 
904
+
905
  # ── Launch ────────────────────────────────────────────────────────────────────
906
  if __name__ == "__main__":
907
  demo.queue(default_concurrency_limit=5)