v7: standalone Sing Detect app — full Tauri project tree
Promotes the v6 detection slice into a complete, buildable Tauri 2 app and renames Stream Studio → Sing Detect (detection-only; video-concat split to its own project). Proper src/ + src-tauri/ layout, Python sidecar under sidecar/, app icons, package/Cargo manifests, and dev/build/pack scripts. Pipeline unchanged in spirit: Rust drives ffmpeg to a 16 kHz mono WAV, the Python sidecar (inaSpeechSegmenter) emits line-JSON segments, and Rust writes EDL / markers CSV / CSV / JSON exports. Long ops run on spawn_blocking; cancel kills the whole py -3.10 process tree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
180
sidecar/detect.py
Normal file
180
sidecar/detect.py
Normal file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stream Studio — singing-detection sidecar.
|
||||
|
||||
This is the ONLY Python in the project. It exists solely because the ML model
|
||||
(inaSpeechSegmenter, which classifies singing voice as "music") is TensorFlow-
|
||||
based and has no native Rust/JS equivalent. Everything else — UI, ffmpeg
|
||||
concat, audio extraction, file exports — is handled by the Tauri/Rust host.
|
||||
|
||||
Contract (line-delimited JSON on stdout):
|
||||
{"type": "progress", "message": "..."}
|
||||
{"type": "result", "segments": [{"index","start","end","duration"}...],
|
||||
"stats": {"music": 123.4, "speech": 456.7, ...}}
|
||||
{"type": "error", "message": "..."}
|
||||
|
||||
The host extracts a 16 kHz mono WAV and passes it via --wav, so this script
|
||||
never touches ffmpeg.
|
||||
|
||||
Run with Python 3.10 (TensorFlow does not support 3.12+):
|
||||
py -3.10 detect.py --wav audio.wav --gap 10 --min-duration 20 --padding 2
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def emit(obj: dict) -> None:
|
||||
"""Write one JSON record per line and flush immediately."""
|
||||
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def progress(msg: str) -> None:
|
||||
emit({"type": "progress", "message": msg})
|
||||
|
||||
|
||||
def merge_singing_segments(segments, max_gap, min_duration, padding):
|
||||
"""Filter for 'music' segments and merge nearby ones into songs."""
|
||||
music = sorted((s, e) for label, s, e in segments if label == "music")
|
||||
if not music:
|
||||
return []
|
||||
|
||||
merged = []
|
||||
cur_start, cur_end = music[0]
|
||||
for s, e in music[1:]:
|
||||
if s - cur_end <= max_gap:
|
||||
cur_end = max(cur_end, e)
|
||||
else:
|
||||
merged.append((cur_start, cur_end))
|
||||
cur_start, cur_end = s, e
|
||||
merged.append((cur_start, cur_end))
|
||||
|
||||
results = []
|
||||
idx = 1
|
||||
for s, e in merged:
|
||||
if (e - s) >= min_duration:
|
||||
ps = max(0.0, s - padding)
|
||||
pe = e + padding
|
||||
results.append({
|
||||
"index": idx,
|
||||
"start": round(ps, 3),
|
||||
"end": round(pe, 3),
|
||||
"duration": round(pe - ps, 3),
|
||||
})
|
||||
idx += 1
|
||||
return results
|
||||
|
||||
|
||||
def _model_present() -> bool:
|
||||
"""Best-effort check for cached model weights, without downloading."""
|
||||
try:
|
||||
import glob
|
||||
import os
|
||||
|
||||
import inaSpeechSegmenter
|
||||
|
||||
roots = [
|
||||
os.path.dirname(inaSpeechSegmenter.__file__),
|
||||
os.path.join(os.path.expanduser("~"), ".keras"),
|
||||
]
|
||||
for root in roots:
|
||||
for ext in ("*.hdf5", "*.h5", "*.keras", "*.pb"):
|
||||
if glob.glob(os.path.join(root, "**", ext), recursive=True):
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def run_check() -> int:
|
||||
"""Report whether the ML packages (and weights) are available."""
|
||||
record = {"type": "check", "packages": False, "model": False, "detail": ""}
|
||||
try:
|
||||
import inaSpeechSegmenter # noqa: F401
|
||||
import tensorflow # noqa: F401
|
||||
record["packages"] = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
record["detail"] = f"Packages not installed ({type(exc).__name__})."
|
||||
emit(record)
|
||||
return 0
|
||||
record["model"] = _model_present()
|
||||
record["detail"] = (
|
||||
"Ready — packages and model weights are installed."
|
||||
if record["model"]
|
||||
else "Packages installed; model weights will download on first run."
|
||||
)
|
||||
emit(record)
|
||||
return 0
|
||||
|
||||
|
||||
def run_warmup() -> int:
|
||||
"""Instantiate the model once, forcing the weight download if needed."""
|
||||
try:
|
||||
progress("Loading detection model (downloading weights if needed)…")
|
||||
from inaSpeechSegmenter import Segmenter
|
||||
|
||||
Segmenter(vad_engine="smn", detect_gender=False)
|
||||
emit({"type": "done", "message": "Model ready."})
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
emit({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--wav")
|
||||
ap.add_argument("--gap", type=float, default=10.0)
|
||||
ap.add_argument("--min-duration", type=float, default=20.0)
|
||||
ap.add_argument("--padding", type=float, default=2.0)
|
||||
ap.add_argument("--check", action="store_true", help="probe environment, then exit")
|
||||
ap.add_argument("--warmup", action="store_true", help="download/verify weights, then exit")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.check:
|
||||
return run_check()
|
||||
if args.warmup:
|
||||
return run_warmup()
|
||||
if not args.wav:
|
||||
emit({"type": "error", "message": "--wav is required for detection."})
|
||||
return 1
|
||||
|
||||
try:
|
||||
progress("Loading detection model (first run downloads weights)…")
|
||||
from inaSpeechSegmenter import Segmenter
|
||||
except ImportError:
|
||||
emit({
|
||||
"type": "error",
|
||||
"message": (
|
||||
"inaSpeechSegmenter is not installed for this Python.\n"
|
||||
"Install it with:\n"
|
||||
" py -3.10 -m pip install inaSpeechSegmenter tensorflow"
|
||||
),
|
||||
})
|
||||
return 1
|
||||
|
||||
try:
|
||||
t0 = time.time()
|
||||
seg = Segmenter(vad_engine="smn", detect_gender=False)
|
||||
progress("Analysing audio — this can take a while on long streams…")
|
||||
raw = seg(args.wav)
|
||||
progress(f"Segmentation done in {time.time() - t0:.0f}s ({len(raw)} raw segments).")
|
||||
|
||||
stats = {}
|
||||
for label, s, e in raw:
|
||||
stats[label] = round(stats.get(label, 0.0) + (e - s), 1)
|
||||
|
||||
songs = merge_singing_segments(raw, args.gap, args.min_duration, args.padding)
|
||||
progress(f"Found {len(songs)} singing segment(s) after merge + filter.")
|
||||
emit({"type": "result", "segments": songs, "stats": stats})
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001 — surface any model/runtime error to the host
|
||||
emit({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
12
sidecar/requirements.txt
Normal file
12
sidecar/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
# Stream Studio detection sidecar — install into a Python 3.10 environment.
|
||||
# py -3.10 -m pip install -r requirements.txt
|
||||
#
|
||||
# TensorFlow does NOT support Python 3.12+, which is why the sidecar pins 3.10
|
||||
# even though the rest of the toolchain can use newer Python.
|
||||
|
||||
# NumPy MUST stay <2: TensorFlow 2.11 is built against NumPy 1.x and crashes on
|
||||
# NumPy 2.x with "_pywrap_bfloat16.TF_bfloat16_type() () -> handle". Keep this pin
|
||||
# above tensorflow so the resolver installs the compatible NumPy.
|
||||
numpy<2
|
||||
inaSpeechSegmenter
|
||||
tensorflow # use: tensorflow[and-cuda] for NVIDIA GPU acceleration
|
||||
25
sidecar/setup_sidecar.bat
Normal file
25
sidecar/setup_sidecar.bat
Normal file
@@ -0,0 +1,25 @@
|
||||
@echo off
|
||||
REM Install the singing-detection ML dependencies into Python 3.10.
|
||||
REM TensorFlow does not support Python 3.12+, so we pin 3.10 via the py launcher.
|
||||
|
||||
py -3.10 --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo Python 3.10 not found via the "py" launcher.
|
||||
echo Install Python 3.10 from https://www.python.org/downloads/release/python-31011/
|
||||
echo Then re-run this script.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Installing inaSpeechSegmenter + TensorFlow into Python 3.10...
|
||||
py -3.10 -m pip install --upgrade pip
|
||||
py -3.10 -m pip install -r "%~dp0requirements.txt"
|
||||
|
||||
REM Re-pin NumPy last: TensorFlow can drag NumPy 2.x back in, which crashes TF
|
||||
REM with "_pywrap_bfloat16.TF_bfloat16_type() () -> handle".
|
||||
py -3.10 -m pip install "numpy<2"
|
||||
|
||||
echo.
|
||||
echo Verifying...
|
||||
py -3.10 -c "from inaSpeechSegmenter import Segmenter; print('inaSpeechSegmenter OK')"
|
||||
pause
|
||||
Reference in New Issue
Block a user