feat: QMediaPlayer video playback and whisper audio rename tool
This commit is contained in:
1 parent
c06c7fbc69
commit
10f03ec6d3
3 files changed
+523
-193
No files matched your search
@@ -51,8 +51,8 @@ install:
|
|||||||
fi
|
fi
|
||||||
@echo "=> installing common deps..."
|
@echo "=> installing common deps..."
|
||||||
@$(PIP_INSTALL) soundfile scipy numpy pyqtgraph PyQt6 || echo " common deps failed"
|
@$(PIP_INSTALL) soundfile scipy numpy pyqtgraph PyQt6 || echo " common deps failed"
|
||||||
@echo "=> optional sounddevice (audio playback, volume slider)..."
|
@echo "=> optional: sounddevice (playback), openai-whisper (language detection)..."
|
||||||
@$(PIP_INSTALL) sounddevice || echo " sounddevice optional failed (PortAudio missing) -> playback disabled"
|
@$(PIP_INSTALL) sounddevice openai-whisper || echo " optional deps failed (sounddevice/whisper)"
|
||||||
@$(PY) -c "import torch, sys; print(f'torch {torch.__version__} cuda_available={torch.cuda.is_available()}', end=''); print(f' device={torch.cuda.get_device_name(0)}' if torch.cuda.is_available() else ' (cpu)')" 2>&1 || echo "torch not available, using numpy CPU fallback (still functional for 90s xcorr)"
|
@$(PY) -c "import torch, sys; print(f'torch {torch.__version__} cuda_available={torch.cuda.is_available()}', end=''); print(f' device={torch.cuda.get_device_name(0)}' if torch.cuda.is_available() else ' (cpu)')" 2>&1 || echo "torch not available, using numpy CPU fallback (still functional for 90s xcorr)"
|
||||||
@echo "=> done. Activate: source $(VENV)/bin/activate"
|
@echo "=> done. Activate: source $(VENV)/bin/activate"
|
||||||
|
|
||||||
|
|||||||
+403
@@ -0,0 +1,403 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Detect audio stream languages via Whisper and set MKV metadata.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python rename_audio.py /path/to/movie.mkv
|
||||||
|
python rename_audio.py /path/to/movie.mkv --apply
|
||||||
|
python rename_audio.py /path/to/movie.mkv --apply --stream 0,2
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Probes all audio streams in the file
|
||||||
|
- Runs Whisper language detection on each (showing confidence scores)
|
||||||
|
- Detects uncertain results (low confidence / top language < 80%)
|
||||||
|
- Sets language code + updates title with detected language name
|
||||||
|
- No re-encode — metadata only via ffmpeg -map_metadata + -metadata
|
||||||
|
|
||||||
|
Requires: pip install openai-whisper (or faster-whisper)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
import whisper
|
||||||
|
HAS_WHISPER = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_WHISPER = False
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Language code ↔ name mapping (ISO 639-1 / ISO 639-2 for MKV) ───
|
||||||
|
|
||||||
|
LANG_NAMES = {
|
||||||
|
"en": "English", "de": "German", "fr": "French", "es": "Spanish",
|
||||||
|
"it": "Italian", "pt": "Portuguese", "nl": "Dutch", "ru": "Russian",
|
||||||
|
"ja": "Japanese", "ko": "Korean", "zh": "Chinese", "ar": "Arabic",
|
||||||
|
"hi": "Hindi", "tr": "Turkish", "pl": "Polish", "sv": "Swedish",
|
||||||
|
"da": "Danish", "no": "Norwegian", "fi": "Finnish", "cs": "Czech",
|
||||||
|
"ro": "Romanian", "hu": "Hungarian", "uk": "Ukrainian", "el": "Greek",
|
||||||
|
"he": "Hebrew", "th": "Thai", "vi": "Vietnamese", "id": "Indonesian",
|
||||||
|
"ms": "Malay", "tl": "Filipino", "ca": "Catalan", "eu": "Basque",
|
||||||
|
"gl": "Galician", "af": "Afrikaans", "bg": "Bulgarian", "hr": "Croatian",
|
||||||
|
"sk": "Slovak", "sl": "Slovenian", "sr": "Serbian", "lt": "Lithuanian",
|
||||||
|
"lv": "Latvian", "et": "Estonian", "sw": "Swahili", "ta": "Tamil",
|
||||||
|
"te": "Telugu", "bn": "Bengali", "ur": "Urdu", "fa": "Persian",
|
||||||
|
"ga": "Irish", "cy": "Welsh", "is": "Icelandic", "mk": "Macedonian",
|
||||||
|
"sq": "Albanian", "bs": "Bosnian", "mt": "Maltese", "hy": "Armenian",
|
||||||
|
"ka": "Georgian", "km": "Khmer", "my": "Burmese", "lo": "Lao",
|
||||||
|
"ne": "Nepali", "si": "Sinhala", "kk": "Kazakh", "uz": "Uzbek",
|
||||||
|
"mn": "Mongolian", "ps": "Pashto", "sd": "Sindhi", "gu": "Gujarati",
|
||||||
|
"pa": "Punjabi", "mr": "Marathi", "kn": "Kannada", "ml": "Malayalam",
|
||||||
|
"or": "Odia", "as": "Assamese", "ha": "Hausa", "yo": "Yoruba",
|
||||||
|
"zu": "Zulu", "ig": "Igbo", "so": "Somali", "am": "Amharic",
|
||||||
|
"rw": "Kinyarwanda", "sn": "Shona", "mg": "Malagasy", "wo": "Wolof",
|
||||||
|
"ny": "Chichewa", "st": "Southern Sotho", "tn": "Tswana",
|
||||||
|
"ts": "Tsonga", "ve": "Venda", "xh": "Xhosa", "ab": "Abkhaz",
|
||||||
|
"aa": "Afar", "ak": "Akan", "an": "Aragonese", "as": "Assamese",
|
||||||
|
"av": "Avaric", "ay": "Aymara", "az": "Azerbaijani", "be": "Belarusian",
|
||||||
|
"bh": "Bihari", "bi": "Bislama", "bm": "Bambara", "bo": "Tibetan",
|
||||||
|
"br": "Breton", "ch": "Chamorro", "co": "Corsican", "cr": "Cree",
|
||||||
|
"cu": "Old Church Slavonic", "cv": "Chuvash", "dv": "Divehi",
|
||||||
|
"dz": "Dzongkha", "ee": "Ewe", "fo": "Faroese", "fj": "Fijian",
|
||||||
|
"fy": "Western Frisian", "gd": "Scottish Gaelic", "gn": "Guarani",
|
||||||
|
"gv": "Manx", "ht": "Haitian", "hz": "Herero", "ia": "Interlingua",
|
||||||
|
"ie": "Interlingue", "ik": "Inupiaq", "io": "Ido", "jv": "Javanese",
|
||||||
|
"ka": "Georgian", "kg": "Kongo", "ki": "Kikuyu", "kj": "Kwanyama",
|
||||||
|
"kl": "Kalaallisut", "kr": "Kanuri", "ku": "Kurdish", "ky": "Kyrgyz",
|
||||||
|
"lb": "Luxembourgish", "lg": "Ganda", "li": "Limburgish", "ln": "Lingala",
|
||||||
|
"lt": "Lithuanian", "lu": "Luba-Katanga", "lv": "Latvian",
|
||||||
|
"mg": "Malagasy", "mh": "Marshall", "mi": "Maori", "mk": "Macedonian",
|
||||||
|
"mn": "Mongolian", "mo": "Moldavian", "mr": "Marathi", "ms": "Malay",
|
||||||
|
"mt": "Maltese", "na": "Nauru", "nb": "Norwegian Bokmal",
|
||||||
|
"nd": "North Ndebele", "ne": "Nepali", "ng": "Ndonga",
|
||||||
|
"nn": "Norwegian Nynorsk", "nr": "South Ndebele", "nv": "Navajo",
|
||||||
|
"oc": "Occitan", "oj": "Ojibwa", "om": "Oromo", "or": "Odia",
|
||||||
|
"os": "Ossetic", "pi": "Pali", "ps": "Pashto", "qu": "Quechua",
|
||||||
|
"rm": "Romansh", "rn": "Rundi", "rw": "Kinyarwanda",
|
||||||
|
"sa": "Sanskrit", "sc": "Sardinian", "se": "Northern Sami",
|
||||||
|
"sg": "Sango", "si": "Sinhala", "sm": "Samoan", "sn": "Shona",
|
||||||
|
"sd": "Sindhi", "so": "Somali", "sr": "Serbian", "ss": "Swati",
|
||||||
|
"su": "Sundanese", "sw": "Swahili", "ty": "Tahitian",
|
||||||
|
"ug": "Uyghur", "uz": "Uzbek", "ve": "Venda", "vo": "Volapuk",
|
||||||
|
"wa": "Walloon", "wo": "Wolof", "xh": "Xhosa", "yi": "Yiddish",
|
||||||
|
"yo": "Yoruba", "za": "Zhuang", "zu": "Zulu",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ISO 639-2/B codes for MKV (3-letter)
|
||||||
|
LANG_CODE_TO_3 = {
|
||||||
|
"en": "eng", "de": "ger", "fr": "fre", "es": "spa", "it": "ita",
|
||||||
|
"pt": "por", "nl": "dut", "ru": "rus", "ja": "jpn", "ko": "kor",
|
||||||
|
"zh": "chi", "ar": "ara", "hi": "hin", "tr": "tur", "pl": "pol",
|
||||||
|
"sv": "swe", "da": "dan", "no": "nor", "fi": "fin", "cs": "cze",
|
||||||
|
"ro": "rum", "hu": "hun", "uk": "ukr", "el": "gre", "he": "heb",
|
||||||
|
"th": "tha", "vi": "vie", "id": "ind", "ms": "may", "tl": "fil",
|
||||||
|
"ca": "cat", "eu": "baq", "gl": "gal", "af": "afr", "bg": "bul",
|
||||||
|
"hr": "hrv", "sk": "slo", "sl": "slv", "sr": "srp", "lt": "lit",
|
||||||
|
"lv": "lav", "et": "est", "sw": "swa", "ta": "tam", "te": "tel",
|
||||||
|
"bn": "ben", "ur": "urd", "fa": "per", "ga": "gle", "cy": "wel",
|
||||||
|
"is": "ice", "mk": "mac", "sq": "alb", "bs": "bos", "mt": "mlt",
|
||||||
|
"hy": "arm", "ka": "geo", "km": "khm", "my": "bur", "lo": "lao",
|
||||||
|
"ne": "nep", "si": "sin", "kk": "kaz", "uz": "uzb", "mn": "mon",
|
||||||
|
"ps": "pus", "sd": "snd", "gu": "guj", "pa": "pan", "as": "asm",
|
||||||
|
"ha": "hau", "yo": "yor", "zu": "zul", "ig": "ibo", "so": "som",
|
||||||
|
"am": "amh", "rw": "kin", "sn": "sna", "ny": "nya", "wo": "wol",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def probe_audio_streams(path: str) -> list[dict]:
|
||||||
|
"""Probe all audio streams in a file."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["ffprobe", "-v", "error", "-select_streams", "a",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=index,codec_name,channels,sample_rate,bit_rate,"
|
||||||
|
"stream_tags=language,title",
|
||||||
|
"-of", "json", path],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return []
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
streams = []
|
||||||
|
for s in data.get("streams", []):
|
||||||
|
tags = {k.lower(): v for k, v in (s.get("tags") or {}).items()}
|
||||||
|
sr = s.get("sample_rate")
|
||||||
|
streams.append({
|
||||||
|
"index": s.get("index", 0),
|
||||||
|
"codec": s.get("codec_name", ""),
|
||||||
|
"channels": s.get("channels", 0),
|
||||||
|
"sample_rate": int(sr) if sr else 0,
|
||||||
|
"language": tags.get("language", ""),
|
||||||
|
"title": tags.get("title", ""),
|
||||||
|
})
|
||||||
|
return streams
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def extract_audio_segment(video_path: str, stream_index: int, out_wav: str,
|
||||||
|
start_sec: float = 0, duration_sec: float = 60) -> bool:
|
||||||
|
"""Extract a segment of an audio stream for language detection."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["ffmpeg", "-y",
|
||||||
|
"-ss", str(start_sec),
|
||||||
|
"-i", video_path,
|
||||||
|
"-map", f"0:a:{stream_index}",
|
||||||
|
"-t", str(duration_sec),
|
||||||
|
"-acodec", "pcm_f32le", "-ar", "16000", "-ac", "1",
|
||||||
|
"-v", "error", out_wav],
|
||||||
|
capture_output=True, timeout=30
|
||||||
|
)
|
||||||
|
return r.returncode == 0 and os.path.exists(out_wav) and os.path.getsize(out_wav) > 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def detect_language_whisper(wav_path: str, model_name: str = "base") -> tuple[str, float, dict]:
|
||||||
|
"""
|
||||||
|
Detect language using Whisper. Returns (lang_code, top_probability, full_probs).
|
||||||
|
"""
|
||||||
|
if not HAS_WHISPER:
|
||||||
|
raise RuntimeError("openai-whisper not installed. Run: pip install openai-whisper")
|
||||||
|
|
||||||
|
model = whisper.load_model(model_name)
|
||||||
|
audio = whisper.load_audio(wav_path)
|
||||||
|
# trim to 30 seconds max for speed
|
||||||
|
audio = audio[:30 * 16000]
|
||||||
|
|
||||||
|
result = model.detect_language(audio)
|
||||||
|
lang = result["language"]
|
||||||
|
probs = result.get("probabilities", {})
|
||||||
|
|
||||||
|
# Build full probability dict
|
||||||
|
full_probs = {}
|
||||||
|
if probs is not None:
|
||||||
|
for i, p in enumerate(probs):
|
||||||
|
lang_token = whisper.tokenizer._lang_code_to_token.get(i, "")
|
||||||
|
if lang_token:
|
||||||
|
full_probs[lang_token] = float(p)
|
||||||
|
|
||||||
|
# Top probability
|
||||||
|
top_prob = full_probs.get(lang, 0.0)
|
||||||
|
return lang, top_prob, full_probs
|
||||||
|
|
||||||
|
|
||||||
|
def get_lang_name(code: str) -> str:
|
||||||
|
"""Get human-readable language name from ISO 639-1 code."""
|
||||||
|
return LANG_NAMES.get(code, code.upper())
|
||||||
|
|
||||||
|
|
||||||
|
def apply_metadata(file_path: str, stream_idx: int, lang_code_3: str,
|
||||||
|
title: str, dry_run: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
Apply language metadata to a specific audio stream.
|
||||||
|
Returns the ffmpeg command (for display) or runs it.
|
||||||
|
"""
|
||||||
|
# Use 3-letter ISO 639-2/B code for MKV
|
||||||
|
meta_key = f"metadata:s:a:{stream_idx}"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", file_path,
|
||||||
|
"-map", "0",
|
||||||
|
"-c", "copy",
|
||||||
|
f"{meta_key}:language={lang_code_3}",
|
||||||
|
f"{meta_key}:title={title}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return " ".join(cmd)
|
||||||
|
|
||||||
|
# Write to temp file, then replace
|
||||||
|
base, ext = os.path.splitext(file_path)
|
||||||
|
tmp_out = f"{tmp_out_base}{ext}"
|
||||||
|
cmd.append(tmp_out)
|
||||||
|
|
||||||
|
r = subprocess.run(cmd, capture_output=True, timeout=300)
|
||||||
|
if r.returncode != 0:
|
||||||
|
print(f" ERROR: ffmpeg metadata failed: {r.stderr.decode()[-200:]}", file=sys.stderr)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Replace original
|
||||||
|
shutil.move(tmp_out, file_path)
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Detect audio languages via Whisper and set MKV metadata")
|
||||||
|
ap.add_argument("file", help="MKV/MP4 file to process")
|
||||||
|
ap.add_argument("--apply", action="store_true", help="Actually apply changes (default: dry run)")
|
||||||
|
ap.add_argument("--stream", default=None, help="Process specific stream indices (comma-separated, e.g. 0,2)")
|
||||||
|
ap.add_argument("--model", default="base", help="Whisper model (tiny/base/small/medium/large)")
|
||||||
|
ap.add_argument("--segment", type=float, default=60, help="Seconds of audio to analyze (default: 60)")
|
||||||
|
ap.add_argument("--start", type=float, default=0, help="Start offset for analysis (seconds)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not os.path.exists(args.file):
|
||||||
|
print(f"ERROR: file not found: {args.file}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not HAS_WHISPER:
|
||||||
|
print("ERROR: openai-whisper not installed.", file=sys.stderr)
|
||||||
|
print("Install with: pip install openai-whisper", file=sys.stderr)
|
||||||
|
print("Or for faster detection: pip install faster-whisper", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
streams = probe_audio_streams(args.file)
|
||||||
|
if not streams:
|
||||||
|
print("No audio streams found.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"File: {args.file}")
|
||||||
|
print(f"Audio streams: {len(streams)}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Filter streams if --stream specified
|
||||||
|
if args.stream:
|
||||||
|
selected = [int(x.strip()) for x in args.stream.split(",")]
|
||||||
|
streams = [s for s in streams if s["index"] in selected]
|
||||||
|
|
||||||
|
# Create temp dir for extracted segments
|
||||||
|
with tempfile.TemporaryDirectory(prefix="rename_audio_") as tmp_dir:
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for s in streams:
|
||||||
|
idx = s["index"]
|
||||||
|
cur_lang = s["language"]
|
||||||
|
cur_title = s["title"]
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
print(f"Stream [{idx}]: {s['codec'].upper()} {s['channels']}ch")
|
||||||
|
print(f" Current language: {cur_lang or '(none)'}")
|
||||||
|
print(f" Current title: {cur_title or '(none)'}")
|
||||||
|
|
||||||
|
# Extract audio segment
|
||||||
|
wav_path = os.path.join(tmp_dir, f"stream_{idx}.wav")
|
||||||
|
print(f" Extracting {args.segment}s from {args.start}s...")
|
||||||
|
if not extract_audio_segment(args.file, idx, wav_path, args.start, args.segment):
|
||||||
|
print(f" SKIP: failed to extract audio", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Detect language
|
||||||
|
print(f" Detecting language with Whisper ({args.model})...")
|
||||||
|
try:
|
||||||
|
lang, top_prob, all_probs = detect_language_whisper(wav_path, args.model)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR: detection failed: {e}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
lang_name = get_lang_name(lang)
|
||||||
|
print(f"\n Detected: {lang_name} ({lang}) — confidence: {top_prob:.1%}")
|
||||||
|
|
||||||
|
# Show top 5 probabilities
|
||||||
|
sorted_probs = sorted(all_probs.items(), key=lambda x: -x[1])[:5]
|
||||||
|
print(f" Top languages:")
|
||||||
|
for l, p in sorted_probs:
|
||||||
|
marker = " <<<" if l == lang else ""
|
||||||
|
print(f" {get_lang_name(l):20s} ({l}) {p:.1%}{marker}")
|
||||||
|
|
||||||
|
# Confidence assessment
|
||||||
|
if top_prob >= 0.80:
|
||||||
|
confidence = "HIGH"
|
||||||
|
elif top_prob >= 0.50:
|
||||||
|
confidence = "MEDIUM (review recommended)"
|
||||||
|
else:
|
||||||
|
confidence = "LOW (likely unsupported language)"
|
||||||
|
print(f" Confidence: {confidence}")
|
||||||
|
|
||||||
|
# Determine new metadata
|
||||||
|
lang_3 = LANG_CODE_TO_3.get(lang, lang)
|
||||||
|
needs_change = False
|
||||||
|
|
||||||
|
if cur_lang != lang_3:
|
||||||
|
needs_change = True
|
||||||
|
print(f"\n → Language will change: {cur_lang or '(none)'} → {lang_3}")
|
||||||
|
else:
|
||||||
|
print(f"\n → Language already correct: {lang_3}")
|
||||||
|
|
||||||
|
# Build new title: prepend detected language name if not already present
|
||||||
|
new_title = cur_title
|
||||||
|
if lang_name.lower() not in (cur_title or "").lower():
|
||||||
|
if cur_title:
|
||||||
|
new_title = f"{lang_name} {cur_title}"
|
||||||
|
else:
|
||||||
|
new_title = lang_name
|
||||||
|
if new_title != cur_title:
|
||||||
|
needs_change = True
|
||||||
|
print(f" → Title will change: \"{cur_title or '(none)'}\" → \"{new_title}\"")
|
||||||
|
|
||||||
|
if not needs_change:
|
||||||
|
print(f" → No changes needed")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build ffmpeg command
|
||||||
|
cmd_str = apply_metadata(args.file, idx, lang_3, new_title, dry_run=True)
|
||||||
|
print(f"\n Command: {cmd_str}")
|
||||||
|
|
||||||
|
changes.append({
|
||||||
|
"index": idx,
|
||||||
|
"old_lang": cur_lang,
|
||||||
|
"new_lang": lang_3,
|
||||||
|
"old_title": cur_title,
|
||||||
|
"new_title": new_title,
|
||||||
|
"detected": lang,
|
||||||
|
"confidence": top_prob,
|
||||||
|
"cmd": cmd_str,
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
if not changes:
|
||||||
|
print("No changes to apply.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Changes to apply: {len(changes)}")
|
||||||
|
for c in changes:
|
||||||
|
print(f" Stream [{c['index']}]: lang {c['old_lang'] or '(none)'} → {c['new_lang']}, "
|
||||||
|
f"title → \"{c['new_title']}\" ({c['confidence']:.0%} confidence)")
|
||||||
|
|
||||||
|
if not args.apply:
|
||||||
|
print(f"\nDry run complete. Use --apply to apply changes.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Apply changes
|
||||||
|
print(f"\nApplying changes...")
|
||||||
|
for c in changes:
|
||||||
|
lang_3 = c["new_lang"]
|
||||||
|
title = c["new_title"]
|
||||||
|
idx = c["index"]
|
||||||
|
|
||||||
|
meta_key = f"metadata:s:a:{idx}"
|
||||||
|
base, ext = os.path.splitext(args.file)
|
||||||
|
tmp_out = f"{base}_tmp{ext}"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", args.file,
|
||||||
|
"-map", "0",
|
||||||
|
"-c", "copy",
|
||||||
|
f"{meta_key}:language={lang_3}",
|
||||||
|
f"{meta_key}:title={title}",
|
||||||
|
tmp_out
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f" Stream [{idx}]: {' '.join(cmd[:8])}...")
|
||||||
|
r = subprocess.run(cmd, capture_output=True, timeout=300)
|
||||||
|
if r.returncode != 0:
|
||||||
|
print(f" ERROR: ffmpeg failed: {r.stderr.decode()[-200:]}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
shutil.move(tmp_out, args.file)
|
||||||
|
print(f" ✓ Stream [{idx}] updated")
|
||||||
|
|
||||||
|
print(f"\nDone. File: {args.file}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+118
-191
@@ -1,18 +1,19 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Verify sync: video player + dual waveform display for A/B audio comparison.
|
Verify sync: QMediaPlayer video player + dual waveform display.
|
||||||
|
|
||||||
Modes:
|
Modes:
|
||||||
--mode short : extracted 30s parts (video_a + wav_a + wav_b)
|
--mode short : extracted 30s parts (video_a + wav_a + wav_b)
|
||||||
--mode full : synced output MKV (extracts audio streams from it)
|
--mode full : synced output MKV (extracts audio streams from it)
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Video playback with timeline seek bar
|
- Smooth video playback via QMediaPlayer/QVideoWidget
|
||||||
- Audio playback: A only, B only, or A+B mix
|
- Audio playback: A only, B only, or A+B mix via sounddevice
|
||||||
- Combined waveform overlay (A and B in different colors)
|
- Combined waveform overlay (A and B in different colors)
|
||||||
- Two separate waveform graphs with playhead
|
- Two separate waveform graphs with playhead
|
||||||
- Zoomable waveforms (Ctrl+scroll) with linked x-axes
|
- Zoomable waveforms (scroll) with linked x-axes
|
||||||
- Toggle A/B on/off, volume, play/pause
|
- Timeline seek bar synced to video position
|
||||||
|
- Toggle A/B on/off, volume
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
make verify LAG=0.042
|
make verify LAG=0.042
|
||||||
@@ -31,7 +32,7 @@ except ModuleNotFoundError:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from PyQt6 import QtWidgets, QtCore, QtGui
|
from PyQt6 import QtWidgets, QtCore, QtGui, QtMultimedia, QtMultimediaWidgets
|
||||||
HAS_PG = True
|
HAS_PG = True
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print("ERROR: pyqtgraph/PyQt6 not installed — run make install", file=sys.stderr)
|
print("ERROR: pyqtgraph/PyQt6 not installed — run make install", file=sys.stderr)
|
||||||
@@ -61,37 +62,7 @@ def load_config(path: str) -> dict:
|
|||||||
return tomllib.load(f)
|
return tomllib.load(f)
|
||||||
|
|
||||||
|
|
||||||
def probe_video_info(path: str) -> dict:
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
|
||||||
"-show_entries", "stream=width,height,r_frame_rate,duration",
|
|
||||||
"-show_entries", "format=duration",
|
|
||||||
"-of", "json", path],
|
|
||||||
capture_output=True, text=True, timeout=10
|
|
||||||
)
|
|
||||||
data = json.loads(r.stdout)
|
|
||||||
stream = data.get("streams", [{}])[0]
|
|
||||||
fmt = data.get("format", {})
|
|
||||||
fps_str = stream.get("r_frame_rate", "24000/1001")
|
|
||||||
if "/" in fps_str:
|
|
||||||
num, den = fps_str.split("/")
|
|
||||||
fps = float(num) / float(den)
|
|
||||||
else:
|
|
||||||
fps = float(fps_str)
|
|
||||||
duration = float(fmt.get("duration", stream.get("duration", 0)))
|
|
||||||
return {
|
|
||||||
"width": int(stream.get("width", 1920)),
|
|
||||||
"height": int(stream.get("height", 1080)),
|
|
||||||
"fps": fps,
|
|
||||||
"duration": duration,
|
|
||||||
}
|
|
||||||
except Exception:
|
|
||||||
return {"width": 1920, "height": 1080, "fps": 24000/1001, "duration": 0}
|
|
||||||
|
|
||||||
|
|
||||||
def probe_audio_streams(path: str) -> list[dict]:
|
def probe_audio_streams(path: str) -> list[dict]:
|
||||||
"""Probe audio streams for full mode."""
|
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["ffprobe", "-v", "error", "-select_streams", "a",
|
["ffprobe", "-v", "error", "-select_streams", "a",
|
||||||
@@ -113,7 +84,7 @@ def probe_audio_streams(path: str) -> list[dict]:
|
|||||||
"codec": s.get("codec_name", ""),
|
"codec": s.get("codec_name", ""),
|
||||||
"channels": s.get("channels", 0),
|
"channels": s.get("channels", 0),
|
||||||
"sample_rate": int(sr) if sr else 0,
|
"sample_rate": int(sr) if sr else 0,
|
||||||
"language": tags.get("language", "und"),
|
"language": tags.get("language", ""),
|
||||||
"title": tags.get("title", ""),
|
"title": tags.get("title", ""),
|
||||||
})
|
})
|
||||||
return streams
|
return streams
|
||||||
@@ -122,7 +93,6 @@ def probe_audio_streams(path: str) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def extract_audio_from_mkv(mkv_path: str, stream_index: int, out_wav: str, sr: int = 48000) -> bool:
|
def extract_audio_from_mkv(mkv_path: str, stream_index: int, out_wav: str, sr: int = 48000) -> bool:
|
||||||
"""Extract a single audio stream from MKV to mono WAV."""
|
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["ffmpeg", "-y", "-i", mkv_path,
|
["ffmpeg", "-y", "-i", mkv_path,
|
||||||
@@ -136,32 +106,7 @@ def extract_audio_from_mkv(mkv_path: str, stream_index: int, out_wav: str, sr: i
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def extract_frame(video_path: str, timestamp: float, width: int, height: int) -> bytes | None:
|
|
||||||
"""Extract a single frame at timestamp as raw RGB bytes."""
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
["ffmpeg", "-ss", f"{timestamp:.4f}", "-i", video_path,
|
|
||||||
"-vframes", "1", "-vf", f"scale={width}:{height}",
|
|
||||||
"-f", "rawvideo", "-pix_fmt", "rgb24",
|
|
||||||
"-v", "error", "-"],
|
|
||||||
capture_output=True, timeout=5
|
|
||||||
)
|
|
||||||
if r.returncode != 0 or not r.stdout:
|
|
||||||
return None
|
|
||||||
return r.stdout
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def raw_to_qimage(raw: bytes, width: int, height: int) -> QtGui.QImage | None:
|
|
||||||
if len(raw) < width * height * 3:
|
|
||||||
return None
|
|
||||||
img = QtGui.QImage(raw, width, height, width * 3, QtGui.QImage.Format.Format_RGB888)
|
|
||||||
return img.copy()
|
|
||||||
|
|
||||||
|
|
||||||
def apply_lag(a: np.ndarray, b: np.ndarray, lag_samples: int) -> tuple[np.ndarray, np.ndarray]:
|
def apply_lag(a: np.ndarray, b: np.ndarray, lag_samples: int) -> tuple[np.ndarray, np.ndarray]:
|
||||||
"""Align A and B with lag applied (B delayed by lag_samples)."""
|
|
||||||
if lag_samples >= 0:
|
if lag_samples >= 0:
|
||||||
a_out = a[lag_samples:]
|
a_out = a[lag_samples:]
|
||||||
b_out = np.concatenate([np.zeros(lag_samples, dtype=np.float32), b])
|
b_out = np.concatenate([np.zeros(lag_samples, dtype=np.float32), b])
|
||||||
@@ -221,36 +166,13 @@ class AudioPlayer:
|
|||||||
self.playing = False
|
self.playing = False
|
||||||
|
|
||||||
|
|
||||||
# ─── Zoomable plot widget ───
|
|
||||||
|
|
||||||
class ZoomPlot(pg.PlotWidget):
|
|
||||||
"""PlotWidget with Ctrl+scroll zoom on x-axis, right-drag to pan."""
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.setMouseEnabled(x=True, y=False)
|
|
||||||
# Enable wheel zoom on x-axis only
|
|
||||||
self.getViewBox().setMouseEnabled(x=True, y=False)
|
|
||||||
|
|
||||||
def wheelEvent(self, event):
|
|
||||||
delta = event.angleDelta().y()
|
|
||||||
factor = 1.1 if delta > 0 else 1 / 1.1
|
|
||||||
vb = self.getViewBox()
|
|
||||||
# zoom x-axis around cursor
|
|
||||||
mouse_pos = vb.mapSceneToView(event.position())
|
|
||||||
x_range = vb.viewRange()[0]
|
|
||||||
new_width = (x_range[1] - x_range[0]) / factor
|
|
||||||
center = mouse_pos.x()
|
|
||||||
vb.setXRange(center - new_width / 2, center + new_width / 2, padding=0)
|
|
||||||
event.accept()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Main Window ───
|
# ─── Main Window ───
|
||||||
|
|
||||||
class VerifyWindow(QtWidgets.QMainWindow):
|
class VerifyWindow(QtWidgets.QMainWindow):
|
||||||
def __init__(self, video_path: str, wav_a_path: str, wav_b_path: str,
|
def __init__(self, video_path: str, wav_a_path: str, wav_b_path: str,
|
||||||
sr: int = 48000, lag_sec: float = 0.0,
|
sr: int = 48000, lag_sec: float = 0.0,
|
||||||
mode: str = "short", out_dir: str = "output",
|
mode: str = "short", out_dir: str = "output",
|
||||||
temp_dir: str | None = None):
|
temp_dir: str | None = None, volume: int = 70):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.video_path = video_path
|
self.video_path = video_path
|
||||||
self.wav_a_path = wav_a_path
|
self.wav_a_path = wav_a_path
|
||||||
@@ -261,7 +183,10 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
self.mode = mode
|
self.mode = mode
|
||||||
self.out_dir = out_dir
|
self.out_dir = out_dir
|
||||||
self.temp_dir = temp_dir
|
self.temp_dir = temp_dir
|
||||||
self.audio = AudioPlayer()
|
self.audio_player = AudioPlayer()
|
||||||
|
self.default_volume = volume
|
||||||
|
self.duration = 0.0
|
||||||
|
self.playing_a_b = False # our custom audio mix playing
|
||||||
|
|
||||||
title = "verify_sync"
|
title = "verify_sync"
|
||||||
if mode == "short":
|
if mode == "short":
|
||||||
@@ -282,26 +207,11 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
self.a_aligned, self.b_aligned = apply_lag(self.a_orig, self.b_orig, self.lag_samples)
|
self.a_aligned, self.b_aligned = apply_lag(self.a_orig, self.b_orig, self.lag_samples)
|
||||||
self.duration = len(self.a_aligned) / sr
|
self.duration = len(self.a_aligned) / sr
|
||||||
|
|
||||||
# Video info
|
|
||||||
vinfo = probe_video_info(video_path)
|
|
||||||
self.v_width = vinfo["width"]
|
|
||||||
self.v_height = vinfo["height"]
|
|
||||||
self.v_fps = vinfo["fps"]
|
|
||||||
self.v_duration = vinfo["duration"] if vinfo["duration"] > 0 else self.duration
|
|
||||||
|
|
||||||
# Playback state
|
|
||||||
self.playing = False
|
|
||||||
self.play_start_time = 0.0
|
|
||||||
self.play_offset = 0.0
|
|
||||||
self.timer = QtCore.QTimer()
|
|
||||||
self.timer.setInterval(33)
|
|
||||||
self.timer.timeout.connect(self._on_tick)
|
|
||||||
self._seeking = False
|
|
||||||
self._last_frame_t = -1.0
|
|
||||||
|
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
self._draw_waveforms()
|
self._draw_waveforms()
|
||||||
self._update_frame(0.0)
|
|
||||||
|
# Set up QMediaPlayer for video
|
||||||
|
self._setup_video_player()
|
||||||
|
|
||||||
def _setup_ui(self):
|
def _setup_ui(self):
|
||||||
central = QtWidgets.QWidget()
|
central = QtWidgets.QWidget()
|
||||||
@@ -309,14 +219,13 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
lay = QtWidgets.QVBoxLayout(central)
|
lay = QtWidgets.QVBoxLayout(central)
|
||||||
lay.setSpacing(2)
|
lay.setSpacing(2)
|
||||||
|
|
||||||
# ── Video display ──
|
# ── Video display (QVideoWidget) ──
|
||||||
self.video_label = QtWidgets.QLabel()
|
self.video_widget = QtMultimediaWidgets.QVideoWidget()
|
||||||
self.video_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
self.video_widget.setMinimumHeight(300)
|
||||||
self.video_label.setMinimumHeight(300)
|
self.video_widget.setStyleSheet("background: #000;")
|
||||||
self.video_label.setStyleSheet("background: #000;")
|
lay.addWidget(self.video_widget, stretch=4)
|
||||||
lay.addWidget(self.video_label, stretch=4)
|
|
||||||
|
|
||||||
# ── Timeline seek bar (video editor style) ──
|
# ── Timeline seek bar ──
|
||||||
time_row = QtWidgets.QHBoxLayout()
|
time_row = QtWidgets.QHBoxLayout()
|
||||||
lay.addLayout(time_row)
|
lay.addLayout(time_row)
|
||||||
|
|
||||||
@@ -343,6 +252,7 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
self.seek.sliderPressed.connect(self._on_seek_start)
|
self.seek.sliderPressed.connect(self._on_seek_start)
|
||||||
self.seek.sliderMoved.connect(self._on_seek_move)
|
self.seek.sliderMoved.connect(self._on_seek_move)
|
||||||
self.seek.sliderReleased.connect(self._on_seek_end)
|
self.seek.sliderReleased.connect(self._on_seek_end)
|
||||||
|
self._seeking = False
|
||||||
|
|
||||||
# ── Combined waveform overlay ──
|
# ── Combined waveform overlay ──
|
||||||
self.gw_comb = pg.GraphicsLayoutWidget()
|
self.gw_comb = pg.GraphicsLayoutWidget()
|
||||||
@@ -353,6 +263,7 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
self.curve_a_comb = self.p_comb.plot(pen=pg.mkPen("#4ecdc4", width=1), name="A")
|
self.curve_a_comb = self.p_comb.plot(pen=pg.mkPen("#4ecdc4", width=1), name="A")
|
||||||
self.curve_b_comb = self.p_comb.plot(pen=pg.mkPen("#ff6b6b", width=1, style=QtCore.Qt.PenStyle.DashLine), name="B")
|
self.curve_b_comb = self.p_comb.plot(pen=pg.mkPen("#ff6b6b", width=1, style=QtCore.Qt.PenStyle.DashLine), name="B")
|
||||||
self.playhead_comb = self.p_comb.addLine(x=0, pen=pg.mkPen("#ffff00", width=2, style=QtCore.Qt.PenStyle.DashLine))
|
self.playhead_comb = self.p_comb.addLine(x=0, pen=pg.mkPen("#ffff00", width=2, style=QtCore.Qt.PenStyle.DashLine))
|
||||||
|
self.p_comb.addLegend(offset=(60, 10))
|
||||||
lay.addWidget(self.gw_comb, stretch=1)
|
lay.addWidget(self.gw_comb, stretch=1)
|
||||||
|
|
||||||
# ── Two separate waveforms ──
|
# ── Two separate waveforms ──
|
||||||
@@ -371,7 +282,6 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
self.curve_b_sep = self.p_b.plot(pen=pg.mkPen("#ff6b6b", width=1))
|
self.curve_b_sep = self.p_b.plot(pen=pg.mkPen("#ff6b6b", width=1))
|
||||||
self.playhead_b = self.p_b.addLine(x=0, pen=pg.mkPen("#ffff00", width=2, style=QtCore.Qt.PenStyle.DashLine))
|
self.playhead_b = self.p_b.addLine(x=0, pen=pg.mkPen("#ffff00", width=2, style=QtCore.Qt.PenStyle.DashLine))
|
||||||
|
|
||||||
# Link all x-axes for synchronized zoom/pan
|
|
||||||
self.p_a.setXLink(self.p_comb)
|
self.p_a.setXLink(self.p_comb)
|
||||||
self.p_b.setXLink(self.p_comb)
|
self.p_b.setXLink(self.p_comb)
|
||||||
|
|
||||||
@@ -381,7 +291,6 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
ctrl = QtWidgets.QHBoxLayout()
|
ctrl = QtWidgets.QHBoxLayout()
|
||||||
lay.addLayout(ctrl)
|
lay.addLayout(ctrl)
|
||||||
|
|
||||||
# Play mode
|
|
||||||
ctrl.addWidget(QtWidgets.QLabel("Play:"))
|
ctrl.addWidget(QtWidgets.QLabel("Play:"))
|
||||||
self.play_mode = QtWidgets.QComboBox()
|
self.play_mode = QtWidgets.QComboBox()
|
||||||
self.play_mode.addItems(["A+B mix", "A only", "B only"])
|
self.play_mode.addItems(["A+B mix", "A only", "B only"])
|
||||||
@@ -389,21 +298,19 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
self.play_mode.setFixedWidth(90)
|
self.play_mode.setFixedWidth(90)
|
||||||
ctrl.addWidget(self.play_mode)
|
ctrl.addWidget(self.play_mode)
|
||||||
|
|
||||||
# Volume
|
|
||||||
ctrl.addWidget(QtWidgets.QLabel("Vol:"))
|
ctrl.addWidget(QtWidgets.QLabel("Vol:"))
|
||||||
self.vol_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
|
self.vol_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
|
||||||
self.vol_slider.setRange(0, 100)
|
self.vol_slider.setRange(0, 100)
|
||||||
self.vol_slider.setValue(70)
|
self.vol_slider.setValue(self.default_volume)
|
||||||
self.vol_slider.setMaximumWidth(100)
|
self.vol_slider.setMaximumWidth(100)
|
||||||
ctrl.addWidget(self.vol_slider)
|
ctrl.addWidget(self.vol_slider)
|
||||||
self.vol_label = QtWidgets.QLabel("70%")
|
self.vol_label = QtWidgets.QLabel(f"{self.default_volume}%")
|
||||||
self.vol_label.setMinimumWidth(35)
|
self.vol_label.setMinimumWidth(35)
|
||||||
ctrl.addWidget(self.vol_label)
|
ctrl.addWidget(self.vol_label)
|
||||||
self.vol_slider.valueChanged.connect(lambda v: self.vol_label.setText(f"{v}%"))
|
self.vol_slider.valueChanged.connect(lambda v: self.vol_label.setText(f"{v}%"))
|
||||||
|
|
||||||
ctrl.addSpacing(10)
|
ctrl.addSpacing(10)
|
||||||
|
|
||||||
# Toggle A/B
|
|
||||||
self.chk_a = QtWidgets.QCheckBox("Show A")
|
self.chk_a = QtWidgets.QCheckBox("Show A")
|
||||||
self.chk_a.setChecked(True)
|
self.chk_a.setChecked(True)
|
||||||
self.chk_a.toggled.connect(self._update_visibility)
|
self.chk_a.toggled.connect(self._update_visibility)
|
||||||
@@ -416,7 +323,6 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
|
|
||||||
ctrl.addSpacing(10)
|
ctrl.addSpacing(10)
|
||||||
|
|
||||||
# Reset zoom button
|
|
||||||
btn_reset_zoom = QtWidgets.QPushButton("Reset zoom")
|
btn_reset_zoom = QtWidgets.QPushButton("Reset zoom")
|
||||||
btn_reset_zoom.setFixedWidth(90)
|
btn_reset_zoom.setFixedWidth(90)
|
||||||
btn_reset_zoom.clicked.connect(self._reset_zoom)
|
btn_reset_zoom.clicked.connect(self._reset_zoom)
|
||||||
@@ -424,16 +330,64 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
|
|
||||||
ctrl.addStretch()
|
ctrl.addStretch()
|
||||||
|
|
||||||
# Info
|
|
||||||
mode_str = "30s preview" if self.mode == "short" else "full length"
|
mode_str = "30s preview" if self.mode == "short" else "full length"
|
||||||
self.info_label = QtWidgets.QLabel(
|
self.info_label = QtWidgets.QLabel(
|
||||||
f"[{mode_str}] Lag: {self.lag_sec:+.6f}s ({self.lag_samples} samp) | "
|
f"[{mode_str}] Lag: {self.lag_sec:+.6f}s ({self.lag_samples} samp) | "
|
||||||
f"A: {len(self.a_orig)/self.sr:.1f}s | B: {len(self.b_orig)/self.sr:.1f}s | "
|
f"A: {len(self.a_orig)/self.sr:.1f}s | B: {len(self.b_orig)/self.sr:.1f}s | "
|
||||||
f"Video: {self.v_duration:.1f}s"
|
f"Audio duration: {self.duration:.1f}s"
|
||||||
)
|
)
|
||||||
self.info_label.setStyleSheet("color: #aaa; font-size: 11px;")
|
self.info_label.setStyleSheet("color: #aaa; font-size: 11px;")
|
||||||
ctrl.addWidget(self.info_label)
|
ctrl.addWidget(self.info_label)
|
||||||
|
|
||||||
|
def _setup_video_player(self):
|
||||||
|
"""Set up QMediaPlayer with QVideoWidget for smooth video playback."""
|
||||||
|
self.media_player = QtMultimedia.QMediaPlayer()
|
||||||
|
self.video_output = QtMultimediaWidgets.QVideoWidget()
|
||||||
|
# Reparent video_output into our video_widget container
|
||||||
|
layout = self.video_widget.layout()
|
||||||
|
if layout is None:
|
||||||
|
layout = QtWidgets.QVBoxLayout(self.video_widget)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.addWidget(self.video_output)
|
||||||
|
|
||||||
|
self.media_player.setVideoOutput(self.video_output)
|
||||||
|
# Mute the video player's own audio — we play audio via sounddevice
|
||||||
|
self.audio_output = QtMultimedia.QAudioOutput()
|
||||||
|
self.audio_output.setMuted(True)
|
||||||
|
self.media_player.setAudioOutput(self.audio_output)
|
||||||
|
|
||||||
|
# Load the video file
|
||||||
|
from PyQt6.QtCore import QUrl
|
||||||
|
self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(self.video_path)))
|
||||||
|
|
||||||
|
# Timer to sync waveform playheads with video position
|
||||||
|
self.sync_timer = QtCore.QTimer()
|
||||||
|
self.sync_timer.setInterval(33) # ~30fps
|
||||||
|
self.sync_timer.timeout.connect(self._sync_from_video)
|
||||||
|
|
||||||
|
self.media_player.playbackStateChanged.connect(self._on_playback_state)
|
||||||
|
|
||||||
|
# Wait for media loaded to get actual duration
|
||||||
|
self.media_player.durationChanged.connect(self._on_duration_changed)
|
||||||
|
|
||||||
|
def _on_duration_changed(self, dur_ms):
|
||||||
|
"""QMediaPlayer reports actual video duration in ms."""
|
||||||
|
vid_dur = dur_ms / 1000.0
|
||||||
|
if vid_dur > 0:
|
||||||
|
self.info_label.setText(
|
||||||
|
self.info_label.text().replace(
|
||||||
|
f"Audio duration: {self.duration:.1f}s",
|
||||||
|
f"Video: {vid_dur:.1f}s"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_playback_state(self, state):
|
||||||
|
if state == QtMultimedia.QMediaPlayer.PlaybackState.StoppedState:
|
||||||
|
self.sync_timer.stop()
|
||||||
|
self.playing_a_b = False
|
||||||
|
self.btn_play.setText("Play")
|
||||||
|
self.audio_player.stop()
|
||||||
|
|
||||||
def _draw_waveforms(self):
|
def _draw_waveforms(self):
|
||||||
max_pts = 200_000 if self.mode == "full" else 80_000
|
max_pts = 200_000 if self.mode == "full" else 80_000
|
||||||
|
|
||||||
@@ -459,40 +413,12 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
self.p_a.setYRange(-y_max, y_max)
|
self.p_a.setYRange(-y_max, y_max)
|
||||||
self.p_b.setYRange(-y_max, y_max)
|
self.p_b.setYRange(-y_max, y_max)
|
||||||
|
|
||||||
# Set initial view to full duration
|
for p in [self.p_comb, self.p_a, self.p_b]:
|
||||||
self.p_comb.setXRange(0, self.duration, padding=0)
|
p.setXRange(0, self.duration, padding=0)
|
||||||
|
p.getViewBox().setLimits(xMin=0, xMax=self.duration)
|
||||||
def _update_frame(self, t: float):
|
|
||||||
t = max(0, min(t, self.v_duration))
|
|
||||||
# Avoid re-extracting same frame
|
|
||||||
if abs(t - self._last_frame_t) < 0.04:
|
|
||||||
return
|
|
||||||
self._last_frame_t = t
|
|
||||||
raw = extract_frame(self.video_path, t, self.v_width, self.v_height)
|
|
||||||
if raw:
|
|
||||||
img = raw_to_qimage(raw, self.v_width, self.v_height)
|
|
||||||
if img:
|
|
||||||
pixmap = QtGui.QPixmap.fromImage(img)
|
|
||||||
scaled = pixmap.scaled(self.video_label.size(),
|
|
||||||
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
|
|
||||||
QtCore.Qt.TransformationMode.SmoothTransformation)
|
|
||||||
self.video_label.setPixmap(scaled)
|
|
||||||
|
|
||||||
def _fmt_time(self, t: float) -> str:
|
|
||||||
h = int(t // 3600)
|
|
||||||
m = int((t % 3600) // 60)
|
|
||||||
s = t % 60
|
|
||||||
return f"{h:02d}:{m:02d}:{s:06.3f}"
|
|
||||||
|
|
||||||
def _current_pos(self) -> float:
|
|
||||||
if self.playing:
|
|
||||||
elapsed = time.time() - self.play_start_time
|
|
||||||
return min(self.play_offset + elapsed, self.duration)
|
|
||||||
return self.play_offset
|
|
||||||
|
|
||||||
def _set_pos(self, t: float):
|
def _set_pos(self, t: float):
|
||||||
t = max(0, min(t, self.duration))
|
t = max(0, min(t, self.duration))
|
||||||
self.play_offset = t
|
|
||||||
self.playhead_comb.setPos(t)
|
self.playhead_comb.setPos(t)
|
||||||
self.playhead_a.setPos(t)
|
self.playhead_a.setPos(t)
|
||||||
self.playhead_b.setPos(t)
|
self.playhead_b.setPos(t)
|
||||||
@@ -503,22 +429,23 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
self.seek.blockSignals(False)
|
self.seek.blockSignals(False)
|
||||||
self.time_label.setText(f"{self._fmt_time(t)} / {self._fmt_time(self.duration)}")
|
self.time_label.setText(f"{self._fmt_time(t)} / {self._fmt_time(self.duration)}")
|
||||||
|
|
||||||
def _on_tick(self):
|
def _fmt_time(self, t: float) -> str:
|
||||||
if not self.playing:
|
h = int(t // 3600)
|
||||||
return
|
m = int((t % 3600) // 60)
|
||||||
t = self._current_pos()
|
s = t % 60
|
||||||
if t >= self.duration:
|
return f"{h:02d}:{m:02d}:{s:06.3f}"
|
||||||
self.stop_play()
|
|
||||||
return
|
def _sync_from_video(self):
|
||||||
|
"""Timer callback: sync waveform playheads and seek bar to video position."""
|
||||||
|
pos_ms = self.media_player.position()
|
||||||
|
t = pos_ms / 1000.0
|
||||||
|
if t > self.duration:
|
||||||
|
t = self.duration
|
||||||
self._set_pos(t)
|
self._set_pos(t)
|
||||||
# Update video frame (limit to ~8fps)
|
|
||||||
frame_interval = 1.0 / 8.0
|
|
||||||
now = time.time()
|
|
||||||
if int(t / frame_interval) != int((t - 0.033) / frame_interval):
|
|
||||||
self._update_frame(t)
|
|
||||||
|
|
||||||
def toggle_play(self):
|
def toggle_play(self):
|
||||||
if self.playing:
|
state = self.media_player.playbackState()
|
||||||
|
if state == QtMultimedia.QMediaPlayer.PlaybackState.PlayingState:
|
||||||
self.stop_play()
|
self.stop_play()
|
||||||
else:
|
else:
|
||||||
self.start_play()
|
self.start_play()
|
||||||
@@ -526,11 +453,16 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
def start_play(self):
|
def start_play(self):
|
||||||
if self.duration <= 0:
|
if self.duration <= 0:
|
||||||
return
|
return
|
||||||
pos = self._current_pos()
|
|
||||||
if pos >= self.duration:
|
|
||||||
pos = 0.0
|
|
||||||
|
|
||||||
start_samp = int(pos * self.sr)
|
# Start video playback
|
||||||
|
self.media_player.play()
|
||||||
|
|
||||||
|
# Start audio mix via sounddevice
|
||||||
|
t = self.media_player.position() / 1000.0
|
||||||
|
start_samp = int(t * self.sr)
|
||||||
|
if start_samp >= len(self.a_aligned):
|
||||||
|
start_samp = 0
|
||||||
|
|
||||||
a_seg = self.a_aligned[start_samp:]
|
a_seg = self.a_aligned[start_samp:]
|
||||||
b_seg = self.b_aligned[start_samp:]
|
b_seg = self.b_aligned[start_samp:]
|
||||||
vol = self.vol_slider.value() / 100.0
|
vol = self.vol_slider.value() / 100.0
|
||||||
@@ -544,23 +476,21 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
else: # B only
|
else: # B only
|
||||||
mix = b_seg * vol
|
mix = b_seg * vol
|
||||||
|
|
||||||
self.audio.play(mix, self.sr)
|
self.audio_player.play(mix, self.sr)
|
||||||
self.play_offset = pos
|
self.playing_a_b = True
|
||||||
self.play_start_time = time.time()
|
|
||||||
self.playing = True
|
|
||||||
self.btn_play.setText("Pause")
|
self.btn_play.setText("Pause")
|
||||||
self.timer.start()
|
self.sync_timer.start()
|
||||||
|
|
||||||
def stop_play(self):
|
def stop_play(self):
|
||||||
self.audio.stop()
|
self.media_player.pause()
|
||||||
self.play_offset = self._current_pos()
|
self.audio_player.stop()
|
||||||
self.playing = False
|
self.playing_a_b = False
|
||||||
self.btn_play.setText("Play")
|
self.btn_play.setText("Play")
|
||||||
self.timer.stop()
|
self.sync_timer.stop()
|
||||||
|
|
||||||
def _on_seek_start(self):
|
def _on_seek_start(self):
|
||||||
self._seeking = True
|
self._seeking = True
|
||||||
was_playing = self.playing
|
was_playing = self.media_player.playbackState() == QtMultimedia.QMediaPlayer.PlaybackState.PlayingState
|
||||||
if was_playing:
|
if was_playing:
|
||||||
self.stop_play()
|
self.stop_play()
|
||||||
self._was_playing = was_playing
|
self._was_playing = was_playing
|
||||||
@@ -569,14 +499,13 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
frac = value / 1_000_000.0
|
frac = value / 1_000_000.0
|
||||||
t = frac * self.duration
|
t = frac * self.duration
|
||||||
self._set_pos(t)
|
self._set_pos(t)
|
||||||
self._update_frame(t)
|
self.media_player.setPosition(int(t * 1000))
|
||||||
|
|
||||||
def _on_seek_end(self):
|
def _on_seek_end(self):
|
||||||
frac = self.seek.value() / 1_000_000.0
|
frac = self.seek.value() / 1_000_000.0
|
||||||
t = frac * self.duration
|
t = frac * self.duration
|
||||||
self.play_offset = t
|
self.media_player.setPosition(int(t * 1000))
|
||||||
self._set_pos(t)
|
self._set_pos(t)
|
||||||
self._update_frame(t)
|
|
||||||
self._seeking = False
|
self._seeking = False
|
||||||
if getattr(self, '_was_playing', False):
|
if getattr(self, '_was_playing', False):
|
||||||
self.start_play()
|
self.start_play()
|
||||||
@@ -591,9 +520,9 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
self.p_comb.setXRange(0, self.duration, padding=0)
|
self.p_comb.setXRange(0, self.duration, padding=0)
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
self.audio.stop()
|
self.media_player.stop()
|
||||||
self.timer.stop()
|
self.audio_player.stop()
|
||||||
# Clean up temp files if any
|
self.sync_timer.stop()
|
||||||
if self.temp_dir and os.path.isdir(self.temp_dir):
|
if self.temp_dir and os.path.isdir(self.temp_dir):
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(self.temp_dir)
|
shutil.rmtree(self.temp_dir)
|
||||||
@@ -620,14 +549,13 @@ def main():
|
|||||||
audio_cfg = cfg.get("audio", {})
|
audio_cfg = cfg.get("audio", {})
|
||||||
|
|
||||||
sr = int(audio_cfg.get("sample_rate", 48000))
|
sr = int(audio_cfg.get("sample_rate", 48000))
|
||||||
|
volume = int(audio_cfg.get("volume", 70))
|
||||||
out_dir = args.out or sync_cfg.get("out_dir", "output")
|
out_dir = args.out or sync_cfg.get("out_dir", "output")
|
||||||
lag_sec = args.lag
|
lag_sec = args.lag
|
||||||
|
|
||||||
if args.mode == "full":
|
if args.mode == "full":
|
||||||
# Full mode: use synced MKV, extract audio streams from it
|
|
||||||
synced = args.synced
|
synced = args.synced
|
||||||
if not synced:
|
if not synced:
|
||||||
# Auto-derive from config
|
|
||||||
video_a = ext.get("file_a", "")
|
video_a = ext.get("file_a", "")
|
||||||
stem = os.path.splitext(os.path.basename(video_a))[0]
|
stem = os.path.splitext(os.path.basename(video_a))[0]
|
||||||
synced = os.path.join(out_dir, f"{stem}_synced.mkv")
|
synced = os.path.join(out_dir, f"{stem}_synced.mkv")
|
||||||
@@ -645,7 +573,6 @@ def main():
|
|||||||
print("ERROR: synced MKV needs at least 2 audio streams (A + B)", file=sys.stderr)
|
print("ERROR: synced MKV needs at least 2 audio streams (A + B)", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Extract both audio streams to temp WAVs
|
|
||||||
temp_dir = tempfile.mkdtemp(prefix="verify_sync_")
|
temp_dir = tempfile.mkdtemp(prefix="verify_sync_")
|
||||||
wav_a = os.path.join(temp_dir, "audio_a.wav")
|
wav_a = os.path.join(temp_dir, "audio_a.wav")
|
||||||
wav_b = os.path.join(temp_dir, "audio_b.wav")
|
wav_b = os.path.join(temp_dir, "audio_b.wav")
|
||||||
@@ -663,7 +590,6 @@ def main():
|
|||||||
video_path = synced
|
video_path = synced
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Short mode: use original video + extracted WAVs
|
|
||||||
video_a = args.a or ext.get("file_a", "")
|
video_a = args.a or ext.get("file_a", "")
|
||||||
video_b = args.b or ext.get("file_b", "")
|
video_b = args.b or ext.get("file_b", "")
|
||||||
|
|
||||||
@@ -699,7 +625,8 @@ def main():
|
|||||||
apply_dark_theme(app)
|
apply_dark_theme(app)
|
||||||
|
|
||||||
w = VerifyWindow(video_path, wav_a, wav_b, sr=sr, lag_sec=lag_sec,
|
w = VerifyWindow(video_path, wav_a, wav_b, sr=sr, lag_sec=lag_sec,
|
||||||
mode=args.mode, out_dir=out_dir, temp_dir=temp_dir)
|
mode=args.mode, out_dir=out_dir, temp_dir=temp_dir,
|
||||||
|
volume=volume)
|
||||||
w.show()
|
w.show()
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user