v5: refactor into modular stream_tools desktop app

Same inaSpeechSegmenter engine split into reusable core/ (fftools, segmenter, exporters) + utils/ and a CustomTkinter multi-tab UI. Adds JSON and ffmpeg-script export. Extracted from the stream_tools app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-01-05 10:00:00 +00:00
parent b70402932e
commit 878be5fe4a
6 changed files with 1016 additions and 0 deletions

View File

@@ -0,0 +1,188 @@
"""
Export singing segments to various formats:
- EDL (DaVinci Resolve timeline import)
- CSV (human-readable)
- DaVinci Resolve Markers CSV
- JSON (programmatic)
- ffmpeg concat script (auto-cut)
"""
import csv
import json
import os
import sys
from pathlib import Path
from utils.formats import format_timecode, seconds_to_smpte
def export_edl(segments: list, output_path: str, fps: float = 29.97,
title: str = "Singing Segments", source_filename: str = None):
"""Export EDL file for DaVinci Resolve."""
if source_filename:
clip_name = source_filename
reel_name_safe = Path(source_filename).stem[:32].replace(" ", "_")
else:
clip_name = None
reel_name_safe = "001"
rec_offset = 3600.0
with open(output_path, "w", encoding="utf-8") as f:
f.write(f"TITLE: {title}\n")
f.write("FCM: NON-DROP FRAME\n\n")
current_rec_pos = rec_offset
for seg in segments:
edit_num = f"{seg['index']:03d}"
src_in = seconds_to_smpte(seg["start"], fps)
src_out = seconds_to_smpte(seg["end"], fps)
rec_in = seconds_to_smpte(current_rec_pos, fps)
rec_out = seconds_to_smpte(current_rec_pos + seg["duration"], fps)
f.write(f"{edit_num} {reel_name_safe} V C {src_in} {src_out} {rec_in} {rec_out}\n")
if clip_name:
f.write(f"* FROM CLIP NAME: {clip_name}\n")
f.write(f"* COMMENT: Song {seg['index']} - Duration {seg['duration']:.0f}s\n\n")
current_rec_pos += seg["duration"]
def export_csv(segments: list, output_path: str):
"""Export singing segments as CSV."""
with open(output_path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["index", "start_sec", "end_sec", "duration_sec",
"start_timecode", "end_timecode"])
for seg in segments:
writer.writerow([
seg["index"],
f"{seg['start']:.2f}",
f"{seg['end']:.2f}",
f"{seg['duration']:.1f}",
format_timecode(seg["start"]),
format_timecode(seg["end"]),
])
def export_markers_csv(segments: list, output_path: str, fps: float = 29.97):
"""Export DaVinci Resolve Markers CSV."""
with open(output_path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["#", "Color", "Name", "Start TC", "End TC", "Duration TC", "Notes"])
for seg in segments:
writer.writerow([
seg["index"],
"Blue",
f"Song {seg['index']}",
seconds_to_smpte(seg["start"], fps),
seconds_to_smpte(seg["end"], fps),
seconds_to_smpte(seg["duration"], fps),
f"Duration: {seg['duration']:.0f}s",
])
def export_json(segments: list, output_path: str):
"""Export as JSON."""
with open(output_path, "w", encoding="utf-8") as f:
json.dump(segments, f, indent=2, ensure_ascii=False)
def export_ffmpeg_script(segments: list, video_path: str, output_dir: str):
"""
Export a script that uses ffmpeg to cut and concatenate all
singing segments into a single video file.
"""
video_name = Path(video_path).stem
ext = Path(video_path).suffix
out_video = str(Path(output_dir) / f"{video_name}_singing_only{ext}")
is_windows = os.name == "nt" or sys.platform == "win32"
script_ext = ".bat" if is_windows else ".sh"
script_path = str(Path(output_dir) / f"{video_name}_autocut{script_ext}")
concat_list_path = str(Path(output_dir) / f"{video_name}_concat_list.txt")
lines = []
if is_windows:
lines.append("@echo off")
lines.append("REM Auto-generated ffmpeg script to extract singing segments")
lines.append(f'REM Source: {video_path}')
lines.append("")
else:
lines.append("#!/bin/bash")
lines.append("# Auto-generated ffmpeg script to extract singing segments")
lines.append(f'# Source: {video_path}')
lines.append("")
segment_files = []
for seg in segments:
seg_file = f"_seg_{seg['index']:03d}{ext}"
seg_path = str(Path(output_dir) / seg_file)
segment_files.append(seg_file)
cmd = (f'ffmpeg -y -ss {seg["start"]:.2f} -i "{video_path}" '
f'-t {seg["duration"]:.2f} -c copy "{seg_path}"')
lines.append(f"echo Extracting Song {seg['index']}...")
lines.append(cmd)
lines.append("")
lines.append("echo Creating concat list...")
if is_windows:
lines.append("(")
for sf in segment_files:
seg_path = str(Path(output_dir) / sf)
lines.append(f" echo file '{seg_path}'")
lines.append(f') > "{concat_list_path}"')
else:
for i, sf in enumerate(segment_files):
seg_path = str(Path(output_dir) / sf)
op = ">" if i == 0 else ">>"
lines.append(f"echo \"file '{seg_path}'\" {op} \"{concat_list_path}\"")
lines.append("")
lines.append("echo Concatenating all segments...")
lines.append(f'ffmpeg -y -f concat -safe 0 -i "{concat_list_path}" -c copy "{out_video}"')
lines.append("")
lines.append("echo Cleaning up temp files...")
for sf in segment_files:
seg_path = str(Path(output_dir) / sf)
if is_windows:
lines.append(f'del "{seg_path}"')
else:
lines.append(f'rm -f "{seg_path}"')
if is_windows:
lines.append(f'del "{concat_list_path}"')
else:
lines.append(f'rm -f "{concat_list_path}"')
lines.append("")
lines.append(f'echo Done! Output: {out_video}')
if is_windows:
lines.append("pause")
with open(script_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
if not is_windows:
os.chmod(script_path, 0o755)
return script_path
def export_raw_segments(segments: list, output_path: str):
"""Export ALL raw segments (speech/music/noise) as CSV for debugging."""
with open(output_path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["label", "start_sec", "end_sec", "duration_sec",
"start_timecode", "end_timecode"])
for label, start, end in segments:
writer.writerow([
label,
f"{start:.2f}",
f"{end:.2f}",
f"{end - start:.1f}",
format_timecode(start),
format_timecode(end),
])

View File

@@ -0,0 +1,99 @@
"""
ffmpeg / ffprobe discovery and probing helpers.
Shared by both concat and singing detection features.
"""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
# ── Suppress console windows on Windows ──────────────────────────────────────
_NO_WINDOW: dict = {}
if sys.platform == "win32":
_NO_WINDOW["creationflags"] = subprocess.CREATE_NO_WINDOW
def app_dir() -> str:
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
def find_tool(name: str) -> str | None:
"""Locate ffmpeg / ffprobe next to the exe / script, then on PATH."""
base = app_dir()
for sub in ["_internal", "..", ""]:
for variant in [name + ".exe", name]:
p = os.path.join(base, sub, variant)
if os.path.exists(p):
return os.path.abspath(p)
return shutil.which(name + ".exe") or shutil.which(name)
def probe_duration(path: str, ffprobe_bin: str | None = None) -> float:
"""Get video/audio duration in seconds via ffprobe."""
ffprobe = ffprobe_bin or find_tool("ffprobe")
if not ffprobe:
return 0.0
try:
r = subprocess.run(
[ffprobe, "-v", "error",
"-show_entries", "format=duration",
"-of", "json", path],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=20,
**_NO_WINDOW,
)
if r.returncode != 0:
return 0.0
data = json.loads(r.stdout or "{}")
return max(0.0, float(data.get("format", {}).get("duration", 0) or 0))
except Exception:
return 0.0
def extract_audio(video_path: str, output_wav: str,
ffmpeg_bin: str | None = None,
progress_cb=None) -> str:
"""
Extract audio from video file as 16kHz mono WAV.
Required format for inaSpeechSegmenter.
progress_cb: optional callable(message: str) for status updates.
"""
ffmpeg = ffmpeg_bin or find_tool("ffmpeg")
if not ffmpeg:
raise FileNotFoundError("ffmpeg not found. Install ffmpeg and add to PATH.")
if progress_cb:
progress_cb(f"Extracting audio from: {Path(video_path).name}")
cmd = [
ffmpeg,
"-i", video_path,
"-vn",
"-acodec", "pcm_s16le",
"-ar", "16000",
"-ac", "1",
"-y",
output_wav,
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=1800,
**_NO_WINDOW,
)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg failed:\n{result.stderr[-500:]}")
if progress_cb:
size_mb = os.path.getsize(output_wav) / (1024 * 1024)
progress_cb(f"Audio extracted: {size_mb:.1f} MB")
return output_wav

View File

@@ -0,0 +1,118 @@
"""
Singing segment detection via inaSpeechSegmenter.
Pipeline:
1. Run inaSpeechSegmenter to classify speech/music/noise
2. Filter for 'music' segments (singing voice is classified as music)
3. Merge nearby segments, apply padding, filter by minimum duration
"""
import time
from utils.formats import format_timecode
def check_segmenter_available() -> tuple[bool, str]:
"""Check if inaSpeechSegmenter is installed."""
try:
from inaSpeechSegmenter import Segmenter # noqa: F401
return True, ""
except ImportError:
return False, (
"inaSpeechSegmenter is not installed.\n\n"
"Install it with:\n"
" pip install inaSpeechSegmenter\n\n"
"You also need TensorFlow:\n"
" pip install tensorflow[and-cuda] (GPU)\n"
" pip install tensorflow (CPU)"
)
def run_segmentation(wav_path: str, progress_cb=None) -> list:
"""
Run inaSpeechSegmenter on audio file.
Returns list of (label, start_sec, end_sec) tuples.
Labels: 'music', 'speech', 'noise', 'noEnergy'
"""
from inaSpeechSegmenter import Segmenter
if progress_cb:
progress_cb("Loading segmentation model…")
start_time = time.time()
seg = Segmenter(vad_engine='smn', detect_gender=False)
if progress_cb:
progress_cb("Running audio segmentation (this may take a while)…")
segments = seg(wav_path)
elapsed = time.time() - start_time
if progress_cb:
progress_cb(f"Segmentation complete in {elapsed:.1f}s — {len(segments)} raw segments")
return segments
def merge_singing_segments(
segments: list,
max_gap: float = 10.0,
min_duration: float = 20.0,
padding: float = 2.0,
) -> list:
"""
Filter for 'music' segments and merge nearby ones.
Returns list of dicts:
[{"index", "start", "end", "duration", "original_start", "original_end"}, ...]
"""
music_segs = [(start, end) for label, start, end in segments if label == "music"]
if not music_segs:
return []
music_segs.sort(key=lambda x: x[0])
# Merge segments with gaps smaller than max_gap
merged = []
current_start, current_end = music_segs[0]
for start, end in music_segs[1:]:
if start - current_end <= max_gap:
current_end = max(current_end, end)
else:
merged.append((current_start, current_end))
current_start, current_end = start, end
merged.append((current_start, current_end))
# Apply padding and minimum duration filter
results = []
idx = 1
for start, end in merged:
duration = end - start
if duration >= min_duration:
padded_start = max(0, start - padding)
padded_end = end + padding
results.append({
"index": idx,
"start": padded_start,
"end": padded_end,
"duration": padded_end - padded_start,
"original_start": start,
"original_end": end,
})
idx += 1
return results
def get_segment_stats(segments: list) -> dict:
"""Get summary statistics from raw segmentation output."""
type_counts = {}
type_durations = {}
for label, start, end in segments:
type_counts[label] = type_counts.get(label, 0) + 1
type_durations[label] = type_durations.get(label, 0) + (end - start)
return {
"counts": type_counts,
"durations": type_durations,
}