Spaces:
Sleeping
Sleeping
| # 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 | |
| ) |