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:
188
v5_stream_tools_app/core/exporters.py
Normal file
188
v5_stream_tools_app/core/exporters.py
Normal 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),
|
||||
])
|
||||
99
v5_stream_tools_app/core/fftools.py
Normal file
99
v5_stream_tools_app/core/fftools.py
Normal 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
|
||||
118
v5_stream_tools_app/core/segmenter.py
Normal file
118
v5_stream_tools_app/core/segmenter.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user