Added tests and more

This commit is contained in:
Hannes committed 2026-03-22 03:21:39 +01:00
1 parent 295177be6f
commit a7a7abf720
9 files changed
+1327

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
import subprocess
import tempfile
import os
from pathlib import Path
from typing import List, Tuple, Optional
import numpy as np
from scipy.io import wavfile
class ConfidenceError(Exception):
"""Raised when an area is found but the energy difference is too low to be confident."""
pass
class MissingDetectionError(Exception):
"""Raised when the script fails to find both an intro and an outro."""
pass
def get_video_duration(file: Path) -> float:
"""Uses ffprobe to get the total duration of the video."""
cmd = [
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(file)
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
return float(result.stdout.strip())
except ValueError:
raise RuntimeError(f"Could not determine duration for {file}")
def extract_audio_segment_tmp(
file: Path,
start_or_section: float | tuple[float, float],
end: Optional[float] = None,
audio_stream: Optional[int] = None
) -> Path:
"""Extracts audio using FFmpeg and saves it to a temporary WAV file."""
if isinstance(start_or_section, tuple):
if len(start_or_section) != 2:
raise ValueError("Tuple must strictly be (start, end)")
start = float(start_or_section[0])
duration = float(start_or_section[1])
elif isinstance(start_or_section, (int, float)):
if end is None:
raise ValueError("The 'end' parameter must be provided when using a separate start float.")
start = float(start_or_section)
end_time = float(end)
duration = end_time - start
else:
raise TypeError("Invalid type for start_or_section. Expected float or tuple.")
if duration <= 0:
raise ValueError("Duration must be above 0")
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp_path = Path(tmp.name)
tmp.close()
cmd = [
"ffmpeg", "-y",
"-i", str(file),
"-ss", str(start),
"-t", str(duration)
]
if audio_stream is not None:
cmd += ["-map", f"0:a:{audio_stream}"]
else:
cmd += ["-map", "0:a:0"]
cmd += [
"-vn",
"-acodec", "pcm_s16le",
"-ar", "44100",
"-ac", "2",
str(tmp_path)
]
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
return tmp_path
def find_high_power_segment(wav_path: Path, expected_length: float, confidence_threshold: float = 1.0) -> float:
rate, data = wavfile.read(wav_path)
if len(data.shape) > 1:
data = data.mean(axis=1)
data = data.astype(np.float32) / (np.max(np.abs(data)) + 1e-9)
chunk_size = rate
num_chunks = len(data) // chunk_size
# Calculate RMS (Volume) and Standard Deviation (Variation) for every second
powers = np.zeros(num_chunks)
variations = np.zeros(num_chunks)
for i in range(num_chunks):
chunk = data[i * chunk_size : (i + 1) * chunk_size]
powers[i] = np.sqrt(np.mean(chunk**2))
variations[i] = np.std(chunk)
window_size = int(expected_length)
if len(powers) < window_size:
raise ValueError("Audio segment too short.")
# SCORING ALGORITHM:
# We want High Power and Low Variation (that "solid block" look)
# We invert variation so that a small variation results in a higher score.
scores = []
for i in range(len(powers) - window_size + 1):
win_power = np.mean(powers[i : i + window_size])
win_var = np.mean(variations[i : i + window_size])
# The score increases with power and decreases as the variation increases
# We add a small constant to win_var to avoid division by zero
score = win_power / (win_var + 0.05)
scores.append(score)
best_idx = np.argmax(scores)
# Confidence check: is our best score significantly better than the average score?
avg_score = np.mean(scores)
if scores[best_idx] < avg_score * confidence_threshold:
raise ConfidenceError("Detection unreliable: no distinct high-power constant block found.")
return float(best_idx)
def get_intro_outro(file: Path, length: float = 90.0) -> List[Tuple[float, float]]:
"""
Finds the intro and outro of a video file by analyzing audio power peaks.
Searches the first 8 minutes and the last 8 minutes.
"""
results = []
total_duration = get_video_duration(file)
search_window = 8 * 60.0 # 8 minutes
# --- 1. FIND INTRO ---
intro_search_dur = min(search_window, total_duration)
intro_wav = extract_audio_segment_tmp(file, 0.0, intro_search_dur)
try:
intro_start_rel = find_high_power_segment(intro_wav, length)
results.append((intro_start_rel, length))
except ConfidenceError:
intro_wav.unlink()
raise ConfidenceError("Could not confidently detect the Intro.")
finally:
if intro_wav.exists():
intro_wav.unlink() # Cleanup temp file
# --- 2. FIND OUTRO ---
outro_search_start = max(0.0, total_duration - search_window)
outro_search_dur = total_duration - outro_search_start
outro_wav = extract_audio_segment_tmp(file, outro_search_start, total_duration)
try:
outro_start_rel = find_high_power_segment(outro_wav, length)
# Add the 8-minute offset back to get the absolute timestamp in the video
outro_start_abs = outro_search_start + outro_start_rel
results.append((outro_start_abs, length))
except ConfidenceError:
outro_wav.unlink()
raise ConfidenceError("Could not confidently detect the Outro.")
finally:
if outro_wav.exists():
outro_wav.unlink() # Cleanup temp file
# Ensure we got both
if len(results) < 2:
raise MissingDetectionError("Failed to find both an intro and an outro.")
return results
# --- Example Usage ---
if __name__ == "__main__":
try:
# Requires: pip install numpy scipy
file_path = Path("/home/honney/mount/Media/tmp/serien/ready/Charlotte/Season 01/[NO CHAPTER]_E04.mkv")
if file_path.exists():
timestamps = get_intro_outro(file_path, length=90.0)
print("Found Intro and Outro [Start, Duration]:", timestamps)
except Exception as e:
print(f"Error: {e}")