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:
2026-06-26 23:12:25 +08:00
parent 765816e701
commit 4887981a2d
51 changed files with 8250 additions and 52 deletions

7
src-tauri/.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

33
src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

380
src-tauri/src/detect.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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"
]
}
}