AudioSplit / app.py
Ivan000's picture
Update app.py
2516b23 verified
# audio_split_zip_gradio.py
# Gradio app: upload big audio -> split into N-second parts -> download ZIP (mp3 parts)
# UI: English + custom neon/glass style with subtle animations
import os
import math
import re
import time
import tempfile
import zipfile
import subprocess
from pathlib import Path
import gradio as gr
# -------------------------
# Subprocess helpers
# -------------------------
def _run(cmd):
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if p.returncode != 0:
raise RuntimeError(f"Command failed:\n{' '.join(cmd)}\n\n{p.stderr.strip()}")
return p.stdout, p.stderr
def ffprobe_info(path: str) -> dict:
"""
Returns:
duration_sec: float|None
bitrate_bps: int|None (tries stream bitrate, then format bitrate)
"""
info = {"duration_sec": None, "bitrate_bps": None}
out, _ = _run([
"ffprobe", "-v", "error",
"-show_entries", "format=duration,bit_rate",
"-of", "default=noprint_wrappers=1:nokey=0",
path
])
for line in out.splitlines():
if "=" not in line:
continue
k, v = line.split("=", 1)
k, v = k.strip(), v.strip()
if k == "duration":
try:
info["duration_sec"] = float(v)
except Exception:
pass
elif k == "bit_rate":
try:
info["bitrate_bps"] = int(v)
except Exception:
pass
out2, _ = _run([
"ffprobe", "-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=bit_rate",
"-of", "default=noprint_wrappers=1:nokey=0",
path
])
for line in out2.splitlines():
if "=" not in line:
continue
k, v = line.split("=", 1)
if k.strip() == "bit_rate":
try:
info["bitrate_bps"] = int(v.strip())
except Exception:
pass
return info
def to_kbps(bps: int | None) -> int | None:
if not bps:
return None
return max(1, int(round(bps / 1000)))
def sanitize_filename(name: str) -> str:
name = re.sub(r"[^\w\-.() ]+", "_", name, flags=re.UNICODE).strip().strip(".")
return name or "audio"
def format_seconds(s: float) -> str:
s = max(0, float(s))
hh = int(s // 3600)
mm = int((s % 3600) // 60)
ss = int(s % 60)
return f"{hh:02d}:{mm:02d}:{ss:02d}" if hh > 0 else f"{mm:02d}:{ss:02d}"
# -------------------------
# Temp cleanup
# -------------------------
def cleanup_tmpdirs(prefix="audiosplit_", older_than_seconds=6 * 3600):
tmp_root = Path(tempfile.gettempdir())
now = time.time()
for p in tmp_root.glob(prefix + "*"):
try:
if not p.is_dir():
continue
age = now - p.stat().st_mtime
if age > older_than_seconds:
import shutil
shutil.rmtree(p, ignore_errors=True)
except Exception:
pass
# -------------------------
# Main logic
# -------------------------
def split_and_zip(file_path: str, chunk_seconds: int, quality_mode: str, custom_kbps: int):
if not file_path or not os.path.exists(file_path):
raise gr.Error("Please upload an audio file.")
try:
chunk_seconds = int(chunk_seconds)
except Exception:
raise gr.Error("Chunk length must be an integer (seconds).")
if chunk_seconds <= 0:
raise gr.Error("Chunk length must be greater than 0 seconds.")
cleanup_tmpdirs()
info = ffprobe_info(file_path)
duration = info.get("duration_sec")
src_kbps = to_kbps(info.get("bitrate_bps"))
if duration is None:
raise gr.Error("Couldn't detect audio duration. Check ffmpeg/ffprobe and the file format.")
# bitrate selection
if quality_mode == "Auto (same as source)":
out_kbps = src_kbps if src_kbps else 192
out_quality_text = f"Auto → {out_kbps} kbps" + ("" if src_kbps else " (fallback)")
else:
out_kbps = int(max(8, min(320, int(custom_kbps))))
out_quality_text = f"Custom → {out_kbps} kbps"
base = sanitize_filename(Path(file_path).stem)
tmpdir = tempfile.mkdtemp(prefix="audiosplit_")
parts_dir = os.path.join(tmpdir, "parts")
os.makedirs(parts_dir, exist_ok=True)
total_parts = int(math.ceil(duration / chunk_seconds))
digits = max(3, len(str(total_parts)))
created = []
for i in range(total_parts):
start = i * chunk_seconds
remaining = max(0.0, duration - start)
this_len = min(float(chunk_seconds), remaining)
out_name = (
f"{base}_part_{str(i+1).zfill(digits)}_"
f"{format_seconds(start)}-{format_seconds(start+this_len)}.mp3"
)
out_path = os.path.join(parts_dir, out_name)
cmd = [
"ffmpeg", "-y",
"-ss", str(start),
"-t", str(this_len),
"-i", file_path,
"-vn",
"-c:a", "libmp3lame",
"-b:a", f"{out_kbps}k",
out_path
]
_run(cmd)
created.append(out_path)
zip_path = os.path.join(tmpdir, f"{base}_split_{chunk_seconds}s_mp3.zip")
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in created:
zf.write(p, arcname=os.path.basename(p))
src_txt = f"{src_kbps} kbps" if src_kbps else "unknown"
status = (
f"Input: {Path(file_path).name}\n"
f"Duration: {duration:.2f} sec\n"
f"Chunk length: {chunk_seconds} sec\n"
f"Parts: {total_parts}\n"
f"Source bitrate: {src_txt}\n"
f"Output: mp3\n"
f"Quality: {out_quality_text}\n"
f"ZIP is ready."
)
return zip_path, status
# -------------------------
# UI styling
# -------------------------
CSS = r"""
/* --------- Neon / Glass Theme (subtle animations) --------- */
:root{
--bg0:#05060a;
--bg1:#070a12;
--panel: rgba(255,255,255,0.06);
--panel2: rgba(0,0,0,0.25);
--stroke: rgba(255,255,255,0.14);
--txt: rgba(255,255,255,0.92);
--muted: rgba(255,255,255,0.70);
--neon1:#7c3aed; /* purple */
--neon2:#22d3ee; /* cyan */
--neon3:#f97316; /* orange */
--good:#34d399;
--bad:#fb7185;
}
.gradio-container {
color: var(--txt) !important;
background:
radial-gradient(900px 600px at 20% 10%, rgba(124,58,237,0.35), transparent 55%),
radial-gradient(700px 500px at 80% 20%, rgba(34,211,238,0.25), transparent 60%),
radial-gradient(700px 500px at 50% 90%, rgba(249,115,22,0.15), transparent 60%),
linear-gradient(180deg, var(--bg0), var(--bg1)) !important;
}
#app {
max-width: 980px;
margin: 0 auto;
padding: 18px 14px 28px;
}
.glass {
background: var(--panel);
border: 1px solid var(--stroke);
border-radius: 18px;
padding: 16px;
backdrop-filter: blur(10px);
box-shadow:
0 0 0 1px rgba(255,255,255,0.04) inset,
0 14px 50px rgba(0,0,0,0.55);
}
.header {
border-radius: 18px;
padding: 16px 18px;
border: 1px solid rgba(255,255,255,0.12);
background:
linear-gradient(135deg, rgba(124,58,237,0.16), rgba(34,211,238,0.10) 55%, rgba(249,115,22,0.07));
box-shadow: 0 14px 50px rgba(0,0,0,0.55);
}
#title {
margin: 0;
font-size: 24px;
letter-spacing: 0.2px;
line-height: 1.15;
background: linear-gradient(90deg, var(--neon2), var(--neon1), var(--neon3));
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: hueflow 6s ease-in-out infinite;
}
@keyframes hueflow {
0% { background-position: 0% 50%; filter: drop-shadow(0 0 10px rgba(34,211,238,0.18)); }
50% { background-position: 100% 50%; filter: drop-shadow(0 0 14px rgba(124,58,237,0.22)); }
100% { background-position: 0% 50%; filter: drop-shadow(0 0 10px rgba(249,115,22,0.15)); }
}
.subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
}
.smallnote {
color: var(--muted);
font-size: 13px;
}
/* Make inputs feel more "custom" */
.gradio-container input[type="text"],
.gradio-container input[type="number"],
.gradio-container textarea {
background: rgba(0,0,0,0.25) !important;
border: 1px solid rgba(255,255,255,0.14) !important;
border-radius: 14px !important;
}
.gradio-container label { color: var(--muted) !important; }
/* Button: neon glow + hover lift */
.gradio-container button.primary {
border-radius: 16px !important;
border: 1px solid rgba(255,255,255,0.16) !important;
background: linear-gradient(90deg, rgba(34,211,238,0.22), rgba(124,58,237,0.22), rgba(249,115,22,0.18)) !important;
box-shadow:
0 10px 30px rgba(0,0,0,0.55),
0 0 0 1px rgba(255,255,255,0.05) inset;
transition: transform .15s ease, box-shadow .15s ease, filter .15s ease;
}
.gradio-container button.primary:hover {
transform: translateY(-1px);
filter: brightness(1.05);
box-shadow:
0 14px 44px rgba(0,0,0,0.60),
0 0 22px rgba(34,211,238,0.12),
0 0 24px rgba(124,58,237,0.10);
}
/* File input area a bit nicer */
.gradio-container .file-preview,
.gradio-container .upload-container {
border-radius: 16px !important;
border: 1px dashed rgba(255,255,255,0.18) !important;
background: rgba(0,0,0,0.16) !important;
}
/* Mobile: bigger text and spacing */
@media (max-width: 640px) {
#app { padding: 12px 10px 24px; }
#title { font-size: 20px; }
.subtitle, .smallnote { font-size: 13px; }
.glass { padding: 14px; }
.gradio-container button.primary { width: 100%; }
}
"""
# -------------------------
# UI
# -------------------------
with gr.Blocks() as demo:
with gr.Column(elem_id="app"):
with gr.Column(elem_classes=["header"]):
gr.Markdown("## Neon Audio Splitter → ZIP", elem_id="title")
gr.Markdown(
"<div class='subtitle'>"
"Upload a large audio file, choose chunk length, optionally override bitrate, and download a ZIP of MP3 parts."
"</div>"
)
with gr.Column(elem_classes=["glass"]):
inp = gr.File(label="Audio file", file_count="single", type="filepath")
chunk_seconds = gr.Number(label="Chunk length (seconds)", value=10, precision=0)
quality_mode = gr.Radio(
label="Bitrate mode",
choices=["Auto (same as source)", "Custom (8..320 kbps)"],
value="Auto (same as source)"
)
custom_bitrate = gr.Slider(
label="Custom bitrate (kbps)",
minimum=8, maximum=320, value=192, step=1,
visible=False, interactive=True
)
gr.Markdown(
"<div class='smallnote'>"
"<b>Auto</b> tries to read the original bitrate via <code>ffprobe</code>. "
"If it can't be detected, it uses <b>192 kbps</b> as a fallback."
"</div>"
)
btn = gr.Button("Split & Download ZIP", variant="primary")
with gr.Column(elem_classes=["glass"]):
out_zip = gr.File(label="ZIP archive (download)")
status = gr.Textbox(label="Status", lines=8)
def toggle_custom(mode):
return gr.update(visible=mode.startswith("Custom"))
quality_mode.change(toggle_custom, inputs=quality_mode, outputs=custom_bitrate)
btn.click(
split_and_zip,
inputs=[inp, chunk_seconds, quality_mode, custom_bitrate],
outputs=[out_zip, status]
)
if __name__ == "__main__":
demo.launch(
server_name="0.0.0.0",
server_port=7860,
ssr_mode=False,
css=CSS
)