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>
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
5045
src-tauri/Cargo.lock
generated
Normal file
33
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "sing-detect"
|
||||
version = "2.0.0"
|
||||
description = "Sing Detect — find singing segments in stream recordings"
|
||||
authors = ["youfu"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "sing_detect_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
15
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"opener:default",
|
||||
"opener:allow-reveal-item-in-dir",
|
||||
"dialog:default",
|
||||
"dialog:allow-open"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
380
src-tauri/src/detect.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
//! Singing-segment detection.
|
||||
//!
|
||||
//! Rust owns the fast/IO parts (audio extraction via ffmpeg, progress events,
|
||||
//! and all file exports). The Python sidecar owns *only* the ML step
|
||||
//! (inaSpeechSegmenter + TensorFlow), which has no native equivalent.
|
||||
//!
|
||||
//! Protocol: the sidecar prints one JSON object per line to stdout:
|
||||
//! {"type":"progress","message":"…"}
|
||||
//! {"type":"result","segments":[{index,start,end,duration}, …],
|
||||
//! "stats":{"music":123.4,"speech":456.7,…}}
|
||||
//! {"type":"error","message":"…"}
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::tools::hide_window;
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Segment {
|
||||
pub index: u32,
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct DetectResult {
|
||||
pub segments: Vec<Segment>,
|
||||
pub stats: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct DetectProgress {
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// Environment readiness for the detection feature.
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct DetectionStatus {
|
||||
pub python: bool,
|
||||
pub packages: bool,
|
||||
pub model: bool,
|
||||
pub ready: bool,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct SetupProgress {
|
||||
message: String,
|
||||
pct: f64,
|
||||
}
|
||||
|
||||
fn emit(app: &AppHandle, message: impl Into<String>) {
|
||||
let _ = app.emit("detect-progress", DetectProgress { message: message.into() });
|
||||
}
|
||||
|
||||
fn emit_setup(app: &AppHandle, message: impl Into<String>, pct: f64) {
|
||||
let _ = app.emit("detect-setup-progress", SetupProgress { message: message.into(), pct });
|
||||
}
|
||||
|
||||
/// Probe whether Python 3.10 + the ML packages (and weights) are available.
|
||||
/// Never installs or downloads anything — purely a read-only check.
|
||||
pub fn check_env() -> DetectionStatus {
|
||||
let not_ready = |python: bool, detail: &str| DetectionStatus {
|
||||
python,
|
||||
packages: false,
|
||||
model: false,
|
||||
ready: false,
|
||||
detail: detail.into(),
|
||||
};
|
||||
|
||||
let script = match sidecar_script() {
|
||||
Some(s) => s,
|
||||
None => return not_ready(false, "detect.py was not found next to the app."),
|
||||
};
|
||||
|
||||
let mut cmd = python_command(&script);
|
||||
cmd.arg("--check");
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
cmd.env("PYTHONIOENCODING", "utf-8");
|
||||
hide_window(&mut cmd);
|
||||
|
||||
let out = match cmd.output() {
|
||||
Ok(o) => o,
|
||||
Err(_) => {
|
||||
return not_ready(
|
||||
false,
|
||||
"Python 3.10 was not found. Install Python 3.10, then click Test again.",
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
for line in stdout.lines().rev() {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(line.trim()) {
|
||||
if v.get("type").and_then(|t| t.as_str()) == Some("check") {
|
||||
let packages = v.get("packages").and_then(|b| b.as_bool()).unwrap_or(false);
|
||||
let model = v.get("model").and_then(|b| b.as_bool()).unwrap_or(false);
|
||||
let detail = v.get("detail").and_then(|s| s.as_str()).unwrap_or("").to_string();
|
||||
return DetectionStatus { python: true, packages, model, ready: packages, detail };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Python launcher ran but produced no status — most likely 3.10 isn't installed.
|
||||
not_ready(
|
||||
false,
|
||||
"Python 3.10 was not found. Install Python 3.10, then click Test again.",
|
||||
)
|
||||
}
|
||||
|
||||
/// Stream a child command's output to the setup progress channel, holding the
|
||||
/// bar at `pct`. Returns an error (with captured stderr) on non-zero exit.
|
||||
fn run_streamed(app: &AppHandle, mut cmd: Command, pct: f64) -> Result<(), String> {
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
cmd.env("PYTHONUNBUFFERED", "1").env("PYTHONIOENCODING", "utf-8");
|
||||
hide_window(&mut cmd);
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| format!("Could not start Python ({e})."))?;
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
if let Some(out) = stdout {
|
||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||
let line = line.trim();
|
||||
if !line.is_empty() {
|
||||
// detect.py emits JSON; pip emits plain text — show whichever.
|
||||
let msg = serde_json::from_str::<Value>(line)
|
||||
.ok()
|
||||
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| line.to_string());
|
||||
emit_setup(app, msg, pct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut stderr_text = String::new();
|
||||
if let Some(mut se) = stderr {
|
||||
use std::io::Read;
|
||||
let _ = se.read_to_string(&mut stderr_text);
|
||||
}
|
||||
let status = child.wait().map_err(|e| format!("process wait failed: {e}"))?;
|
||||
if !status.success() {
|
||||
let tail: String = stderr_text.chars().rev().take(900).collect::<String>().chars().rev().collect();
|
||||
return Err(if tail.trim().is_empty() { "Step failed.".into() } else { tail });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the detection ML stack into Python 3.10 and pre-download the model
|
||||
/// weights, streaming progress to the UI. Requires Python 3.10 to be present.
|
||||
pub fn setup_env(app: &AppHandle) -> Result<(), String> {
|
||||
let script = sidecar_script().ok_or("detect.py was not found next to the app.")?;
|
||||
|
||||
// Preflight: confirm Python 3.10 exists before we try to install into it.
|
||||
let mut ver = python_base();
|
||||
ver.arg("--version");
|
||||
ver.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
hide_window(&mut ver);
|
||||
match ver.output() {
|
||||
Ok(o) if o.status.success() => {}
|
||||
_ => {
|
||||
return Err(
|
||||
"Python 3.10 was not found.\n\nInstall it from \
|
||||
https://www.python.org/downloads/release/python-31011/ \
|
||||
(or run: winget install Python.Python.3.10), then click Test again."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
emit_setup(app, "Upgrading pip…", 0.05);
|
||||
let mut pip_up = python_base();
|
||||
pip_up.args(["-m", "pip", "install", "--upgrade", "pip"]);
|
||||
run_streamed(app, pip_up, 0.05)?;
|
||||
|
||||
emit_setup(app, "Installing inaSpeechSegmenter + TensorFlow (large — a few minutes)…", 0.15);
|
||||
let mut pip_main = python_base();
|
||||
pip_main.args(["-m", "pip", "install", "inaSpeechSegmenter", "tensorflow"]);
|
||||
run_streamed(app, pip_main, 0.15)?;
|
||||
|
||||
// TensorFlow can drag NumPy 2.x back in, which crashes it; pin <2 last.
|
||||
emit_setup(app, "Pinning NumPy < 2…", 0.70);
|
||||
let mut pip_np = python_base();
|
||||
pip_np.args(["-m", "pip", "install", "numpy<2"]);
|
||||
run_streamed(app, pip_np, 0.70)?;
|
||||
|
||||
emit_setup(app, "Downloading model weights…", 0.82);
|
||||
let mut warm = python_command(&script);
|
||||
warm.arg("--warmup");
|
||||
run_streamed(app, warm, 0.82)?;
|
||||
|
||||
emit_setup(app, "Setup complete.", 1.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locate the sidecar script: bundled resources first, dev tree as fallback.
|
||||
fn sidecar_script() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("STREAM_STUDIO_SIDECAR") {
|
||||
let pb = PathBuf::from(p);
|
||||
if pb.exists() {
|
||||
return Some(pb);
|
||||
}
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(base) = exe.parent() {
|
||||
for sub in ["sidecar", "resources/sidecar", "../sidecar"] {
|
||||
let cand = base.join(sub).join("detect.py");
|
||||
if cand.exists() {
|
||||
return Some(cand);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dev fallback: <crate>/../sidecar/detect.py
|
||||
let dev = Path::new(env!("CARGO_MANIFEST_DIR")).join("../sidecar/detect.py");
|
||||
if dev.exists() {
|
||||
return Some(dev);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build the bare python invocation (no script/args). Prefers the Windows
|
||||
/// `py -3.10` launcher (TensorFlow does not yet support 3.12+), then a custom
|
||||
/// override, then plain `python`. Callers append a script or `-m pip …`.
|
||||
fn python_base() -> Command {
|
||||
if let Ok(custom) = std::env::var("STREAM_STUDIO_PYTHON") {
|
||||
return Command::new(custom);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// The py launcher lets us pin 3.10 regardless of the default install.
|
||||
let mut c = Command::new("py");
|
||||
c.args(["-3.10"]);
|
||||
return c;
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
Command::new("python")
|
||||
}
|
||||
|
||||
/// `python <script>` ready for detection args.
|
||||
fn python_command(script: &Path) -> Command {
|
||||
let mut c = python_base();
|
||||
c.arg(script);
|
||||
c
|
||||
}
|
||||
|
||||
/// Extract 16 kHz mono WAV (the format inaSpeechSegmenter expects).
|
||||
fn extract_audio(app: &AppHandle, ffmpeg: &str, video: &str, wav: &Path) -> Result<(), String> {
|
||||
emit(app, "Extracting audio (16 kHz mono)…");
|
||||
let mut cmd = Command::new(ffmpeg);
|
||||
cmd.args([
|
||||
"-y", "-i", video,
|
||||
"-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1",
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
&wav.to_string_lossy(),
|
||||
]);
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::piped());
|
||||
hide_window(&mut cmd);
|
||||
let out = cmd.output().map_err(|e| format!("ffmpeg audio extract failed: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("Audio extraction failed:\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
ffmpeg: &str,
|
||||
video: String,
|
||||
gap: f64,
|
||||
min_duration: f64,
|
||||
padding: f64,
|
||||
) -> Result<DetectResult, String> {
|
||||
state.cancel.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
let script = sidecar_script().ok_or(
|
||||
"Detection sidecar not found. Expected sidecar/detect.py next to the app.",
|
||||
)?;
|
||||
|
||||
// Temp WAV beside the source video.
|
||||
let wav = Path::new(&video)
|
||||
.with_extension("")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let wav = PathBuf::from(format!("{wav}.stream_studio.wav"));
|
||||
|
||||
extract_audio(app, ffmpeg, &video, &wav)?;
|
||||
|
||||
let mut cmd = python_command(&script);
|
||||
cmd.args([
|
||||
"--wav", &wav.to_string_lossy(),
|
||||
"--gap", &gap.to_string(),
|
||||
"--min-duration", &min_duration.to_string(),
|
||||
"--padding", &padding.to_string(),
|
||||
]);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
cmd.env("PYTHONUNBUFFERED", "1").env("PYTHONIOENCODING", "utf-8");
|
||||
hide_window(&mut cmd);
|
||||
|
||||
emit(app, "Loading detection model…");
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
format!("Could not start Python sidecar ({e}). Is Python 3.10 + inaSpeechSegmenter installed?")
|
||||
})?;
|
||||
let stdout = child.stdout.take().ok_or("No sidecar stdout")?;
|
||||
let stderr = child.stderr.take();
|
||||
*state.proc.lock().unwrap() = Some(child);
|
||||
|
||||
let mut result: Option<DetectResult> = None;
|
||||
let mut err_msg: Option<String> = None;
|
||||
|
||||
for line in BufReader::new(stdout).lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => break,
|
||||
};
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue, // ignore stray non-JSON output
|
||||
};
|
||||
match parsed.get("type").and_then(|t| t.as_str()) {
|
||||
Some("progress") => {
|
||||
if let Some(m) = parsed.get("message").and_then(|m| m.as_str()) {
|
||||
emit(app, m);
|
||||
}
|
||||
}
|
||||
Some("result") => {
|
||||
let segments: Vec<Segment> = serde_json::from_value(
|
||||
parsed.get("segments").cloned().unwrap_or(Value::Array(vec![])),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let stats = parsed.get("stats").cloned().unwrap_or(Value::Null);
|
||||
result = Some(DetectResult { segments, stats });
|
||||
}
|
||||
Some("error") => {
|
||||
err_msg = parsed
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut child = state.proc.lock().unwrap().take().ok_or("Sidecar vanished")?;
|
||||
let status = child.wait().map_err(|e| format!("sidecar wait failed: {e}"))?;
|
||||
let mut stderr_text = String::new();
|
||||
if let Some(mut se) = stderr {
|
||||
use std::io::Read;
|
||||
let _ = se.read_to_string(&mut stderr_text);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&wav);
|
||||
|
||||
if state.cancel.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return Err("__cancelled__".into());
|
||||
}
|
||||
if let Some(m) = err_msg {
|
||||
return Err(m);
|
||||
}
|
||||
if let Some(r) = result {
|
||||
return Ok(r);
|
||||
}
|
||||
if !status.success() {
|
||||
let tail: String = stderr_text.chars().rev().take(800).collect::<String>().chars().rev().collect();
|
||||
return Err(format!("Detection failed.\n{tail}"));
|
||||
}
|
||||
Err("Detection produced no result.".into())
|
||||
}
|
||||
129
src-tauri/src/export.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
//! Export detected singing segments to editor-friendly formats.
|
||||
//! EDL + DaVinci marker CSV ported 1:1 from the proven Python implementation.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::detect::Segment;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct ExportResult {
|
||||
pub files: Vec<String>,
|
||||
}
|
||||
|
||||
fn timecode(seconds: f64, fps: f64) -> String {
|
||||
let total_frames = (seconds * fps) as i64;
|
||||
let fps_i = fps.round() as i64;
|
||||
let ff = total_frames % fps_i;
|
||||
let total_seconds = total_frames / fps_i;
|
||||
let ss = total_seconds % 60;
|
||||
let total_minutes = total_seconds / 60;
|
||||
let mm = total_minutes % 60;
|
||||
let hh = total_minutes / 60;
|
||||
format!("{hh:02}:{mm:02}:{ss:02}:{ff:02}")
|
||||
}
|
||||
|
||||
fn hms(seconds: f64) -> String {
|
||||
let s = seconds as i64;
|
||||
format!("{:02}:{:02}:{:02}", s / 3600, (s % 3600) / 60, s % 60)
|
||||
}
|
||||
|
||||
fn write_edl(segs: &[Segment], path: &Path, fps: f64, title: &str, source: &str) -> std::io::Result<()> {
|
||||
let reel = Path::new(source)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("001")
|
||||
.chars()
|
||||
.take(32)
|
||||
.collect::<String>()
|
||||
.replace(' ', "_");
|
||||
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(out, "TITLE: {title}");
|
||||
let _ = writeln!(out, "FCM: NON-DROP FRAME\n");
|
||||
|
||||
let mut rec_pos = 3600.0_f64; // start at 01:00:00:00
|
||||
for s in segs {
|
||||
let edit = format!("{:03}", s.index);
|
||||
let src_in = timecode(s.start, fps);
|
||||
let src_out = timecode(s.end, fps);
|
||||
let rec_in = timecode(rec_pos, fps);
|
||||
let rec_out = timecode(rec_pos + s.duration, fps);
|
||||
let _ = writeln!(out, "{edit} {reel} V C {src_in} {src_out} {rec_in} {rec_out}");
|
||||
let _ = writeln!(out, "* FROM CLIP NAME: {source}");
|
||||
let _ = writeln!(out, "* COMMENT: Song {} - Duration {:.0}s\n", s.index, s.duration);
|
||||
rec_pos += s.duration;
|
||||
}
|
||||
std::fs::write(path, out)
|
||||
}
|
||||
|
||||
fn write_markers_csv(segs: &[Segment], path: &Path, fps: f64) -> std::io::Result<()> {
|
||||
let mut out = String::from("#,Color,Name,Start TC,End TC,Duration TC,Notes\n");
|
||||
for s in segs {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{},Blue,Song {},{},{},{},Duration: {:.0}s",
|
||||
s.index,
|
||||
s.index,
|
||||
timecode(s.start, fps),
|
||||
timecode(s.end, fps),
|
||||
timecode(s.duration, fps),
|
||||
s.duration
|
||||
);
|
||||
}
|
||||
std::fs::write(path, out)
|
||||
}
|
||||
|
||||
fn write_csv(segs: &[Segment], path: &Path) -> std::io::Result<()> {
|
||||
let mut out = String::from("index,start_sec,end_sec,duration_sec,start_timecode,end_timecode\n");
|
||||
for s in segs {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{},{:.2},{:.2},{:.1},{},{}",
|
||||
s.index, s.start, s.end, s.duration, hms(s.start), hms(s.end)
|
||||
);
|
||||
}
|
||||
std::fs::write(path, out)
|
||||
}
|
||||
|
||||
fn write_json(segs: &[Segment], path: &Path) -> std::io::Result<()> {
|
||||
let body = serde_json::to_string_pretty(segs).unwrap_or_else(|_| "[]".into());
|
||||
std::fs::write(path, body)
|
||||
}
|
||||
|
||||
/// Write all four formats next to (or into `out_dir` for) the source video.
|
||||
pub fn export_all(
|
||||
segments: &[Segment],
|
||||
source_video: &str,
|
||||
out_dir: Option<String>,
|
||||
fps: f64,
|
||||
) -> Result<ExportResult, String> {
|
||||
let src = Path::new(source_video);
|
||||
let stem = src.file_stem().and_then(|s| s.to_str()).unwrap_or("output").to_string();
|
||||
let source_name = src.file_name().and_then(|s| s.to_str()).unwrap_or(source_video).to_string();
|
||||
let dir: PathBuf = match out_dir {
|
||||
Some(d) if !d.trim().is_empty() => PathBuf::from(d),
|
||||
_ => src.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from(".")),
|
||||
};
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("Cannot create output dir: {e}"))?;
|
||||
|
||||
let edl = dir.join(format!("{stem}_singing.edl"));
|
||||
let csv = dir.join(format!("{stem}_singing.csv"));
|
||||
let markers = dir.join(format!("{stem}_markers.csv"));
|
||||
let json = dir.join(format!("{stem}_singing.json"));
|
||||
|
||||
write_edl(segments, &edl, fps, &format!("{stem} - Singing"), &source_name)
|
||||
.map_err(|e| format!("EDL write failed: {e}"))?;
|
||||
write_markers_csv(segments, &markers, fps).map_err(|e| format!("Markers write failed: {e}"))?;
|
||||
write_csv(segments, &csv).map_err(|e| format!("CSV write failed: {e}"))?;
|
||||
write_json(segments, &json).map_err(|e| format!("JSON write failed: {e}"))?;
|
||||
|
||||
Ok(ExportResult {
|
||||
files: [edl, markers, csv, json]
|
||||
.iter()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
142
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! Sing Detect — Tauri backend.
|
||||
//! Detects singing segments in a recording via a Python ML sidecar
|
||||
//! (inaSpeechSegmenter + TensorFlow), with ffmpeg for audio extraction, and
|
||||
//! exports editor-friendly markers (EDL / CSV / JSON).
|
||||
|
||||
mod detect;
|
||||
mod export;
|
||||
mod tools;
|
||||
|
||||
use std::process::Child;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
use detect::{DetectResult, Segment};
|
||||
use export::ExportResult;
|
||||
|
||||
pub struct AppState {
|
||||
pub proc: Mutex<Option<Child>>,
|
||||
pub cancel: AtomicBool,
|
||||
pub ffmpeg: Option<String>,
|
||||
pub ffprobe: Option<String>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
proc: Mutex::new(None),
|
||||
cancel: AtomicBool::new(false),
|
||||
ffmpeg: tools::find_tool("ffmpeg"),
|
||||
ffprobe: tools::find_tool("ffprobe"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ToolsInfo {
|
||||
ffmpeg: Option<String>,
|
||||
ffprobe: Option<String>,
|
||||
}
|
||||
|
||||
// ── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn get_tools(state: State<AppState>) -> ToolsInfo {
|
||||
ToolsInfo {
|
||||
ffmpeg: state.ffmpeg.clone(),
|
||||
ffprobe: state.ffprobe.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether the detection environment (Python 3.10 + ML packages) is
|
||||
/// ready. Read-only — installs nothing.
|
||||
#[tauri::command]
|
||||
async fn check_detection() -> detect::DetectionStatus {
|
||||
tauri::async_runtime::spawn_blocking(detect::check_env)
|
||||
.await
|
||||
.unwrap_or_else(|e| detect::DetectionStatus {
|
||||
python: false,
|
||||
packages: false,
|
||||
model: false,
|
||||
ready: false,
|
||||
detail: format!("Environment check failed: {e}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Install the detection ML stack and pre-download the model weights,
|
||||
/// streaming progress via the `detect-setup-progress` event.
|
||||
#[tauri::command]
|
||||
async fn setup_detection(app: AppHandle) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || detect::setup_env(&app))
|
||||
.await
|
||||
.map_err(|e| format!("setup task failed: {e}"))?
|
||||
}
|
||||
|
||||
// Detection runs the ML sidecar for a long time, so it MUST run off the main
|
||||
// thread or the window goes "Not responding". `spawn_blocking` keeps the UI
|
||||
// responsive while still resolving with the final result.
|
||||
#[tauri::command]
|
||||
async fn detect_songs(
|
||||
app: AppHandle,
|
||||
video: String,
|
||||
gap: f64,
|
||||
min_duration: f64,
|
||||
padding: f64,
|
||||
) -> Result<DetectResult, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let ffmpeg = state.ffmpeg.clone().ok_or("ffmpeg not found.")?;
|
||||
detect::run(&app, &state, &ffmpeg, video, gap, min_duration, padding)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("detect task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn export_segments(
|
||||
segments: Vec<Segment>,
|
||||
source_video: String,
|
||||
out_dir: Option<String>,
|
||||
fps: f64,
|
||||
) -> Result<ExportResult, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
export::export_all(&segments, &source_video, out_dir, fps)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("export task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cancel(state: State<AppState>) {
|
||||
state.cancel.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
if let Some(child) = state.proc.lock().unwrap().as_mut() {
|
||||
// Kill the whole tree: detection runs `py -3.10` → `python.exe`, and
|
||||
// killing only `py` would leave the Python/TensorFlow process alive.
|
||||
tools::kill_process_tree(child.id());
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
app.manage(AppState::new());
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_tools,
|
||||
check_detection,
|
||||
setup_detection,
|
||||
detect_songs,
|
||||
export_segments,
|
||||
cancel,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
sing_detect_lib::run()
|
||||
}
|
||||
81
src-tauri/src/tools.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
//! ffmpeg / ffprobe discovery, probing, and small shared helpers.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// On Windows, prevent child processes from flashing a console window.
|
||||
#[cfg(windows)]
|
||||
pub const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
/// Apply the no-window flag to a Command (no-op on non-Windows).
|
||||
pub fn hide_window(cmd: &mut Command) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = cmd;
|
||||
}
|
||||
}
|
||||
|
||||
/// Forcibly kill a process *and all of its descendants*.
|
||||
///
|
||||
/// `Child::kill()` only signals the direct child. That is fine for ffmpeg
|
||||
/// (a single process), but detection runs through the `py` launcher, which
|
||||
/// spawns the real `python.exe` as a child — killing `py` would orphan the
|
||||
/// TensorFlow process and the job would keep running. `taskkill /T` walks the
|
||||
/// whole tree.
|
||||
pub fn kill_process_tree(pid: u32) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut cmd = Command::new("taskkill");
|
||||
cmd.args(["/F", "/T", "/PID", &pid.to_string()]);
|
||||
hide_window(&mut cmd);
|
||||
let _ = cmd.output();
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Best-effort on Unix: kill the process group.
|
||||
let _ = Command::new("pkill").args(["-TERM", "-P", &pid.to_string()]).output();
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory the executable lives in (resources dir when bundled).
|
||||
fn app_dir() -> Option<PathBuf> {
|
||||
std::env::current_exe().ok()?.parent().map(|p| p.to_path_buf())
|
||||
}
|
||||
|
||||
/// Locate ffmpeg / ffprobe: next to the exe, then on PATH.
|
||||
pub fn find_tool(name: &str) -> Option<String> {
|
||||
let exe = format!("{name}.exe");
|
||||
if let Some(base) = app_dir() {
|
||||
for sub in ["", "bin", "resources", "_internal", ".."] {
|
||||
let cand = base.join(sub).join(&exe);
|
||||
if cand.exists() {
|
||||
return Some(cand.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to PATH discovery via `where` semantics.
|
||||
if which(&exe).is_some() {
|
||||
return Some(exe);
|
||||
}
|
||||
if which(name).is_some() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Minimal PATH lookup so we don't pull in an extra crate.
|
||||
fn which(name: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
for dir in std::env::split_paths(&path) {
|
||||
let full = dir.join(name);
|
||||
if full.is_file() {
|
||||
return Some(full);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
42
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Sing Detect",
|
||||
"version": "2.0.0",
|
||||
"identifier": "com.youfu.sing-detect",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Sing Detect",
|
||||
"width": 960,
|
||||
"height": 820,
|
||||
"minWidth": 720,
|
||||
"minHeight": 620,
|
||||
"dragDropEnabled": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["msi"],
|
||||
"resources": {
|
||||
"../sidecar": "sidecar"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||