"""Deep Voice — multi-model bioacoustic call detection Space.""" from __future__ import annotations import tempfile import traceback from pathlib import Path import gradio as gr import pandas as pd from helpers import ( download_checkpoint, estimate_minutes, merge_ravens, run_inference, validate_uploads, zip_files, ) # --------------------------------------------------------------------------- # Model registry: human label -> per-model settings. # `ckpt` is the path inside the deepvoice1/bioacoustic-checkpoints HF repo. # `min_sr` is the minimum input sample rate the model needs (== the model's # internal SR after resampling — uploads below this are rejected because the # missing high-freq content can't be reconstructed). # `notes` is the short blurb shown to the user beneath the model dropdown. # --------------------------------------------------------------------------- MODELS: dict[str, dict] = { # `coef` is the fraction-of-real-time we expect on HF free CPU. Calibrated # against observed runs (e.g. echo: 50 s for 5 min audio = 0.17). The cheap # downsampling models are much faster than the 96 kHz PCEN ones. "Arctic cod fish (Boreogadus saida)": dict( ckpt="xo9c3x6c/best.pth", min_sr=2000, threshold=0.5, coef=0.01, notes="1 s window · classes: Noise / Call · needs ≥ 2 kHz input.", credit="Trained in collaboration with Shaye Ogurek, University of Victoria.", ), "Greater Caribbean manatee (Trichechus manatus)": dict( ckpt="o5ot9qky/best.pth", min_sr=96000, threshold=0.5, coef=0.10, notes="0.2 s window · classes: Noise / Call · needs ≥ 96 kHz input.", credit="Trained in collaboration with Eric A. Ramos (Mote Marine Laboratory, *in memoriam*) and Beth Brady (Save the Manatee Club).", ), "Burrunan dolphin — barks (low-freq calls)": dict( ckpt="4af2w6lt/best.pth", min_sr=10000, threshold=0.5, coef=0.02, notes="3 s window · classes: bg / barks · needs ≥ 10 kHz input.", ), "Burrunan dolphin — echo (echolocation)": dict( ckpt="bki984uw/best.pth", min_sr=96000, threshold=0.9, coef=0.17, notes="3 s window · classes: bg / echo · needs ≥ 96 kHz input · threshold 0.9 recommended.", ), "Burrunan dolphin — buzz (rapid clicks)": dict( ckpt="ccgojzau/best.pth", min_sr=96000, threshold=0.8, coef=0.20, notes="0.5 s window · classes: bg / buzz · needs ≥ 96 kHz input · threshold 0.8 recommended.", ), "Burrunan dolphin — whistle (tonal signals)": dict( ckpt="g8gtuypk/best.pth", min_sr=96000, threshold=0.5, coef=0.18, notes="1 s window · classes: bg / whistle · needs ≥ 96 kHz input.", ), "Killer whale / orca (Orcinus orca) — 5-class": dict( ckpt="v5q3lg3h/best.pth", min_sr=24000, threshold=0.5, coef=0.05, notes="1.5 s window · classes: Upsweeps / Downsweeps / Tones / Squeaks / Clicks · needs ≥ 24 kHz input.", credit="Trained in collaboration with Fannie W. Shabangu, University of Pretoria.", ), "Humpback whale (Megaptera novaeangliae) — Mozambique / C1 group": dict( ckpt="2dobs988/best.pth", min_sr=16000, threshold=0.5, coef=0.06, notes="1 s window · classes: Noise / Call · needs ≥ 16 kHz input · trained on the C1 breeding subpopulation of humpback whales recorded off Mozambique.", ), } # If the estimated runtime exceeds this, we reject before starting (protects # the HF free-tier worker from timing out mid-run). MAX_RUNTIME_MIN = 15 INTRO_MD = """ # 🐳 Deep Voice — Bioacoustic Call Detection Run open-source detectors trained by **[Deep Voice](https://huggingface.co/deepvoice1)** on your own underwater recordings. Pick a species/call type, upload one or more `.wav` files, and download per-window probability scores (CSV) plus a [Raven](https://www.ravensoundsoftware.com/) selection table for further analysis. **How to use** 1. Choose a model from the dropdown — the description updates with the window size and recommended detection threshold. 2. Upload one or more `.wav` files. Total upload size capped at **500 MB**; runs estimated to exceed **15 min** of compute are rejected. 3. Click **Run inference**. Downloads appear once processing finishes. Running on free HuggingFace CPU hardware. Throughput varies by model — the pre-flight line below shows a per-model estimate once you upload. """ # --------------------------------------------------------------------------- # Event handlers # --------------------------------------------------------------------------- def _model_notes_md(model_label: str) -> str: m = MODELS[model_label] md = f"**Model notes**: {m['notes']}" if m.get("credit"): # Underscore italics outside so the credit can use *...* inside (e.g. *in memoriam*). md += f" \n_{m['credit']}_" return md def update_model_notes(model_label: str) -> tuple[str, float]: m = MODELS[model_label] return _model_notes_md(model_label), m["threshold"] def preflight(files, model_label): if not files: return "_Upload at least one `.wav` file._" paths = [f.name if hasattr(f, "name") else f for f in files] try: info = validate_uploads(paths) except gr.Error as e: return f"⚠️ {e.args[0] if e.args else 'Validation error.'}" coef = MODELS[model_label]["coef"] eta = estimate_minutes(info["total_min"] * 60, coef=coef) warn = "" if eta > MAX_RUNTIME_MIN: warn = ( f" \n⚠️ Estimated runtime exceeds the {MAX_RUNTIME_MIN}-min cap " f"for this Space — please reduce the input or pick a faster model." ) return ( f"**{info['n_files']} file(s)** · {info['total_mb']:.1f} MB · " f"{info['total_min']:.2f} min total audio. \n" f"Estimated runtime for **{model_label.split(' — ')[0]}**: " f"**~{eta:.1f} min** on CPU (rough estimate, actual time may vary)." + warn ) def predict(model_label: str, threshold: float, files, progress=gr.Progress()): if not files: raise gr.Error("Please upload at least one .wav file.") paths = [f.name if hasattr(f, "name") else f for f in files] info = validate_uploads(paths) # raises gr.Error on cap violation model = MODELS[model_label] eta = estimate_minutes(info["total_min"] * 60, coef=model["coef"]) if eta > MAX_RUNTIME_MIN: raise gr.Error( f"Estimated runtime ~{eta:.1f} min exceeds this Space's " f"{MAX_RUNTIME_MIN}-min cap for {model_label}. Reduce the input or pick a faster model." ) progress(0.05, desc="Downloading model checkpoint…") ckpt_path = download_checkpoint(model["ckpt"]) work_dir = Path(tempfile.mkdtemp(prefix="dv_run_")) csv_paths: list[Path] = [] raven_paths: list[Path | None] = [] wav_paths = [Path(p) for p in paths] n = len(wav_paths) for i, wav in enumerate(wav_paths): progress((i + 0.1) / (n + 1), desc=f"[{i+1}/{n}] {wav.name}") # Each file gets its own subdir so soundbay's timestamped output filenames # never collide when two inferences land in the same wall-clock second. per_file_dir = work_dir / f"f{i:03d}" try: csv_p, raven_p = run_inference( wav_path=str(wav), ckpt_path=ckpt_path, threshold=threshold, output_dir=per_file_dir, ) except ValueError as e: # Friendly errors (e.g. sample-rate too low) bubble up cleanly. raise gr.Error(str(e)) except Exception: tb = traceback.format_exc() raise gr.Error(f"Inference failed on {wav.name}:\n{tb[-500:]}") csv_paths.append(csv_p) raven_paths.append(raven_p) progress(0.95, desc="Packaging outputs…") model_id = Path(ckpt_path).parent.stem if n == 1: csv_out = csv_paths[0] raven_out = raven_paths[0] else: csv_zip = work_dir / f"results_{model_id}_csvs.zip" zip_files(csv_paths, csv_zip) csv_out = csv_zip merged = work_dir / f"results_{model_id}_merged-Raven.txt" merge_ravens(raven_paths, wav_paths, merged) raven_out = merged # Preview: head of the first CSV preview_df = pd.read_csv(csv_paths[0]).head(20) return str(csv_out), (str(raven_out) if raven_out else None), preview_df # --------------------------------------------------------------------------- # UI # --------------------------------------------------------------------------- with gr.Blocks(title="Deep Voice — Bioacoustic Detection", theme=gr.themes.Soft()) as demo: gr.Markdown(INTRO_MD) default_model = next(iter(MODELS.keys())) with gr.Row(): with gr.Column(scale=2): model_dd = gr.Dropdown( choices=list(MODELS.keys()), value=default_model, label="Model / species / call type", interactive=True, ) model_notes = gr.Markdown(_model_notes_md(default_model)) threshold_sl = gr.Slider( minimum=0.0, maximum=1.0, value=MODELS[default_model]["threshold"], step=0.05, label="Detection threshold (used for the Raven selection table)", ) with gr.Column(scale=3): files_in = gr.File( file_count="multiple", file_types=[".wav"], label="Upload .wav files (≤ 500 MB total; runtime ≤ 15 min)", ) preflight_md = gr.Markdown("_Upload at least one `.wav` file._") run_btn = gr.Button("🔍 Run inference", variant="primary") with gr.Row(): csv_out = gr.File(label="CSV scores (per-window probabilities)") raven_out = gr.File(label="Raven selection table (.txt)") preview = gr.Dataframe(label="Preview — first 20 rows of first CSV", interactive=False) gr.Markdown( "---\n" "### Acknowledgements\n" "Models trained in collaboration with the following researchers and organisations:\n" "- **Arctic cod** — Shaye Ogurek (University of Victoria)\n" "- **Greater Caribbean manatee** — Eric A. Ramos (Mote Marine Laboratory, *in memoriam*) and Beth Brady (Save the Manatee Club)\n" "- **Killer whale** — Fannie W. Shabangu (University of Pretoria)\n" "\n" "**Feedback & bug reports** — open a thread in the Space's " "[Community tab](https://huggingface.co/spaces/deepvoice1/deepvoice_detection/discussions) " "for anything public; for private inquiries, write to **info@deepvoicefoundation.com**. \n" "**Models & code** are open-source at " "[github.com/deep-voice/soundbay](https://github.com/deep-voice/soundbay). " "More about us at [deepvoicefoundation.com](https://www.deepvoicefoundation.com/). \n" "Built with support from the **WILDLABS Awards 2025**, funded by **Arm**." ) # Wiring model_dd.change(fn=update_model_notes, inputs=model_dd, outputs=[model_notes, threshold_sl]) model_dd.change(fn=preflight, inputs=[files_in, model_dd], outputs=preflight_md) files_in.change(fn=preflight, inputs=[files_in, model_dd], outputs=preflight_md) run_btn.click( fn=predict, inputs=[model_dd, threshold_sl, files_in], outputs=[csv_out, raven_out, preview], ) if __name__ == "__main__": demo.launch(inbrowser=True)