Hydui commited on
Commit
0445a8f
·
verified ·
1 Parent(s): 92737b8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +350 -82
app.py CHANGED
@@ -1,10 +1,15 @@
1
- """Audio Cleanup Studio – a Gradio Space with real audio processing and private history.
2
 
3
  Deployment secrets and storage:
4
  - ADMIN_ACCESS_CODE: required to unlock the administrator archive.
5
  - DATA_DIR: optional persistent mounted directory; uses /data automatically when available.
6
  - HISTORY_RETENTION_DAYS: number of days to retain private audio artifacts (default: 30; 0 disables expiry).
7
  - MAX_AUDIO_MINUTES: optional maximum input duration; 0 means unlimited (default: 0).
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
@@ -31,6 +36,7 @@ from pathlib import Path
31
  from typing import Generator, Iterable
32
 
33
  import gradio as gr
 
34
  from pydub import AudioSegment, effects
35
  from pydub.silence import split_on_silence
36
 
@@ -281,7 +287,7 @@ def admin_rows(limit: int = 100) -> list[list[str]]:
281
  rows = connection.execute(
282
  """
283
  SELECT job_id, public_filename, operation, status, created_at,
284
- COALESCE(completed_at, '—') AS completed_at,
285
  COALESCE(processing_seconds, 0) AS processing_seconds
286
  FROM jobs
287
  ORDER BY created_ts DESC
@@ -297,7 +303,7 @@ def admin_rows(limit: int = 100) -> list[list[str]]:
297
  row["status"],
298
  row["created_at"],
299
  row["completed_at"],
300
- f"{row['processing_seconds']:.1f}s" if row["processing_seconds"] else "—",
301
  ]
302
  for row in rows
303
  ]
@@ -315,7 +321,7 @@ def admin_job_choices() -> list[tuple[str, str]]:
315
  """
316
  ).fetchall()
317
  return [
318
- (f"{row['activity_at']} · {row['public_filename']} · {row['operation']}", row["job_id"])
319
  for row in rows
320
  ]
321
 
@@ -440,6 +446,7 @@ _DEFAULT_OVERHEAD_SECONDS = {
440
  }
441
  _DEFAULT_RATE_PER_AUDIO_SECOND = {
442
  "Remove silence": 0.06,
 
443
  "Enhance audio::deepfilter": 0.5,
444
  "Enhance audio::fallback": 0.65,
445
  "Voice / BGM isolation::htdemucs-fast": 1.0,
@@ -450,7 +457,7 @@ _DEFAULT_RATE_PER_AUDIO_SECOND = {
450
  def estimated_seconds(operation: str, duration: float, quality: str = "", device: str = "cpu") -> float:
451
  """Best-available ETA: starts from a conservative default rate, then blends in
452
  this deployment's own measured processing rate (from performance_stats) as soon
453
- as there is enough real history for that exact operation/quality/device — so the
454
  estimate keeps improving instead of staying a fixed guess forever."""
455
  overhead = _DEFAULT_OVERHEAD_SECONDS.get(operation, 15)
456
  default_key = f"{operation}::{quality}" if quality else operation
@@ -470,7 +477,7 @@ def estimated_seconds(operation: str, duration: float, quality: str = "", device
470
 
471
  def dynamic_remaining(elapsed: float, estimate: float) -> tuple[float, float]:
472
  """Keeps the on-screen ETA honest. The static formula above is only ever a
473
- prediction — if the real job is taking longer than predicted, this grows the
474
  working estimate instead of freezing 'remaining' at 0 while work continues."""
475
  if elapsed < estimate * 0.9:
476
  return estimate, max(0.0, estimate - elapsed)
@@ -483,8 +490,8 @@ _PERCENT_PATTERN = re.compile(r"(\d{1,3})%\|")
483
 
484
  def parse_progress_percent(line: str) -> float | None:
485
  """Demucs (and some DeepFilterNet builds) print a tqdm-style '45%|...' progress
486
- bar to stdout/stderr. When present, this is real model progress — far more
487
- accurate than a time-based guess — so we use it whenever we can detect it."""
488
  match = _PERCENT_PATTERN.search(line)
489
  if not match:
490
  return None
@@ -578,6 +585,8 @@ def ffmpeg_convert_to_mp3(source: Path, destination: Path, bitrate: str = "192k"
578
 
579
 
580
  def remove_silence_audio(source: Path, destination: Path, keep_silence_ms: int) -> tuple[float, float, float]:
 
 
581
  sound = AudioSegment.from_file(source)
582
  before = len(sound) / 1000.0
583
  if sound.dBFS == float("-inf"):
@@ -602,6 +611,215 @@ def remove_silence_audio(source: Path, destination: Path, keep_silence_ms: int)
602
  return before, after, threshold
603
 
604
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  def spectral_denoise_fallback(source: Path, destination: Path, strength: str) -> None:
606
  """A real, audible fallback when DeepFilterNet cannot initialise."""
607
  import numpy as np
@@ -661,10 +879,10 @@ def gpu_status() -> str:
661
  import torch
662
  if torch.cuda.is_available():
663
  name = torch.cuda.get_device_name(0)
664
- return f"GPU: CUDA enabled · {name}"
665
  except Exception:
666
  pass
667
- return "GPU: unavailable · CPU fallback"
668
 
669
  def subprocess_progress(
670
  command: list[str],
@@ -675,7 +893,7 @@ def subprocess_progress(
675
  ) -> Generator[tuple[str, float, float], None, None]:
676
  """Yield (latest_log_line, remaining_seconds, progress_fraction) while a model
677
  subprocess runs. When the tool prints its own tqdm-style '45%|...' progress,
678
- that real progress drives both the fraction and the ETA math — otherwise this
679
  falls back to the honest, self-correcting time estimate."""
680
  process = subprocess.Popen(
681
  command,
@@ -709,7 +927,7 @@ def subprocess_progress(
709
  break
710
  elapsed = time.monotonic() - started
711
  if elapsed - last_yield >= 1.0:
712
- latest = output_lines[-1] if output_lines else f"{label} is running…"
713
  detected = parse_progress_percent(latest)
714
  if detected is not None:
715
  real_progress = detected
@@ -803,17 +1021,22 @@ def run_with_heartbeat(work, estimate: float, progress, status_callback, *, star
803
  return result.get("value")
804
 
805
  # -----------------------------------------------------------------------------
806
- # Gradio processing flows – generators make logs and ETA visible during work.
807
  # -----------------------------------------------------------------------------
808
  def process_remove_silence(
809
  audio_file: str | None,
810
  keep_silence_seconds: float,
 
 
811
  progress: gr.Progress = gr.Progress(),
812
  ):
813
  logs: TimedLogList = TimedLogList()
814
  started = time.monotonic()
815
  job_id = uuid.uuid4().hex
816
  stats, table = recent_public_history()
 
 
 
817
 
818
  def state(status: str, eta: str, audio=gr.skip(), file=gr.skip(), duration=gr.skip(), processed=gr.skip()):
819
  return audio, file, duration, processed, status, "\n".join(logs), eta, stats, table
@@ -822,23 +1045,35 @@ def process_remove_silence(
822
  if not audio_file:
823
  raise ValueError("Upload an audio file before processing.")
824
  logs.append("[1/4] Validating uploaded file")
825
- yield state("Validating upload…", "ETA: calculating…")
826
 
827
  job_dir = make_job_directory(job_id)
828
  original = copy_input_to_job(audio_file, job_dir)
829
  duration = audio_duration(original)
830
  validate_duration(duration)
831
- record_job_started(job_id, safe_filename(Path(audio_file).name), "Remove silence", duration)
832
- estimate = estimated_seconds("Remove silence", duration, "", "cpu")
833
- logs.append(f"[2/4] Duration: {duration:.2f}s · adaptive silence detection enabled")
 
 
 
834
  progress(0.2, desc="Detecting silence")
835
- yield state("Detecting silent sections…", f"Estimated remaining: {format_seconds(estimate)}")
836
 
837
  output = job_dir / f"{original.stem}_silence_removed.wav"
838
  mp3_output = job_dir / f"{original.stem}_silence_removed.mp3"
839
- silence_result = None
 
 
 
 
 
 
 
 
 
840
  heartbeat = run_with_heartbeat(
841
- lambda: remove_silence_audio(original, output, int(max(0, float(keep_silence_seconds)) * 1000)),
842
  estimate,
843
  progress,
844
  lambda pct, eta: "Detecting silent sections",
@@ -846,33 +1081,41 @@ def process_remove_silence(
846
  end_progress=0.72,
847
  )
848
  for pct, eta in heartbeat:
849
- yield state("Detecting silent sections…", f"Estimated remaining: {eta}")
850
- # The generator return value is not directly exposed by a for-loop, so run the operation
851
- # once more is not acceptable; use the worker-result bridge below.
852
- # (The helper stores its result in the output file; derive exact duration values afterward.)
853
- before = duration
854
- after = audio_duration(output)
855
- threshold = max(-55.0, min(-25.0, AudioSegment.from_file(original).dBFS - 18.0))
856
- logs.append(f"[3/4] Removed silence using {threshold:.1f} dB threshold")
 
 
857
  progress(0.75, desc="Saving result")
858
- yield state("Saving result…", "Estimated remaining: a few seconds")
859
 
860
  logs.append("[3.5/4] Encoding MP3 on backend with FFmpeg")
861
  ffmpeg_convert_to_mp3(output, mp3_output, "192k")
862
  archive_dir, archived_files = private_archive(job_id, original, [output, mp3_output])
863
  elapsed = time.monotonic() - started
864
- record_job_finished(job_id, "Completed", elapsed, f"Silence removed: {before:.2f}s → {after:.2f}s", archive_dir, archived_files)
865
- record_performance_sample("Remove silence", "", "cpu", before, elapsed)
 
866
  stats, table = recent_public_history()
867
- logs.append(f"[4/4] Completed in {elapsed:.1f}s · private copy saved for admin")
868
  progress(1.0, desc="Completed")
869
- text = f"Original: {before:.3f} seconds\nProcessed: {after:.3f} seconds\nRemoved: {max(0, before - after):.3f} seconds"
870
- yield state("Completed — silence removed.", "ETA: complete", str(output), str(mp3_output), text, str(mp3_output))
 
 
 
 
 
871
  except Exception as exc:
872
  elapsed = time.monotonic() - started
873
  if audio_file:
874
  try:
875
- record_job_started(job_id, safe_filename(Path(audio_file).name), "Remove silence", 0)
876
  except sqlite3.IntegrityError:
877
  pass
878
  record_job_finished(job_id, "Failed", elapsed, str(exc))
@@ -899,7 +1142,7 @@ def process_enhance_audio(
899
  if not audio_file:
900
  raise ValueError("Upload an audio file before using Enhance Audio.")
901
  logs.append("[1/4] Preparing uploaded audio for real noise suppression")
902
- yield state("Preparing enhancement…", "ETA: calculating…")
903
  job_dir = make_job_directory(job_id)
904
  original = copy_input_to_job(audio_file, job_dir)
905
  duration = audio_duration(original)
@@ -914,7 +1157,7 @@ def process_enhance_audio(
914
  ffmpeg_convert_to_wav(original, wav_input, sample_rate=48000, mono=True)
915
  logs.append("[2/4] Converted to 48 kHz mono for speech-noise processing")
916
  progress(0.15, desc="Preparing audio")
917
- yield state("Running AI noise suppression…", f"Estimated remaining: {format_seconds(estimate)}")
918
 
919
  output_dir = job_dir / "deepfilter_output"
920
  output_dir.mkdir(exist_ok=True)
@@ -929,7 +1172,7 @@ def process_enhance_audio(
929
  for line, remaining, fraction in subprocess_progress(command, estimate, "DeepFilterNet", job_dir):
930
  logs.append(line)
931
  progress(min(0.9, 0.2 + 0.65 * fraction), desc="Suppressing noise")
932
- yield state("AI noise suppression is running…", f"Estimated remaining: {format_seconds(remaining)}")
933
  model_output = locate_enhanced_file(output_dir, wav_input)
934
  if not model_output:
935
  raise RuntimeError("DeepFilterNet completed but did not produce an audio file.")
@@ -942,7 +1185,7 @@ def process_enhance_audio(
942
 
943
  if used_fallback:
944
  logs.append("[3/4] Using spectral denoise fallback")
945
- yield state("Applying spectral noise suppression…", f"Estimated remaining: {format_seconds(estimate)}")
946
  heartbeat = run_with_heartbeat(
947
  lambda: spectral_denoise_fallback(original, output, strength),
948
  estimate,
@@ -952,7 +1195,7 @@ def process_enhance_audio(
952
  end_progress=0.88,
953
  )
954
  for pct, eta in heartbeat:
955
- yield state("Applying spectral noise suppression…", f"Estimated remaining: {eta}")
956
  logs.append("Spectral denoise and 80 Hz rumble filter completed (CPU fallback path).")
957
 
958
  # Keep output level consistent even if the denoiser's output is quiet.
@@ -966,9 +1209,9 @@ def process_enhance_audio(
966
  final_quality = "fallback" if used_fallback else "deepfilter"
967
  record_performance_sample("Enhance audio", final_quality, "cpu" if used_fallback else device, duration, elapsed)
968
  stats, table = recent_public_history()
969
- logs.append(f"[4/4] Completed in {elapsed:.1f}s · enhanced WAV is ready")
970
  progress(1.0, desc="Completed")
971
- yield state("Completed — background noise reduced and audio normalized.", "ETA: complete", str(output), str(mp3_output))
972
  except Exception as exc:
973
  elapsed = time.monotonic() - started
974
  if audio_file:
@@ -1005,7 +1248,7 @@ def process_voice_separation(
1005
  model = "htdemucs"
1006
  fast_mode = model_choice != "High quality"
1007
  logs.append("[1/4] Validating uploaded audio for stem separation")
1008
- yield state("Validating upload…", "ETA: calculating…")
1009
  job_dir = make_job_directory(job_id)
1010
  original = copy_input_to_job(audio_file, job_dir)
1011
  duration = audio_duration(original)
@@ -1018,7 +1261,7 @@ def process_voice_separation(
1018
  logs.append("[2/4] Preparing local HTDemucs model cache")
1019
  logs.append("Using Hugging Face mirror instead of dl.fbaipublicfiles.com")
1020
  progress(0.06, desc="Preparing separation model")
1021
- yield state("Preparing separation model…", "ETA: model setup")
1022
 
1023
  download_state = {"downloaded": 0, "total": None, "done": False, "error": None}
1024
 
@@ -1037,10 +1280,10 @@ def process_voice_separation(
1037
  downloaded, total = download_state["downloaded"], download_state["total"]
1038
  if total:
1039
  pct = downloaded / total
1040
- logs.append(f"Downloading separation model… {pct * 100:.0f}% ({downloaded // (1024 * 1024)}MB / {total // (1024 * 1024)}MB)")
1041
- yield state("Downloading separation model…", f"Model download: {pct * 100:.0f}%")
1042
  else:
1043
- yield state("Downloading separation model…", "ETA: model setup")
1044
  time.sleep(0.5)
1045
  if download_state["error"] is not None:
1046
  raise RuntimeError(
@@ -1049,9 +1292,9 @@ def process_voice_separation(
1049
  ) from download_state["error"]
1050
  model_repo = DEMUCS_MODEL_DIR
1051
 
1052
- logs.append(f"[3/4] {device.upper()} separation · shifts={0 if fast_mode else 1} · overlap={0.10 if fast_mode else 0.25} · segment=7s")
1053
  progress(0.10, desc=f"Starting {device.upper()} separation")
1054
- yield state("Separating voice and BGM…", f"Estimated remaining: {format_seconds(estimate)}")
1055
 
1056
  output_dir = job_dir / "separated"
1057
  command = [
@@ -1070,7 +1313,7 @@ def process_voice_separation(
1070
  for line, remaining, fraction in subprocess_progress(command, estimate, "Demucs", job_dir, env=demucs_env):
1071
  logs.append(line)
1072
  progress(min(0.92, 0.1 + 0.78 * fraction), desc="Separating stems")
1073
- yield state("Separating voice and BGM…", f"Estimated remaining: {format_seconds(remaining)}")
1074
 
1075
  vocals = locate_stem(output_dir, "vocals")
1076
  bgm = locate_stem(output_dir, "no_vocals")
@@ -1086,9 +1329,9 @@ def process_voice_separation(
1086
  record_job_finished(job_id, "Completed", elapsed, f"Demucs {model}: vocals and BGM isolated", archive_dir, archived_files)
1087
  record_performance_sample("Voice / BGM isolation", model_quality, device, duration, elapsed)
1088
  stats, table = recent_public_history()
1089
- logs.append(f"[4/4] Completed in {elapsed:.1f}s · both stems are ready")
1090
  progress(1.0, desc="Completed")
1091
- yield state("Completed — voice and BGM are available separately.", "ETA: complete", str(vocals), str(bgm), str(vocals_mp3), str(bgm_mp3))
1092
  except Exception as exc:
1093
  elapsed = time.monotonic() - started
1094
  if audio_file:
@@ -1115,6 +1358,12 @@ CSS = """
1115
  """
1116
 
1117
 
 
 
 
 
 
 
1118
  def ui() -> gr.Blocks:
1119
  stats, table = recent_public_history()
1120
  with gr.Blocks() as demo:
@@ -1123,14 +1372,14 @@ def ui() -> gr.Blocks:
1123
  admin_session_state = gr.State(value=None)
1124
 
1125
  gr.HTML(
1126
- f"<div class='notice'><b>Runtime:</b> {gpu_status()} · No application-level audio duration or file-size limit is enabled. "
1127
- "Estimated times are predictions that self-calibrate from this deployment's own completed jobs and adjust live if a job runs longer than predicted — not a guaranteed fixed duration.</div>"
1128
  )
1129
 
1130
  gr.HTML(
1131
  """
1132
  <div class='hero'>
1133
- <h1>🔇 Audio Cleanup Studio</h1>
1134
  <p>Remove silence, clean speech noise, and isolate voice or BGM without losing the original workflows.</p>
1135
  </div>
1136
  <div class='notice'><b>Privacy:</b> results are shown only to the current user. Uploaded and processed audio is archived privately for the administrator only; the public activity feed shows only the filename and selected action.</div>
@@ -1140,61 +1389,74 @@ def ui() -> gr.Blocks:
1140
  with gr.Row():
1141
  with gr.Column(scale=1):
1142
  audio_input = gr.Audio(
1143
- label="📂 Upload Audio",
1144
  type="filepath",
1145
  sources=["upload", "microphone"],
1146
  format="wav",
1147
  )
 
 
 
 
 
 
1148
  silence_threshold = gr.Number(
1149
  label="Keep Silence Up To (seconds)", value=0.05, minimum=0, precision=2
1150
  )
1151
- submit_btn = gr.Button("🔇 Remove Silence", variant="primary")
 
 
 
 
 
 
 
1152
  with gr.Column(scale=1):
1153
- audio_output = gr.Audio(label="â–¶ Play Audio")
1154
- file_output = gr.File(label="⬇ Download Audio File")
1155
- duration_output = gr.Textbox(label="📊 Duration Info", interactive=False)
1156
 
1157
- remove_status = gr.Textbox(label="âš¡ Remove Silence Status", interactive=False)
1158
- live_log = gr.Textbox(label="🧾 Live Processing Log", lines=8, interactive=False, elem_classes=["log-box"])
1159
- eta_output = gr.Textbox(label="⏱ Estimated Time", interactive=False)
1160
 
1161
  gr.HTML("<hr>")
1162
- gr.Markdown("âš¡ **Hugging Face ZeroGPU:** ML processing uses the allocated CUDA GPU; CPU fallback is used only when CUDA is unavailable.")
1163
- gr.Markdown("## ✨ Audio Enhancer\nReal speech-noise suppression is used first. If it cannot initialise, the app transparently falls back to spectral denoising, rumble filtering, and loudness normalization.")
1164
  with gr.Row():
1165
  with gr.Column(scale=1):
1166
  enhance_audio_input = gr.Audio(
1167
- label="📂 Upload Audio for Enhancement",
1168
  type="filepath",
1169
  sources=["upload", "microphone"],
1170
  )
1171
  enhancement_strength = gr.Radio(["Light", "Balanced", "Strong"], value="Balanced", label="Noise Cleaning Strength")
1172
- enhance_btn = gr.Button("✨ Enhance Audio", variant="primary")
1173
  with gr.Column(scale=1):
1174
- enhanced_audio_output = gr.Audio(label="â–¶ Enhanced Audio")
1175
- enhanced_file_output = gr.File(label="⬇ Download Enhanced Audio")
1176
- enhance_status = gr.Textbox(label="âš¡ Enhancement Status", interactive=False)
1177
 
1178
  gr.HTML("<hr>")
1179
- gr.Markdown("## 🎙️ Voice / BGM Isolation\nChoose this when you want **voice only** (remove BGM) or **BGM only** (remove voice). The two downloads are created together using Demucs separation.")
1180
  with gr.Row():
1181
  with gr.Column(scale=1):
1182
  isolate_audio_input = gr.Audio(
1183
- label="📂 Upload Audio for Voice / BGM Isolation",
1184
  type="filepath",
1185
  sources=["upload", "microphone"],
1186
  )
1187
  separation_model = gr.Radio(["High quality", "Faster"], value="High quality", label="Separation Quality")
1188
- isolate_btn = gr.Button("🎙️ Isolate Voice & BGM", variant="primary")
1189
  with gr.Column(scale=1):
1190
- vocals_output = gr.Audio(label="🎤 Voice Only")
1191
- bgm_output = gr.Audio(label="🎵 BGM Only (Voice Removed)")
1192
- vocal_file_output = gr.File(label="⬇ Download Voice Only")
1193
- bgm_file_output = gr.File(label="⬇ Download BGM Only")
1194
- separation_status = gr.Textbox(label="âš¡ Voice / BGM Status", interactive=False)
1195
 
1196
  gr.HTML("<hr>")
1197
- gr.Markdown("## 🌐 Public Processing Activity\nThis is an activity count, not a count of verified people. It intentionally never exposes audio files or downloads.")
1198
  public_stats = gr.HTML(value=stats)
1199
  public_history = gr.Dataframe(
1200
  headers=["Audio filename", "Action", "Status", "Activity time"],
@@ -1206,7 +1468,7 @@ def ui() -> gr.Blocks:
1206
  visitor_total_output = gr.Number(value=0, visible=False)
1207
 
1208
  gr.HTML("<hr>")
1209
- with gr.Accordion("🔐 Administrator: Private Audio Archive", open=False):
1210
  gr.Markdown("Enter the administrator access code to view archived source and output audio. The access code is read from the Space Secret `ADMIN_ACCESS_CODE` and is never stored in the app database.")
1211
  with gr.Row():
1212
  admin_code = gr.Textbox(label="Administrator access code", type="password")
@@ -1227,9 +1489,15 @@ def ui() -> gr.Blocks:
1227
  return path
1228
 
1229
  audio_input.change(remember_original, inputs=audio_input, outputs=original_audio_state, api_name=False)
 
 
 
 
 
 
1230
  submit_btn.click(
1231
  process_remove_silence,
1232
- inputs=[audio_input, silence_threshold],
1233
  outputs=[audio_output, file_output, duration_output, processed_file_state, remove_status, live_log, eta_output, public_stats, public_history],
1234
  api_name="process_audio",
1235
  )
@@ -1291,4 +1559,4 @@ def main() -> None:
1291
 
1292
 
1293
  if __name__ == "__main__":
1294
- main()
 
1
+ """Audio Cleanup Studio – a Gradio Space with real audio processing and private history.
2
 
3
  Deployment secrets and storage:
4
  - ADMIN_ACCESS_CODE: required to unlock the administrator archive.
5
  - DATA_DIR: optional persistent mounted directory; uses /data automatically when available.
6
  - HISTORY_RETENTION_DAYS: number of days to retain private audio artifacts (default: 30; 0 disables expiry).
7
  - MAX_AUDIO_MINUTES: optional maximum input duration; 0 means unlimited (default: 0).
8
+
9
+ Optional dependency:
10
+ - silero-vad (add `silero-vad` to requirements.txt) enables the AI speech detector used by
11
+ "Auto (AI detect)" silence removal. Without it, Auto mode still works using the built-in
12
+ adaptive noise-floor detector.
13
  """
14
 
15
  from __future__ import annotations
 
36
  from typing import Generator, Iterable
37
 
38
  import gradio as gr
39
+ import numpy as np
40
  from pydub import AudioSegment, effects
41
  from pydub.silence import split_on_silence
42
 
 
287
  rows = connection.execute(
288
  """
289
  SELECT job_id, public_filename, operation, status, created_at,
290
+ COALESCE(completed_at, '—') AS completed_at,
291
  COALESCE(processing_seconds, 0) AS processing_seconds
292
  FROM jobs
293
  ORDER BY created_ts DESC
 
303
  row["status"],
304
  row["created_at"],
305
  row["completed_at"],
306
+ f"{row['processing_seconds']:.1f}s" if row["processing_seconds"] else "—",
307
  ]
308
  for row in rows
309
  ]
 
321
  """
322
  ).fetchall()
323
  return [
324
+ (f"{row['activity_at']} · {row['public_filename']} · {row['operation']}", row["job_id"])
325
  for row in rows
326
  ]
327
 
 
446
  }
447
  _DEFAULT_RATE_PER_AUDIO_SECOND = {
448
  "Remove silence": 0.06,
449
+ "Remove silence::auto": 0.08,
450
  "Enhance audio::deepfilter": 0.5,
451
  "Enhance audio::fallback": 0.65,
452
  "Voice / BGM isolation::htdemucs-fast": 1.0,
 
457
  def estimated_seconds(operation: str, duration: float, quality: str = "", device: str = "cpu") -> float:
458
  """Best-available ETA: starts from a conservative default rate, then blends in
459
  this deployment's own measured processing rate (from performance_stats) as soon
460
+ as there is enough real history for that exact operation/quality/device — so the
461
  estimate keeps improving instead of staying a fixed guess forever."""
462
  overhead = _DEFAULT_OVERHEAD_SECONDS.get(operation, 15)
463
  default_key = f"{operation}::{quality}" if quality else operation
 
477
 
478
  def dynamic_remaining(elapsed: float, estimate: float) -> tuple[float, float]:
479
  """Keeps the on-screen ETA honest. The static formula above is only ever a
480
+ prediction — if the real job is taking longer than predicted, this grows the
481
  working estimate instead of freezing 'remaining' at 0 while work continues."""
482
  if elapsed < estimate * 0.9:
483
  return estimate, max(0.0, estimate - elapsed)
 
490
 
491
  def parse_progress_percent(line: str) -> float | None:
492
  """Demucs (and some DeepFilterNet builds) print a tqdm-style '45%|...' progress
493
+ bar to stdout/stderr. When present, this is real model progress — far more
494
+ accurate than a time-based guess — so we use it whenever we can detect it."""
495
  match = _PERCENT_PATTERN.search(line)
496
  if not match:
497
  return None
 
585
 
586
 
587
  def remove_silence_audio(source: Path, destination: Path, keep_silence_ms: int) -> tuple[float, float, float]:
588
+ """Custom mode: split on silence using an adaptive threshold and keep a fixed
589
+ amount of silence (keep_silence_ms) around every chunk."""
590
  sound = AudioSegment.from_file(source)
591
  before = len(sound) / 1000.0
592
  if sound.dBFS == float("-inf"):
 
611
  return before, after, threshold
612
 
613
 
614
+ # -----------------------------------------------------------------------------
615
+ # Auto silence removal (AI speech detector + adaptive noise-floor detector)
616
+ # -----------------------------------------------------------------------------
617
+ # style -> (min_silence_ms, pad_ms)
618
+ # min_silence_ms: pauses shorter than this are left untouched (natural commas / breathing)
619
+ # pad_ms: silence kept on each side of every sound segment, so word edges never get clipped
620
+ AUTO_STYLES: dict[str, tuple[int, int]] = {
621
+ "Natural": (450, 150),
622
+ "Balanced": (250, 90),
623
+ "Tight": (150, 40),
624
+ }
625
+ AUTO_FRAME_MS = 10
626
+ AUTO_ANALYSIS_RATE = 16000
627
+ AUTO_FADE_MS = 8
628
+
629
+ _VAD_LOCK = threading.Lock()
630
+ _VAD_MODEL = None
631
+ _VAD_UNAVAILABLE = False
632
+
633
+
634
+ def load_vad_model():
635
+ """Load Silero VAD once. Returns None when the package/model is unavailable."""
636
+ global _VAD_MODEL, _VAD_UNAVAILABLE
637
+ if _VAD_MODEL is not None:
638
+ return _VAD_MODEL
639
+ if _VAD_UNAVAILABLE:
640
+ return None
641
+ with _VAD_LOCK:
642
+ if _VAD_MODEL is None and not _VAD_UNAVAILABLE:
643
+ try:
644
+ from silero_vad import load_silero_vad
645
+ _VAD_MODEL = load_silero_vad()
646
+ except Exception:
647
+ _VAD_UNAVAILABLE = True
648
+ return _VAD_MODEL
649
+
650
+
651
+ def vad_speech_mask(samples: np.ndarray, n_frames: int) -> np.ndarray | None:
652
+ """Run Silero VAD and return a per-frame boolean mask (True = speech), or None if
653
+ the AI detector is unavailable or fails for any reason."""
654
+ model = load_vad_model()
655
+ if model is None:
656
+ return None
657
+ try:
658
+ import torch
659
+ from silero_vad import get_speech_timestamps
660
+
661
+ with torch.no_grad():
662
+ stamps = get_speech_timestamps(
663
+ torch.from_numpy(samples),
664
+ model,
665
+ sampling_rate=AUTO_ANALYSIS_RATE,
666
+ threshold=0.4, # slightly more sensitive than default so soft speech is never cut
667
+ min_speech_duration_ms=80,
668
+ min_silence_duration_ms=80,
669
+ speech_pad_ms=40,
670
+ )
671
+ except Exception:
672
+ return None
673
+ samples_per_frame = AUTO_ANALYSIS_RATE * AUTO_FRAME_MS // 1000
674
+ mask = np.zeros(n_frames, dtype=bool)
675
+ for stamp in stamps:
676
+ start = int(stamp["start"]) // samples_per_frame
677
+ end = -(-int(stamp["end"]) // samples_per_frame) # ceil
678
+ mask[start:end] = True
679
+ return mask
680
+
681
+
682
+ def _run_bounds(mask: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
683
+ """Start (inclusive) and end (exclusive) indices of every run of True values."""
684
+ edges = np.diff(np.concatenate(([0], mask.astype(np.int8), [0])))
685
+ return np.flatnonzero(edges == 1), np.flatnonzero(edges == -1)
686
+
687
+
688
+ def _otsu_threshold(values: np.ndarray, low: float = -120.0, high: float = 0.0, bins: int = 240) -> float:
689
+ """Otsu's method on a dB histogram: finds the valley between the 'quiet' and 'loud' groups."""
690
+ hist, edges = np.histogram(np.clip(values, low, high), bins=bins, range=(low, high))
691
+ hist = hist.astype(np.float64)
692
+ total = hist.sum()
693
+ if total <= 0:
694
+ return float(np.median(values))
695
+ centers = (edges[:-1] + edges[1:]) / 2.0
696
+ weight_low = np.cumsum(hist)
697
+ weight_high = total - weight_low
698
+ sum_low = np.cumsum(hist * centers)
699
+ with np.errstate(divide="ignore", invalid="ignore"):
700
+ mean_low = sum_low / weight_low
701
+ mean_high = (sum_low[-1] - sum_low) / weight_high
702
+ between = weight_low * weight_high * (mean_low - mean_high) ** 2
703
+ between = np.nan_to_num(between)
704
+ return float(centers[int(np.argmax(between))])
705
+
706
+
707
+ def energy_activity_mask(samples: np.ndarray) -> tuple[np.ndarray, float | None]:
708
+ """Noise-floor-aware activity detector on 10 ms frames.
709
+
710
+ The threshold is learned from the clip itself (so it adapts to quiet or loud
711
+ recordings and to room noise) and hysteresis keeps soft word endings attached
712
+ to the word. Returns (active_mask, threshold_db); threshold_db is None when the
713
+ clip has no clear gap between silence and sound (everything stays active)."""
714
+ frame_len = AUTO_ANALYSIS_RATE * AUTO_FRAME_MS // 1000
715
+ padding = (-len(samples)) % frame_len
716
+ if padding:
717
+ samples = np.pad(samples, (0, padding))
718
+ power = np.mean(np.square(samples.reshape(-1, frame_len), dtype=np.float32), axis=1)
719
+ power = np.convolve(power, np.ones(3, dtype=np.float32) / 3.0, mode="same")
720
+ db = 10.0 * np.log10(power + 1e-12)
721
+
722
+ low_ref = float(np.percentile(db, 5))
723
+ high_ref = float(np.percentile(db, 95))
724
+ if high_ref - low_ref < 10.0:
725
+ return np.ones(len(db), dtype=bool), None
726
+
727
+ otsu = _otsu_threshold(db)
728
+ quiet = db[db <= otsu]
729
+ noise_ref = float(np.percentile(quiet, 90)) if quiet.size else low_ref
730
+ threshold = min(otsu, noise_ref + 3.0) # just above the room-noise ceiling, never above the valley
731
+ threshold = max(threshold, -65.0) # below -65 dBFS is inaudible in practice
732
+ threshold = min(threshold, high_ref - 10.0) # always stay well under real content
733
+ seed_threshold = threshold + 5.0
734
+
735
+ starts, ends = _run_bounds(db > threshold)
736
+ active = np.zeros(len(db), dtype=bool)
737
+ min_frames = max(1, 40 // AUTO_FRAME_MS) # ignore clicks/pops shorter than 40 ms
738
+ for start, end in zip(starts, ends):
739
+ if end - start >= min_frames and db[start:end].max() >= seed_threshold:
740
+ active[start:end] = True
741
+ return active, threshold
742
+
743
+
744
+ def detect_active_segments(sound: AudioSegment) -> tuple[list[tuple[int, int]], dict]:
745
+ """Return [(start_ms, end_ms), ...] of every stretch that contains real sound."""
746
+ mono = sound.set_channels(1).set_frame_rate(AUTO_ANALYSIS_RATE).set_sample_width(2)
747
+ samples = np.frombuffer(mono.raw_data, dtype=np.int16).astype(np.float32) / 32768.0
748
+
749
+ energy_mask, threshold = energy_activity_mask(samples)
750
+ vad_mask = vad_speech_mask(samples, len(energy_mask))
751
+ if vad_mask is not None:
752
+ engine = "Silero VAD (AI) + adaptive noise-floor detector"
753
+ if threshold is None:
754
+ # No clear energy gap (steady noise / music bed): trust the AI only if it really found speech.
755
+ active = vad_mask if vad_mask.mean() >= 0.02 else energy_mask
756
+ else:
757
+ # Union = never cut something that either detector considers real sound.
758
+ active = energy_mask | vad_mask
759
+ else:
760
+ engine = "Adaptive noise-floor detector (install silero-vad for AI mode)"
761
+ active = energy_mask
762
+
763
+ starts, ends = _run_bounds(active)
764
+ segments = [(int(s) * AUTO_FRAME_MS, int(e) * AUTO_FRAME_MS) for s, e in zip(starts, ends)]
765
+ return segments, {"engine": engine, "threshold": threshold}
766
+
767
+
768
+ def build_keep_ranges(
769
+ segments: list[tuple[int, int]], total_ms: int, min_silence_ms: int, pad_ms: int
770
+ ) -> list[tuple[int, int]]:
771
+ """Merge segments separated by short pauses, then add edge padding to every kept range."""
772
+ merged: list[list[int]] = []
773
+ for start, end in segments:
774
+ if merged and start - merged[-1][1] < min_silence_ms:
775
+ merged[-1][1] = end
776
+ else:
777
+ merged.append([start, end])
778
+ keep: list[list[int]] = []
779
+ for start, end in merged:
780
+ start, end = max(0, start - pad_ms), min(total_ms, end + pad_ms)
781
+ if keep and start <= keep[-1][1]:
782
+ keep[-1][1] = max(keep[-1][1], end)
783
+ else:
784
+ keep.append([start, end])
785
+ return [(start, end) for start, end in keep]
786
+
787
+
788
+ def auto_remove_silence_audio(source: Path, destination: Path, style: str) -> dict:
789
+ """Auto mode: detect sound automatically, drop the silent gaps, and join the kept
790
+ parts with tiny fades so there are no clicks. Returns a small stats dict."""
791
+ sound = AudioSegment.from_file(source)
792
+ total_ms = len(sound)
793
+ if total_ms <= 0 or sound.dBFS == float("-inf"):
794
+ raise ValueError("The uploaded file is completely silent.")
795
+
796
+ min_silence_ms, pad_ms = AUTO_STYLES.get(style, AUTO_STYLES["Balanced"])
797
+ segments, info = detect_active_segments(sound)
798
+ if not segments:
799
+ raise ValueError("Auto detection could not find any non-silent audio.")
800
+ keep = build_keep_ranges(segments, total_ms, min_silence_ms, pad_ms)
801
+
802
+ pieces: list[bytes] = []
803
+ for index, (start, end) in enumerate(keep):
804
+ chunk = sound[start:end]
805
+ fade = min(AUTO_FADE_MS, len(chunk) // 2)
806
+ if fade > 0:
807
+ if index > 0:
808
+ chunk = chunk.fade_in(fade)
809
+ if index < len(keep) - 1:
810
+ chunk = chunk.fade_out(fade)
811
+ pieces.append(chunk.raw_data)
812
+ combined = sound._spawn(b"".join(pieces))
813
+ combined.export(destination, format="wav")
814
+ return {
815
+ "before": total_ms / 1000.0,
816
+ "after": len(combined) / 1000.0,
817
+ "threshold": info["threshold"],
818
+ "engine": info["engine"],
819
+ "cuts": max(0, len(keep) - 1),
820
+ }
821
+
822
+
823
  def spectral_denoise_fallback(source: Path, destination: Path, strength: str) -> None:
824
  """A real, audible fallback when DeepFilterNet cannot initialise."""
825
  import numpy as np
 
879
  import torch
880
  if torch.cuda.is_available():
881
  name = torch.cuda.get_device_name(0)
882
+ return f"GPU: CUDA enabled · {name}"
883
  except Exception:
884
  pass
885
+ return "GPU: unavailable · CPU fallback"
886
 
887
  def subprocess_progress(
888
  command: list[str],
 
893
  ) -> Generator[tuple[str, float, float], None, None]:
894
  """Yield (latest_log_line, remaining_seconds, progress_fraction) while a model
895
  subprocess runs. When the tool prints its own tqdm-style '45%|...' progress,
896
+ that real progress drives both the fraction and the ETA math — otherwise this
897
  falls back to the honest, self-correcting time estimate."""
898
  process = subprocess.Popen(
899
  command,
 
927
  break
928
  elapsed = time.monotonic() - started
929
  if elapsed - last_yield >= 1.0:
930
+ latest = output_lines[-1] if output_lines else f"{label} is running…"
931
  detected = parse_progress_percent(latest)
932
  if detected is not None:
933
  real_progress = detected
 
1021
  return result.get("value")
1022
 
1023
  # -----------------------------------------------------------------------------
1024
+ # Gradio processing flows – generators make logs and ETA visible during work.
1025
  # -----------------------------------------------------------------------------
1026
  def process_remove_silence(
1027
  audio_file: str | None,
1028
  keep_silence_seconds: float,
1029
+ silence_mode: str = "Custom value",
1030
+ auto_style: str = "Balanced",
1031
  progress: gr.Progress = gr.Progress(),
1032
  ):
1033
  logs: TimedLogList = TimedLogList()
1034
  started = time.monotonic()
1035
  job_id = uuid.uuid4().hex
1036
  stats, table = recent_public_history()
1037
+ auto_mode = str(silence_mode).startswith("Auto")
1038
+ operation_name = "Remove silence (Auto)" if auto_mode else "Remove silence"
1039
+ quality_key = "auto" if auto_mode else ""
1040
 
1041
  def state(status: str, eta: str, audio=gr.skip(), file=gr.skip(), duration=gr.skip(), processed=gr.skip()):
1042
  return audio, file, duration, processed, status, "\n".join(logs), eta, stats, table
 
1045
  if not audio_file:
1046
  raise ValueError("Upload an audio file before processing.")
1047
  logs.append("[1/4] Validating uploaded file")
1048
+ yield state("Validating upload…", "ETA: calculating…")
1049
 
1050
  job_dir = make_job_directory(job_id)
1051
  original = copy_input_to_job(audio_file, job_dir)
1052
  duration = audio_duration(original)
1053
  validate_duration(duration)
1054
+ record_job_started(job_id, safe_filename(Path(audio_file).name), operation_name, duration)
1055
+ estimate = estimated_seconds("Remove silence", duration, quality_key, "cpu")
1056
+ if auto_mode:
1057
+ logs.append(f"[2/4] Duration: {duration:.2f}s · Auto mode ({auto_style}) · automatic silence detection")
1058
+ else:
1059
+ logs.append(f"[2/4] Duration: {duration:.2f}s · adaptive silence detection enabled")
1060
  progress(0.2, desc="Detecting silence")
1061
+ yield state("Detecting silent sections…", f"Estimated remaining: {format_seconds(estimate)}")
1062
 
1063
  output = job_dir / f"{original.stem}_silence_removed.wav"
1064
  mp3_output = job_dir / f"{original.stem}_silence_removed.mp3"
1065
+ keep_ms = int(max(0.0, float(keep_silence_seconds or 0)) * 1000)
1066
+ result_box: dict = {}
1067
+
1068
+ def work() -> None:
1069
+ if auto_mode:
1070
+ result_box.update(auto_remove_silence_audio(original, output, auto_style))
1071
+ else:
1072
+ before_s, after_s, threshold_db = remove_silence_audio(original, output, keep_ms)
1073
+ result_box.update(before=before_s, after=after_s, threshold=threshold_db)
1074
+
1075
  heartbeat = run_with_heartbeat(
1076
+ work,
1077
  estimate,
1078
  progress,
1079
  lambda pct, eta: "Detecting silent sections",
 
1081
  end_progress=0.72,
1082
  )
1083
  for pct, eta in heartbeat:
1084
+ yield state("Detecting silent sections…", f"Estimated remaining: {eta}")
1085
+
1086
+ before = result_box["before"]
1087
+ after = result_box["after"]
1088
+ if auto_mode:
1089
+ threshold_db = result_box.get("threshold")
1090
+ threshold_text = f"{threshold_db:.1f} dB" if threshold_db is not None else "no clear noise gap (kept everything)"
1091
+ logs.append(f"[3/4] {result_box['engine']} · threshold {threshold_text} · {result_box['cuts']} cut(s)")
1092
+ else:
1093
+ logs.append(f"[3/4] Removed silence using {result_box['threshold']:.1f} dB threshold")
1094
  progress(0.75, desc="Saving result")
1095
+ yield state("Saving result…", "Estimated remaining: a few seconds")
1096
 
1097
  logs.append("[3.5/4] Encoding MP3 on backend with FFmpeg")
1098
  ffmpeg_convert_to_mp3(output, mp3_output, "192k")
1099
  archive_dir, archived_files = private_archive(job_id, original, [output, mp3_output])
1100
  elapsed = time.monotonic() - started
1101
+ mode_label = f"Auto ({auto_style})" if auto_mode else "Custom value"
1102
+ record_job_finished(job_id, "Completed", elapsed, f"Silence removed [{mode_label}]: {before:.2f}s → {after:.2f}s", archive_dir, archived_files)
1103
+ record_performance_sample("Remove silence", quality_key, "cpu", before, elapsed)
1104
  stats, table = recent_public_history()
1105
+ logs.append(f"[4/4] Completed in {elapsed:.1f}s · private copy saved for admin")
1106
  progress(1.0, desc="Completed")
1107
+ text = (
1108
+ f"Mode: {mode_label}\n"
1109
+ f"Original: {before:.3f} seconds\n"
1110
+ f"Processed: {after:.3f} seconds\n"
1111
+ f"Removed: {max(0, before - after):.3f} seconds"
1112
+ )
1113
+ yield state("Completed — silence removed.", "ETA: complete", str(output), str(mp3_output), text, str(mp3_output))
1114
  except Exception as exc:
1115
  elapsed = time.monotonic() - started
1116
  if audio_file:
1117
  try:
1118
+ record_job_started(job_id, safe_filename(Path(audio_file).name), operation_name, 0)
1119
  except sqlite3.IntegrityError:
1120
  pass
1121
  record_job_finished(job_id, "Failed", elapsed, str(exc))
 
1142
  if not audio_file:
1143
  raise ValueError("Upload an audio file before using Enhance Audio.")
1144
  logs.append("[1/4] Preparing uploaded audio for real noise suppression")
1145
+ yield state("Preparing enhancement…", "ETA: calculating…")
1146
  job_dir = make_job_directory(job_id)
1147
  original = copy_input_to_job(audio_file, job_dir)
1148
  duration = audio_duration(original)
 
1157
  ffmpeg_convert_to_wav(original, wav_input, sample_rate=48000, mono=True)
1158
  logs.append("[2/4] Converted to 48 kHz mono for speech-noise processing")
1159
  progress(0.15, desc="Preparing audio")
1160
+ yield state("Running AI noise suppression…", f"Estimated remaining: {format_seconds(estimate)}")
1161
 
1162
  output_dir = job_dir / "deepfilter_output"
1163
  output_dir.mkdir(exist_ok=True)
 
1172
  for line, remaining, fraction in subprocess_progress(command, estimate, "DeepFilterNet", job_dir):
1173
  logs.append(line)
1174
  progress(min(0.9, 0.2 + 0.65 * fraction), desc="Suppressing noise")
1175
+ yield state("AI noise suppression is running…", f"Estimated remaining: {format_seconds(remaining)}")
1176
  model_output = locate_enhanced_file(output_dir, wav_input)
1177
  if not model_output:
1178
  raise RuntimeError("DeepFilterNet completed but did not produce an audio file.")
 
1185
 
1186
  if used_fallback:
1187
  logs.append("[3/4] Using spectral denoise fallback")
1188
+ yield state("Applying spectral noise suppression…", f"Estimated remaining: {format_seconds(estimate)}")
1189
  heartbeat = run_with_heartbeat(
1190
  lambda: spectral_denoise_fallback(original, output, strength),
1191
  estimate,
 
1195
  end_progress=0.88,
1196
  )
1197
  for pct, eta in heartbeat:
1198
+ yield state("Applying spectral noise suppression…", f"Estimated remaining: {eta}")
1199
  logs.append("Spectral denoise and 80 Hz rumble filter completed (CPU fallback path).")
1200
 
1201
  # Keep output level consistent even if the denoiser's output is quiet.
 
1209
  final_quality = "fallback" if used_fallback else "deepfilter"
1210
  record_performance_sample("Enhance audio", final_quality, "cpu" if used_fallback else device, duration, elapsed)
1211
  stats, table = recent_public_history()
1212
+ logs.append(f"[4/4] Completed in {elapsed:.1f}s · enhanced WAV is ready")
1213
  progress(1.0, desc="Completed")
1214
+ yield state("Completed — background noise reduced and audio normalized.", "ETA: complete", str(output), str(mp3_output))
1215
  except Exception as exc:
1216
  elapsed = time.monotonic() - started
1217
  if audio_file:
 
1248
  model = "htdemucs"
1249
  fast_mode = model_choice != "High quality"
1250
  logs.append("[1/4] Validating uploaded audio for stem separation")
1251
+ yield state("Validating upload…", "ETA: calculating…")
1252
  job_dir = make_job_directory(job_id)
1253
  original = copy_input_to_job(audio_file, job_dir)
1254
  duration = audio_duration(original)
 
1261
  logs.append("[2/4] Preparing local HTDemucs model cache")
1262
  logs.append("Using Hugging Face mirror instead of dl.fbaipublicfiles.com")
1263
  progress(0.06, desc="Preparing separation model")
1264
+ yield state("Preparing separation model…", "ETA: model setup")
1265
 
1266
  download_state = {"downloaded": 0, "total": None, "done": False, "error": None}
1267
 
 
1280
  downloaded, total = download_state["downloaded"], download_state["total"]
1281
  if total:
1282
  pct = downloaded / total
1283
+ logs.append(f"Downloading separation model… {pct * 100:.0f}% ({downloaded // (1024 * 1024)}MB / {total // (1024 * 1024)}MB)")
1284
+ yield state("Downloading separation model…", f"Model download: {pct * 100:.0f}%")
1285
  else:
1286
+ yield state("Downloading separation model…", "ETA: model setup")
1287
  time.sleep(0.5)
1288
  if download_state["error"] is not None:
1289
  raise RuntimeError(
 
1292
  ) from download_state["error"]
1293
  model_repo = DEMUCS_MODEL_DIR
1294
 
1295
+ logs.append(f"[3/4] {device.upper()} separation · shifts={0 if fast_mode else 1} · overlap={0.10 if fast_mode else 0.25} · segment=7s")
1296
  progress(0.10, desc=f"Starting {device.upper()} separation")
1297
+ yield state("Separating voice and BGM…", f"Estimated remaining: {format_seconds(estimate)}")
1298
 
1299
  output_dir = job_dir / "separated"
1300
  command = [
 
1313
  for line, remaining, fraction in subprocess_progress(command, estimate, "Demucs", job_dir, env=demucs_env):
1314
  logs.append(line)
1315
  progress(min(0.92, 0.1 + 0.78 * fraction), desc="Separating stems")
1316
+ yield state("Separating voice and BGM…", f"Estimated remaining: {format_seconds(remaining)}")
1317
 
1318
  vocals = locate_stem(output_dir, "vocals")
1319
  bgm = locate_stem(output_dir, "no_vocals")
 
1329
  record_job_finished(job_id, "Completed", elapsed, f"Demucs {model}: vocals and BGM isolated", archive_dir, archived_files)
1330
  record_performance_sample("Voice / BGM isolation", model_quality, device, duration, elapsed)
1331
  stats, table = recent_public_history()
1332
+ logs.append(f"[4/4] Completed in {elapsed:.1f}s · both stems are ready")
1333
  progress(1.0, desc="Completed")
1334
+ yield state("Completed — voice and BGM are available separately.", "ETA: complete", str(vocals), str(bgm), str(vocals_mp3), str(bgm_mp3))
1335
  except Exception as exc:
1336
  elapsed = time.monotonic() - started
1337
  if audio_file:
 
1358
  """
1359
 
1360
 
1361
+ def toggle_silence_controls(mode: str):
1362
+ """Custom mode shows the 'keep silence' number; Auto mode shows the cut-style picker."""
1363
+ auto = str(mode).startswith("Auto")
1364
+ return gr.update(visible=not auto), gr.update(visible=auto)
1365
+
1366
+
1367
  def ui() -> gr.Blocks:
1368
  stats, table = recent_public_history()
1369
  with gr.Blocks() as demo:
 
1372
  admin_session_state = gr.State(value=None)
1373
 
1374
  gr.HTML(
1375
+ f"<div class='notice'><b>Runtime:</b> {gpu_status()} · No application-level audio duration or file-size limit is enabled. "
1376
+ "Estimated times are predictions that self-calibrate from this deployment's own completed jobs and adjust live if a job runs longer than predicted — not a guaranteed fixed duration.</div>"
1377
  )
1378
 
1379
  gr.HTML(
1380
  """
1381
  <div class='hero'>
1382
+ <h1>🔇 Audio Cleanup Studio</h1>
1383
  <p>Remove silence, clean speech noise, and isolate voice or BGM without losing the original workflows.</p>
1384
  </div>
1385
  <div class='notice'><b>Privacy:</b> results are shown only to the current user. Uploaded and processed audio is archived privately for the administrator only; the public activity feed shows only the filename and selected action.</div>
 
1389
  with gr.Row():
1390
  with gr.Column(scale=1):
1391
  audio_input = gr.Audio(
1392
+ label="📂 Upload Audio",
1393
  type="filepath",
1394
  sources=["upload", "microphone"],
1395
  format="wav",
1396
  )
1397
+ silence_mode = gr.Radio(
1398
+ ["Custom value", "Auto (AI detect)"],
1399
+ value="Custom value",
1400
+ label="Silence Removal Mode",
1401
+ info="Custom = you choose how much silence to keep · Auto = silence is detected and removed automatically",
1402
+ )
1403
  silence_threshold = gr.Number(
1404
  label="Keep Silence Up To (seconds)", value=0.05, minimum=0, precision=2
1405
  )
1406
+ auto_style = gr.Radio(
1407
+ ["Natural", "Balanced", "Tight"],
1408
+ value="Balanced",
1409
+ label="Auto Cut Style",
1410
+ info="Natural = only long pauses trimmed · Balanced = recommended · Tight = fast-paced, very short gaps",
1411
+ visible=False,
1412
+ )
1413
+ submit_btn = gr.Button("🔇 Remove Silence", variant="primary")
1414
  with gr.Column(scale=1):
1415
+ audio_output = gr.Audio(label="▶ Play Audio")
1416
+ file_output = gr.File(label="⬇ Download Audio File")
1417
+ duration_output = gr.Textbox(label="📊 Duration Info", interactive=False)
1418
 
1419
+ remove_status = gr.Textbox(label="⚡ Remove Silence Status", interactive=False)
1420
+ live_log = gr.Textbox(label="🧾 Live Processing Log", lines=8, interactive=False, elem_classes=["log-box"])
1421
+ eta_output = gr.Textbox(label="⏱ Estimated Time", interactive=False)
1422
 
1423
  gr.HTML("<hr>")
1424
+ gr.Markdown("⚡ **Hugging Face ZeroGPU:** ML processing uses the allocated CUDA GPU; CPU fallback is used only when CUDA is unavailable.")
1425
+ gr.Markdown("## ✨ Audio Enhancer\nReal speech-noise suppression is used first. If it cannot initialise, the app transparently falls back to spectral denoising, rumble filtering, and loudness normalization.")
1426
  with gr.Row():
1427
  with gr.Column(scale=1):
1428
  enhance_audio_input = gr.Audio(
1429
+ label="📂 Upload Audio for Enhancement",
1430
  type="filepath",
1431
  sources=["upload", "microphone"],
1432
  )
1433
  enhancement_strength = gr.Radio(["Light", "Balanced", "Strong"], value="Balanced", label="Noise Cleaning Strength")
1434
+ enhance_btn = gr.Button("✨ Enhance Audio", variant="primary")
1435
  with gr.Column(scale=1):
1436
+ enhanced_audio_output = gr.Audio(label="▶ Enhanced Audio")
1437
+ enhanced_file_output = gr.File(label="⬇ Download Enhanced Audio")
1438
+ enhance_status = gr.Textbox(label="⚡ Enhancement Status", interactive=False)
1439
 
1440
  gr.HTML("<hr>")
1441
+ gr.Markdown("## 🎙️ Voice / BGM Isolation\nChoose this when you want **voice only** (remove BGM) or **BGM only** (remove voice). The two downloads are created together using Demucs separation.")
1442
  with gr.Row():
1443
  with gr.Column(scale=1):
1444
  isolate_audio_input = gr.Audio(
1445
+ label="📂 Upload Audio for Voice / BGM Isolation",
1446
  type="filepath",
1447
  sources=["upload", "microphone"],
1448
  )
1449
  separation_model = gr.Radio(["High quality", "Faster"], value="High quality", label="Separation Quality")
1450
+ isolate_btn = gr.Button("🎙️ Isolate Voice & BGM", variant="primary")
1451
  with gr.Column(scale=1):
1452
+ vocals_output = gr.Audio(label="🎤 Voice Only")
1453
+ bgm_output = gr.Audio(label="🎵 BGM Only (Voice Removed)")
1454
+ vocal_file_output = gr.File(label="⬇ Download Voice Only")
1455
+ bgm_file_output = gr.File(label="⬇ Download BGM Only")
1456
+ separation_status = gr.Textbox(label="⚡ Voice / BGM Status", interactive=False)
1457
 
1458
  gr.HTML("<hr>")
1459
+ gr.Markdown("## 🌐 Public Processing Activity\nThis is an activity count, not a count of verified people. It intentionally never exposes audio files or downloads.")
1460
  public_stats = gr.HTML(value=stats)
1461
  public_history = gr.Dataframe(
1462
  headers=["Audio filename", "Action", "Status", "Activity time"],
 
1468
  visitor_total_output = gr.Number(value=0, visible=False)
1469
 
1470
  gr.HTML("<hr>")
1471
+ with gr.Accordion("🔐 Administrator: Private Audio Archive", open=False):
1472
  gr.Markdown("Enter the administrator access code to view archived source and output audio. The access code is read from the Space Secret `ADMIN_ACCESS_CODE` and is never stored in the app database.")
1473
  with gr.Row():
1474
  admin_code = gr.Textbox(label="Administrator access code", type="password")
 
1489
  return path
1490
 
1491
  audio_input.change(remember_original, inputs=audio_input, outputs=original_audio_state, api_name=False)
1492
+ silence_mode.change(
1493
+ toggle_silence_controls,
1494
+ inputs=silence_mode,
1495
+ outputs=[silence_threshold, auto_style],
1496
+ api_name=False,
1497
+ )
1498
  submit_btn.click(
1499
  process_remove_silence,
1500
+ inputs=[audio_input, silence_threshold, silence_mode, auto_style],
1501
  outputs=[audio_output, file_output, duration_output, processed_file_state, remove_status, live_log, eta_output, public_stats, public_history],
1502
  api_name="process_audio",
1503
  )
 
1559
 
1560
 
1561
  if __name__ == "__main__":
1562
+ main()