diff --git a/audio/audio_utils.py b/audio/audio_utils.py index 7e90ad8..d3ff93f 100644 --- a/audio/audio_utils.py +++ b/audio/audio_utils.py @@ -20,7 +20,7 @@ def extract_audio_segment_file(video: Path, start_time: float, end_time: float, which makes the operation fast and preserves the original audio quality. Args: - German_Videout_mkv: Path to the source video file. + video: Path to the source video file. start_time: Start time of the audio segment (seconds or FFmpeg time format). end_time: End time of the audio segment. out_audio: Path to the output audio file. diff --git a/main.py b/main.py index 539b3f3..26a86e7 100644 --- a/main.py +++ b/main.py @@ -223,7 +223,12 @@ def process_one(German_Video: Path, Quality_Video: Path, output_dir: Path, offse def main(): + # Configure these paths for your own legally owned media. + # Placeholders below: first dir holds the dubbed (e.g. German) source, + # second dir holds the high-quality video source. output_dir = "./output_dir" + dubbed_dir = "./dubbed_source/Season 01" + quality_dir = "./high_quality/S01" if not os.path.isdir(output_dir): raise NotADirectoryError(f"Output directory is not a valid Directory: {output_dir}") start_num = 2 @@ -236,12 +241,12 @@ def main(): for i in tqdm(episodes, desc="Episodes"): # i =1 # if i: - german_pattern = f"./dubbed_source/Season 01/Example Series S01E{i:02}.mkv" + german_pattern = f"{dubbed_dir}/Example Series S01E{i:02}.mkv" if os.path.isfile(german_pattern): German_Video = [german_pattern] else: German_Video = glob.glob(german_pattern) - quality_pattern = f"./high_quality/S01/E{i:02}*" + quality_pattern = f"{quality_dir}/E{i:02}*" if os.path.isfile(quality_pattern): Quality_Video = [quality_pattern] else: diff --git a/mkv/chapter.py b/mkv/chapter.py index 9860719..9caa2ff 100644 --- a/mkv/chapter.py +++ b/mkv/chapter.py @@ -10,8 +10,7 @@ from typing import Tuple, List import json def get_chapters(file: Path) -> List[Tuple[float, float]]: - """Return list of (start_ti -from .intro_outro importme, duration) for each chapter.""" + """Return list of (start_time, duration) for each chapter.""" cmd = [ "ffprobe", "-i", str(file), diff --git a/readme.md b/readme.md index 9b9ee2e..3497c26 100644 --- a/readme.md +++ b/readme.md @@ -1 +1,176 @@ -This script needs ffmpeg and mkvtoolkit/mkvtoolnix installed to work \ No newline at end of file +# anime_dub_sync + +Transfer a dubbed audio track (e.g. German) from a low-quality source file onto a +high-quality video file, with automatic A/V offset detection and verification. + +Only use this with sources you legally own. All paths and titles in this repo +are generic placeholders — do not commit real media filenames, absolute local +paths, or release-group names. + +## Requirements + +System tools (must be on `PATH`): + +- `ffmpeg` + `ffprobe` +- `mkvmerge` (from MKVToolNix) + +Python (>= 3.10): + +- `opencv-python`, `scipy`, `numpy`, `soundfile`, `tqdn` +- see `requirements.txt` + +Install: + +```bash +pip install -r requirements.txt +``` + +## Repository layout + +```text +main.py # batch + single-episode orchestration +utils.py # subprocess helper +audio/ + __init__.py + audio_utils.py # ffmpeg extraction, concat, shifting, mux helpers + audio_cross_correlation.py # intro/outro audio offset measurement +video/ + __init__.py + video_utils.py # ffprobe wrapper + scene_change_cross_correlation.py # primary video offset detection + temporal_cross_correlation.py # alternative/legacy motion-signal method +mkv/ + __init__.py + chapter.py # chapter parsing, intro/outro candidate search + intro_outro.py # final sync verification + check_intro_outro.py # CLI wrapper for verification +``` + +## How it works + +### 1. Video offset detection (`video/`) + +`video/scene_change_cross_correlation.py:70` — `corelate_videos(vid1, vid2, fps)`: + +1. `get_scene_change_signal` decodes each video with OpenCV at 160x90 grayscale, + computes `mean(diff^2)` per frame. Cuts produce large spikes. +2. `resample_signal` resamples both signals to a common FPS with + `scipy.signal.resample`. +3. Both signals are z-normalized, then `scipy.signal.correlate(mode='full')` + finds the lag with peak correlation. +4. Lag in samples / FPS = offset in seconds. Positive means `vid1` starts + after `vid2`. + +`video/video_utils.py:14` — `probe_video(path)` returns +`(fps, duration, codec, start_time)` via `ffprobe -show_streams -show_format`. + +`video/temporal_cross_correlation.py:52` — `find_temporal_offset` is a similar +motion-signal correlator. It is exported but not used by `main.py`; the active +path is `corelate_videos`. + +### 2. Audio splicing (`audio/`) + +`audio/audio_utils.py`: + +- `extract_audio_segment_file(video, start, end, out)` — extracts `0:a:0` + with ffmpeg to stereo AAC `.mka`. +- `extract_audio_segment_tmp(file, (start, duration) | start, end, stream)` — + extracts a stream to a temp 44.1 kHz PCM WAV for analysis. +- `get_audio_duration(file)` — parses `DURATION` tag via ffprobe. +- `stick_together_audio(left, right, out)` — concatenates with the ffmpeg + concat demuxer (`-c copy`, no re-encode). +- `add_silence(file, offset)` — `adelay` (offset > 0, silence at start) or + `apad` (offset < 0, silence at end), re-encoded to Opus. +- `replace_with_shifted_mka(mkv, mka, audioline, offset)` — physically shifts + with `adelay` / `atrim+asetpts`, re-encodes to Opus 160k, replaces the track + with `mkvmerge`, tags it `ger` / `German (Opus Stereo)`. +- `get_audio_streams`, `get_track_info`, `shift_audio` — stream enumeration + and in-place shift helpers. + +`audio/audio_cross_correlation.py:39` — `get_audio_offset(file1, file2, time)`: +decodes to mono 44.1 kHz WAV, mean-normalizes, `scipy.signal.correlate`, returns +lag / sample-rate in seconds. + +### 3. Intro/outro verification (`mkv/`) + +`mkv/chapter.py:12` — `get_chapters(file)` lists `(start, duration)` via +`ffprobe -show_chapters`. + +`mkv/chapter.py:35` — `get_intro_outro(file, length=90.0)` keeps chapters with +duration within `[length/2, length*2]`. Expects exactly 2 (intro + outro). + +`mkv/intro_outro.py:8` — `check_intro_outro_sync(file, length, uncertainty, +max_offset)`: + +1. Trims `uncertainty` (default 10%) off each intro/outro edge. +2. Extracts first and last audio streams for intro and outro. +3. Audio-correlates intro pair and outro pair. +4. Returns `(True, mean_offset, last_stream)` if + `|mean(outro_offsets) - mean(intro_offsets)| < max_offset` (default 0.1 s), + else `(False, None, None)`. + +### 4. Orchestration (`main.py`) + +`main.py:18` — `mux_audio_into_output()` runs `mkvmerge` to add the synced +`.mka` as a German track. + +`main.py:37` — `process_one(German_Video, Quality_Video, output_dir, +offset=None)` runs 9 `tqdm` steps: + +1. `Probe video` — probe both inputs. +2. `Compute offset` — `corelate_videos`, or use supplied `offset`. +3. `Extract pre beginning` — head of quality audio before dub starts + (skipped if < 0.05 s). +4. `Extract german audio` — main dubbed segment. +5. `Extract post end` — tail of quality audio (skipped if < 0.5 s, + appended only if > `MIN_END_DUR = 2.5` s). +6. `Combine audio` — concat head + dub + tail; falls back to `add_silence`. +7. `Mux` — mkvmerge into `output_dir/.mkv`. +8. `Cleanup` — removes temp `_original_audio*.mka`. +9. `Final check` — `check_intro_outro_sync`; sub-frame residual is fixed with + `replace_with_shifted_mka` if `|offset| > 1/fps`. Failures are renamed to + `[FAILED]_*.mkv`, chapterless files to `[NO CHAPTER]_*.mkv`. + +## Usage + +Single episode (output dir as 3rd arg, else `../output_dir`): + +```bash +python main.py [output_dir] +python main.py dubbed_ep01.mkv quality_ep01.mkv ./output_dir +``` + +With explicit offset in seconds (bypass video correlation), pass it as 4th +argument handling inside `process_one` — currently CLI uses auto-detect; edit +or call the function directly for manual offsets. + +Batch: edit `main()` in `main.py:225` (`output_dir`, `dubbed_dir`, +`quality_dir`, episode range), then: + +```bash +python main.py +``` + +Verify an existing file without re-muxing: + +```bash +python mkv/check_intro_outro.py ./output_dir/episode.mkv +``` + +## Configuration + +- `main.py:16` `MIN_END_DUR = 2.5` — minimum tail length to append. +- `main.py:14` `DEBUG = False` — enable ffmpeg segment logging. +- `mkv/intro_outro.py:8` `length=90.0, uncertainty=0.1, max_offset=0.1` — + expected OP/ED length and tolerances. + +## Notes and limitations + +- Needs chapter marks to run the final check; files without them are kept + with a `[NO CHAPTER]_` prefix and return `False`. +- Scene-change correlation assumes both files are the same episode cut. + Different cuts, missing scenes, or very dark/flat video degrade the peak. +- `utils.py:4` `run(cmd)` prints combined stdout on failure, then re-raises + `CalledProcessError`. +- Do not commit `.mkv` / `.mka` media or absolute filesystem paths. Keep + `output_dir/` and temp audio out of git.