Update app.py
Browse files
app.py
CHANGED
|
@@ -1,10 +1,15 @@
|
|
| 1 |
-
"""Audio Cleanup Studio
|
| 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, '
|
| 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']}
|
| 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
|
| 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
|
| 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
|
| 487 |
-
accurate than a time-based guess
|
| 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
|
| 665 |
except Exception:
|
| 666 |
pass
|
| 667 |
-
return "GPU: unavailable
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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),
|
| 832 |
-
estimate = estimated_seconds("Remove silence", duration,
|
| 833 |
-
|
|
|
|
|
|
|
|
|
|
| 834 |
progress(0.2, desc="Detecting silence")
|
| 835 |
-
yield state("Detecting silent
|
| 836 |
|
| 837 |
output = job_dir / f"{original.stem}_silence_removed.wav"
|
| 838 |
mp3_output = job_dir / f"{original.stem}_silence_removed.mp3"
|
| 839 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 840 |
heartbeat = run_with_heartbeat(
|
| 841 |
-
|
| 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
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
|
|
|
|
|
|
| 857 |
progress(0.75, desc="Saving result")
|
| 858 |
-
yield state("Saving
|
| 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 |
-
|
| 865 |
-
|
|
|
|
| 866 |
stats, table = recent_public_history()
|
| 867 |
-
logs.append(f"[4/4] Completed in {elapsed:.1f}s
|
| 868 |
progress(1.0, desc="Completed")
|
| 869 |
-
text =
|
| 870 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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),
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 970 |
progress(1.0, desc="Completed")
|
| 971 |
-
yield state("Completed
|
| 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
|
| 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
|
| 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
|
| 1041 |
-
yield state("Downloading separation
|
| 1042 |
else:
|
| 1043 |
-
yield state("Downloading separation
|
| 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
|
| 1053 |
progress(0.10, desc=f"Starting {device.upper()} separation")
|
| 1054 |
-
yield state("Separating voice and
|
| 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
|
| 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
|
| 1090 |
progress(1.0, desc="Completed")
|
| 1091 |
-
yield state("Completed
|
| 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()}
|
| 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
|
| 1128 |
)
|
| 1129 |
|
| 1130 |
gr.HTML(
|
| 1131 |
"""
|
| 1132 |
<div class='hero'>
|
| 1133 |
-
<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="
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1152 |
with gr.Column(scale=1):
|
| 1153 |
-
audio_output = gr.Audio(label="
|
| 1154 |
-
file_output = gr.File(label="
|
| 1155 |
-
duration_output = gr.Textbox(label="
|
| 1156 |
|
| 1157 |
-
remove_status = gr.Textbox(label="
|
| 1158 |
-
live_log = gr.Textbox(label="
|
| 1159 |
-
eta_output = gr.Textbox(label="
|
| 1160 |
|
| 1161 |
gr.HTML("<hr>")
|
| 1162 |
-
gr.Markdown("
|
| 1163 |
-
gr.Markdown("##
|
| 1164 |
with gr.Row():
|
| 1165 |
with gr.Column(scale=1):
|
| 1166 |
enhance_audio_input = gr.Audio(
|
| 1167 |
-
label="
|
| 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("
|
| 1173 |
with gr.Column(scale=1):
|
| 1174 |
-
enhanced_audio_output = gr.Audio(label="
|
| 1175 |
-
enhanced_file_output = gr.File(label="
|
| 1176 |
-
enhance_status = gr.Textbox(label="
|
| 1177 |
|
| 1178 |
gr.HTML("<hr>")
|
| 1179 |
-
gr.Markdown("##
|
| 1180 |
with gr.Row():
|
| 1181 |
with gr.Column(scale=1):
|
| 1182 |
isolate_audio_input = gr.Audio(
|
| 1183 |
-
label="
|
| 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("
|
| 1189 |
with gr.Column(scale=1):
|
| 1190 |
-
vocals_output = gr.Audio(label="
|
| 1191 |
-
bgm_output = gr.Audio(label="
|
| 1192 |
-
vocal_file_output = gr.File(label="
|
| 1193 |
-
bgm_file_output = gr.File(label="
|
| 1194 |
-
separation_status = gr.Textbox(label="
|
| 1195 |
|
| 1196 |
gr.HTML("<hr>")
|
| 1197 |
-
gr.Markdown("##
|
| 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("
|
| 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()
|