"""Gradio demo and inference API for the acoustic-5 MeowContext Lab baseline.""" from __future__ import annotations import argparse import base64 import io import subprocess import sys import tempfile import wave from pathlib import Path import numpy as np _SRC = Path(__file__).resolve().parent / "src" if _SRC.is_dir() and str(_SRC) not in sys.path: sys.path.insert(0, str(_SRC)) from meowcontext_lab.data import DEMO_MODEL_PATH, FEATURE_COLUMNS # noqa: E402 from meowcontext_lab.models import load_demo_model, predict_from_features # noqa: E402 MAX_DURATION_SEC = 30.0 RECOMMENDED_MAX_SEC = 10.0 MIN_RMS = 0.005 TARGET_SAMPLE_RATE = 8000 def _load_wav_array(path: str | Path) -> tuple[np.ndarray, int]: with wave.open(str(path), "rb") as wav: sample_rate = wav.getframerate() channels = wav.getnchannels() frames = wav.getnframes() raw = wav.readframes(frames) sample_width = wav.getsampwidth() if sample_width == 1: audio = np.frombuffer(raw, dtype=np.uint8).astype(np.float32) audio = (audio - 128) / 128 elif sample_width == 2: audio = np.frombuffer(raw, dtype=" 1: audio = audio.reshape(-1, channels).mean(axis=1) if len(audio) == 0: raise ValueError("Audio file is empty.") return audio, sample_rate def _ffmpeg_to_wav(source: str | Path, destination: str | Path) -> None: cmd = [ "ffmpeg", "-y", "-i", str(source), "-vn", "-acodec", "pcm_s16le", "-ar", str(TARGET_SAMPLE_RATE), "-ac", "1", str(destination), ] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: stderr = (result.stderr or "").strip() if "does not contain any stream" in stderr or "Output file is empty" in stderr: raise ValueError("Video has no audio track.") raise ValueError("Could not extract audio from the uploaded file.") def load_audio_array(path: str | Path) -> tuple[np.ndarray, int]: """Load mono float audio from WAV or convert via ffmpeg.""" path = Path(path) if path.suffix.lower() == ".wav": audio, sample_rate = _load_wav_array(path) else: with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as handle: wav_path = Path(handle.name) try: _ffmpeg_to_wav(path, wav_path) audio, sample_rate = _load_wav_array(wav_path) finally: wav_path.unlink(missing_ok=True) if sample_rate != TARGET_SAMPLE_RATE and len(audio) > 1: duration = len(audio) / sample_rate target_len = max(1, int(round(duration * TARGET_SAMPLE_RATE))) x_old = np.linspace(0, duration, num=len(audio), endpoint=False) x_new = np.linspace(0, duration, num=target_len, endpoint=False) audio = np.interp(x_new, x_old, audio).astype(np.float32) sample_rate = TARGET_SAMPLE_RATE return audio, sample_rate def acoustic5_from_array(audio: np.ndarray, sample_rate: int) -> dict[str, float]: duration = len(audio) / sample_rate rms = float(np.sqrt(np.mean(np.square(audio)))) peak = float(np.max(np.abs(audio))) zcr = float(np.mean(np.abs(np.diff(np.signbit(audio))))) if len(audio) > 1 else 0.0 spectrum = np.abs(np.fft.rfft(audio)) freqs = np.fft.rfftfreq(len(audio), d=1 / sample_rate) centroid = float(np.sum(freqs * spectrum) / max(np.sum(spectrum), 1e-12)) return { "duration_sec": float(duration), "rms_energy": rms, "peak_abs_amplitude": peak, "zero_crossing_rate": zcr, "spectral_centroid_hz": centroid, } def acoustic5_from_wav(path: str | Path) -> dict[str, float]: audio, sample_rate = load_audio_array(path) return acoustic5_from_array(audio, sample_rate) def _png_data_uri(fig) -> str: import matplotlib.pyplot as plt buffer = io.BytesIO() fig.savefig(buffer, format="png", dpi=120, bbox_inches="tight", facecolor="#0f1419") plt.close(fig) encoded = base64.b64encode(buffer.getvalue()).decode("ascii") return f"data:image/png;base64,{encoded}" def waveform_image(audio: np.ndarray, sample_rate: int): import matplotlib.pyplot as plt seconds = np.arange(len(audio)) / sample_rate fig, ax = plt.subplots(figsize=(6.5, 2.2), facecolor="#0f1419") ax.set_facecolor("#0f1419") ax.plot(seconds, audio, color="#7c9cff", linewidth=0.9) ax.set_xlabel("Time (s)", color="#cbd5e1", fontsize=9) ax.set_ylabel("Amplitude", color="#cbd5e1", fontsize=9) ax.tick_params(colors="#94a3b8", labelsize=8) ax.set_title("Waveform", color="#e2e8f0", fontsize=10, pad=8) for spine in ax.spines.values(): spine.set_color("#334155") fig.tight_layout() return _png_data_uri(fig) def spectrogram_image(audio: np.ndarray, sample_rate: int): import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(6.5, 2.6), facecolor="#0f1419") ax.set_facecolor("#0f1419") nfft = min(512, max(64, len(audio) // 8)) spec = ax.specgram( audio, NFFT=nfft, Fs=sample_rate, noverlap=nfft // 2, cmap="magma", ) ax.set_xlabel("Time (s)", color="#cbd5e1", fontsize=9) ax.set_ylabel("Frequency (Hz)", color="#cbd5e1", fontsize=9) ax.tick_params(colors="#94a3b8", labelsize=8) ax.set_title("Spectrogram", color="#e2e8f0", fontsize=10, pad=8) cbar = fig.colorbar(spec[3], ax=ax, fraction=0.046, pad=0.04) cbar.ax.tick_params(colors="#94a3b8", labelsize=7) cbar.set_label("Power (dB)", color="#cbd5e1", fontsize=8) for spine in ax.spines.values(): spine.set_color("#334155") fig.tight_layout() return _png_data_uri(fig) def _collect_warnings(features: dict[str, float]) -> list[str]: warnings: list[str] = [] duration = features["duration_sec"] if duration > MAX_DURATION_SEC: warnings.append(f"Clip is longer than {MAX_DURATION_SEC:.0f}s and may be unreliable.") elif duration > RECOMMENDED_MAX_SEC: warnings.append(f"Clip exceeds the recommended {RECOMMENDED_MAX_SEC:.0f}s window.") if features["rms_energy"] < MIN_RMS: warnings.append("Clip is very quiet; prediction may be unreliable.") if features["peak_abs_amplitude"] < 0.01: warnings.append("Very low peak amplitude detected.") return warnings def build_prediction_response(path: str | Path) -> dict: """Run acoustic-5 inference and return structured JSON for the public website.""" if not path: raise ValueError("No audio file provided.") if not DEMO_MODEL_PATH.exists(): raise FileNotFoundError( f"{DEMO_MODEL_PATH} not found. Run `python scripts/train_demo_model.py` first." ) audio, sample_rate = load_audio_array(path) features = acoustic5_from_array(audio, sample_rate) bundle = load_demo_model(DEMO_MODEL_PATH) prediction = predict_from_features(bundle, features) probabilities = {str(k): float(v) for k, v in prediction.probabilities.items()} confidence = float(max(probabilities.values())) return { "predicted_context": prediction.label, "confidence": confidence, "probabilities": probabilities, "warnings": _collect_warnings(features), "waveform_image": waveform_image(audio, sample_rate), "spectrogram_image": spectrogram_image(audio, sample_rate), "features": {key: float(features[key]) for key in FEATURE_COLUMNS}, } def predict_audio_api(audio_path: str | None) -> dict: """Named API endpoint: /predict_audio""" if not audio_path: raise ValueError("No audio detected in the upload.") return build_prediction_response(audio_path) def predict_video_api(video_path: str | None) -> dict: """Named API endpoint: /predict_video""" if not video_path: raise ValueError("No video file provided.") with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as handle: wav_path = Path(handle.name) try: _ffmpeg_to_wav(video_path, wav_path) return build_prediction_response(wav_path) finally: wav_path.unlink(missing_ok=True) CONTEXT_LABELS = { "brushing": "Brushing", "isolation_unfamiliar_environment": "Isolation (unfamiliar place)", "waiting_for_food": "Waiting for food", } def _confidence_html(probabilities: dict[str, float]) -> str: rows: list[str] = [] for label, value in sorted(probabilities.items(), key=lambda item: item[1], reverse=True): name = CONTEXT_LABELS.get(label, label.replace("_", " ")) percent = int(round(float(value) * 100)) rows.append( f'
' f'
' f"{name}{percent}%
" f'
' f'
' ) return "".join(rows) def _image_html(data_uri: str, alt: str) -> str: if not data_uri: return "" return ( f'{alt}' ) def _friendly_result(path: str | Path) -> tuple[str, str, str, str, str]: response = build_prediction_response(path) label = CONTEXT_LABELS.get( response["predicted_context"], response["predicted_context"].replace("_", " "), ) confidence = int(round(float(response["confidence"]) * 100)) headline = f"## Your cat sounds like: **{label}**" summary = f"Best match: **{label}** ({confidence}% confidence)" warnings = ( "\n\n".join(f"⚠️ {warning}" for warning in response["warnings"]) if response["warnings"] else "" ) visuals = _image_html(response["waveform_image"], "Waveform") + _image_html( response["spectrogram_image"], "Spectrogram" ) if warnings: visuals += f"\n\n{warnings}" return headline, summary, _confidence_html(response["probabilities"]), visuals, ( "This is a benchmark demo, not a real-world cat interpretation system." ) def predict_audio_ui(audio_path: str | None) -> tuple[str, str, str, str, str]: if not audio_path: raise ValueError("Record or upload a meow first.") return _friendly_result(audio_path) def predict_video_ui(video_path: str | None) -> tuple[str, str, str, str, str]: if not video_path: raise ValueError("Upload or record a short cat video first.") with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as handle: wav_path = Path(handle.name) try: _ffmpeg_to_wav(video_path, wav_path) return _friendly_result(wav_path) finally: wav_path.unlink(missing_ok=True) def create_demo(): import gradio as gr with gr.Blocks(title="MeowContext Lab", theme=gr.themes.Soft()) as demo: gr.Markdown("# MeowContext Lab") gr.Markdown("Record or upload a cat meow — see what situation it matches.") gr.Markdown( "This demo predicts one of three eliciting recording contexts from a tiny public " "dataset. It is **not** a cat translator, emotion detector, pain detector, welfare " "tool, veterinary tool, or diagnostic system." ) gr.Markdown("Tip: 2–10 second clips work best.") result_headline = gr.Markdown() result_summary = gr.Markdown() result_bars = gr.HTML() result_visuals = gr.HTML() result_note = gr.Markdown() with gr.Tab("Record audio"): mic = gr.Audio(sources=["microphone"], type="filepath", label="Record a meow") gr.Button("See result", variant="primary").click( predict_audio_ui, inputs=[mic], outputs=[result_headline, result_summary, result_bars, result_visuals, result_note], ) with gr.Tab("Upload audio"): audio_file = gr.Audio(sources=["upload"], type="filepath", label="Upload audio") gr.Button("See result", variant="primary").click( predict_audio_ui, inputs=[audio_file], outputs=[result_headline, result_summary, result_bars, result_visuals, result_note], ) with gr.Tab("Upload / record video"): video_file = gr.Video(sources=["upload", "webcam"], label="Cat video (audio only)") gr.Button("See result", variant="primary").click( predict_video_ui, inputs=[video_file], outputs=[result_headline, result_summary, result_bars, result_visuals, result_note], ) gr.Markdown( "Uploaded or recorded files are used only for this prediction and are not used to " "train the model." ) # Hidden API hooks for the public Vercel website (not shown to visitors). with gr.Row(visible=False): api_audio = gr.Audio(type="filepath") api_audio_out = gr.JSON() gr.Button("api_audio").click( predict_audio_api, inputs=[api_audio], outputs=[api_audio_out], api_name="predict_audio", ) api_video = gr.Video() api_video_out = gr.JSON() gr.Button("api_video").click( predict_video_api, inputs=[api_video], outputs=[api_video_out], api_name="predict_video", ) return demo demo = create_demo() def smoke_test() -> None: if not DEMO_MODEL_PATH.exists(): raise FileNotFoundError( f"{DEMO_MODEL_PATH} not found. Run `python scripts/train_demo_model.py` first." ) features = dict( zip( FEATURE_COLUMNS, [1.4, 0.12, 0.36, 0.08, 1200.0], strict=True, ) ) bundle = load_demo_model(DEMO_MODEL_PATH) prediction = predict_from_features(bundle, features) print(f"Smoke prediction: {prediction.label}") def _write_tiny_wav(path: Path) -> None: sample_rate = 8000 t = np.linspace(0, 0.5, sample_rate // 2, endpoint=False) signal = 0.2 * np.sin(2 * np.pi * 440 * t) pcm = (signal * 32767).astype(" argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("command", nargs="*", help="Use `smoke test` for a CLI smoke test.") parser.add_argument("--share", action="store_true", help="Create a public Gradio share URL.") return parser.parse_args() def main() -> None: args = parse_args() if args.command in (["smoke"], ["smoke", "test"], ["smoke-test"]): smoke_test() with tempfile.NamedTemporaryFile(suffix=".wav") as handle: _write_tiny_wav(Path(handle.name)) response = build_prediction_response(handle.name) print(f"Smoke API context: {response['predicted_context']}") print(f"Smoke confidence: {response['confidence']:.3f}") return demo.launch(share=args.share) if __name__ == "__main__": main()