From 5e7a73d78faec3578383274940f254a46b3507e2 Mon Sep 17 00:00:00 2001 From: honney Date: Tue, 17 Mar 2026 17:03:41 +0100 Subject: [PATCH] Added a way for aduio correlation --- cc_audio.py | 76 +++++++++++++ cc_audio_transfer | 269 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 cc_audio.py create mode 100644 cc_audio_transfer diff --git a/cc_audio.py b/cc_audio.py new file mode 100644 index 0000000..d6b3b45 --- /dev/null +++ b/cc_audio.py @@ -0,0 +1,76 @@ +from pathlib import Path +import subprocess +import numpy as np +from scipy.signal import correlate +import tempfile +import soundfile as sf +import matplotlib.pyplot as plt + +def extract_audio(file: Path, duration: float): + tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + + subprocess.run([ + "ffmpeg", + "-y", + "-i", str(file), + "-map", "0:a:0", # first audio stream + "-ac", "1", # mono + "-ar", "44100", # fixed sample rate + "-t", str(duration), # limit duration + tmp.name + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + data, sr = sf.read(tmp.name) + return data, sr + + +def get_audio_offset(file1: Path, file2: Path, important_time: float): + sig1, sr1 = extract_audio(file1, important_time) + sig2, sr2 = extract_audio(file2, important_time) + + if sr1 != sr2: + raise ValueError("Sample rates do not match") + + # normalize signals + sig1 = sig1 - np.mean(sig1) + sig2 = sig2 - np.mean(sig2) + + corr = correlate(sig1, sig2, mode="full") + lag = np.argmax(corr) - (len(sig2) - 1) + + offset_seconds = lag / sr1 + return offset_seconds + +def view_audio_correlation(file1: Path, file2: Path, important_time: float): + sig1, sr1 = extract_audio(file1, important_time) + sig2, sr2 = extract_audio(file2, important_time) + + if sr1 != sr2: + raise ValueError("Sample rates differ") + + sig1 = sig1 - np.mean(sig1) + sig2 = sig2 - np.mean(sig2) + + corr = correlate(sig1, sig2, mode="full") + + lags = np.arange(-len(sig2)+1, len(sig1)) + lag_seconds = lags / sr1 + + offset_index = np.argmax(corr) + offset_seconds = lag_seconds[offset_index] + + plt.figure(figsize=(10,5)) + plt.plot(lag_seconds, corr) + plt.axvline(offset_seconds, linestyle="--") + plt.title(f"Audio Cross-Correlation (offset ≈ {offset_seconds:.3f}s)") + plt.xlabel("Lag (seconds)") + plt.ylabel("Correlation") + plt.show() + + return offset_seconds + +ger = "/home/honney/mount/Media/tmp/serien/Maid Sama All/Maid Sama/S01/07.mkv" +jap = "/home/honney/mount/Media/tmp/serien/Maid Sama All/Kaichou wa Maid-sama!/S01/07.mkv" + +offset = view_audio_correlation(Path(ger), Path(jap), 90) +print(offset) diff --git a/cc_audio_transfer b/cc_audio_transfer new file mode 100644 index 0000000..79f0244 --- /dev/null +++ b/cc_audio_transfer @@ -0,0 +1,269 @@ +from pathlib import Path +import subprocess +import numpy as np +from scipy.signal import correlate +import tempfile +import soundfile as sf +import matplotlib.pyplot as plt +import subprocess +import sys +import os +import glob +import shutil + +DEBUG = False + +def run(cmd): + """ + Run a subprocess command. + + Args: + cmd: Command and arguments as a list. + + Raises: + subprocess.CalledProcessError: If the command exits with a non-zero status. + """ + try: + result = subprocess.run( + cmd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True + ) + return result + except subprocess.CalledProcessError as e: + print(e.stdout) + raise + + +def extract_audio(file: Path, duration: float): + tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + + subprocess.run([ + "ffmpeg", + "-y", + "-i", str(file), + "-map", "0:a:0", # first audio stream + "-ac", "1", # mono + "-ar", "44100", # fixed sample rate + "-t", str(duration), # limit duration + tmp.name + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + data, sr = sf.read(tmp.name) + return data, sr + + +def get_audio_offset(file1: Path, file2: Path, important_time: float): + sig1, sr1 = extract_audio(file1, important_time) + sig2, sr2 = extract_audio(file2, important_time) + + if sr1 != sr2: + raise ValueError("Sample rates do not match") + + # normalize signals + sig1 = sig1 - np.mean(sig1) + sig2 = sig2 - np.mean(sig2) + + corr = correlate(sig1, sig2, mode="full") + lag = np.argmax(corr) - (len(sig2) - 1) + + offset_seconds = lag / sr1 + return offset_seconds + +def probe_video(path: Path): + """Get video FPS, duration, codec, and stream start time.""" + import json + cmd = [ + "ffprobe", + "-v", "error", + "-print_format", "json", + "-show_streams", + "-show_format", + path + ] + data = json.loads(subprocess.run(cmd, capture_output=True, text=True, check=True).stdout) + + duration = float(data["format"]["duration"]) + for s in data["streams"]: + if s["codec_type"] == "video": + num, den = s["r_frame_rate"].split("/") + fps = float(num) / float(den) + codec = s["codec_name"] + start_time = float(s.get("start_time", 0)) + return fps, duration, codec, start_time + + raise RuntimeError("No video stream found") + +def trim_audio(German_Videout_mkv: Path, start_time: float, end_time: float, out_audio: Path): + """ + Extract a segment of the first audio track from a video file. + + Uses FFmpeg to copy the selected audio range without re-encoding, + which makes the operation fast and preserves the original audio quality. + + Args: + German_Videout_mkv: 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. + + Notes: + The function selects the first audio stream (`0:a:0`) and copies it + directly (`-c:a copy`), meaning the codec and quality remain unchanged. + """ + run([ + "ffmpeg", + "-y", + "-i", German_Videout_mkv, + "-ss", str(start_time), + "-to", str(end_time), + "-map", "0:a:0", # first audio track + "-c:a", "copy", # no re-encode + out_audio + ]) + +def mux_audio_into_output(Quality_Video, audio_file, output_dir): + """Mux trimmed audio into Quality_Video and place final file in output_dir.""" + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True) + + # final_output = output_dir / f"{Path(Quality_Video).stem}_ger.mkv" + final_output = output_dir / f"{Path(Quality_Video).stem}.mkv" + + run([ + "mkvmerge", + "-o", str(final_output), + str(Quality_Video), + "--language", "0:deu", # assign German language + "--track-name", "0:German (2.0 aac)", + str(audio_file) + ]) + + return final_output + +def stick_together_audio(left_audio: Path, right_audio: Path, output_audio: Path) -> None: + """ + Concatenate two audio files so that left_audio plays directly + before right_audio in the output file. + """ + + left_audio = Path(left_audio) + right_audio = Path(right_audio) + output_audio = Path(output_audio) + + # Use stem instead of prefix to create a temporary file + output_audio_tmp = output_audio.parent / (output_audio.stem + "_temp" + output_audio.suffix) + + run([ + "ffmpeg", + "-y", + "-i", str(left_audio), + "-i", str(right_audio), + "-filter_complex", "[0:a][1:a]concat=n=2:v=0:a=1[a]", + "-map", "[a]", + "-c:a", "opus", # or "aac" if you prefer + "-strict", "-2", + str(output_audio_tmp) + ]) + + shutil.move(output_audio_tmp, output_audio) + +def process_one(German_Video: Path, Quality_Video: Path, output_dir: Path): + # --- Probe video --- + fps, duration, _, vstart = probe_video(German_Video) + fps2, duration2, _, vstart2 = probe_video(Quality_Video) + + if duration <= 0: + raise ValueError("Duration of German_Video must be greater than 0") + if duration2 <= 0: + raise ValueError("Duration of Quality_Video must be greater than 0") + + offset = get_audio_offset(Quality_Video, German_Video, 30) + print(f"Offset: {offset}s") + + if offset > duration or offset > duration2: + raise ValueError("Offset needs to be smaller than the video durations") + + # print(f"fps: {fps}") + # print(f"Cutting audio from frame {n1} (timestamp {start:.6f}s) to timestamp {end:.6f}s") + + # --- Trim only audio --- + german_audio_file = output_dir / f"{Path(Quality_Video).stem}_german_audio.mka" + quality_audio_file1 = output_dir / f"{Path(Quality_Video).stem}_original_audio1.mka" + quality_audio_file2 = output_dir / f"{Path(Quality_Video).stem}_original_audio2.mka" + if os.path.isfile(quality_audio_file1): + os.remove(quality_audio_file1) + if os.path.isfile(quality_audio_file2): + os.remove(quality_audio_file2) + + start2 = vstart2 + end2 = start2 + offset + if end2 > start2: + if DEBUG: print(f"Cutting audio ({quality_audio_file1}) (from timestamp {start2:.6f}s) to timestamp {end2:.6f}s") + trim_audio(Quality_Video, start2, end2, quality_audio_file1) + + start = vstart + end = start + duration + if DEBUG: print(f"Cutting audio ({german_audio_file}) (from timestamp {start:.6f}s) to timestamp {end:.6f}s") + trim_audio(German_Video, start, end, german_audio_file) + # Get the end from the first original audio + # start2 = vstart2 + end + # end2 = start2 + (duration2 - duration - offset) + # if end2 > start2: + # if DEBUG: print(f"Cutting audio ({quality_audio_file2}) (from timestamp {start2:.6f}s) to timestamp {end2:.6f}s") + # trim_audio(Quality_Video, start2, end2, quality_audio_file2) + + if os.path.isfile(quality_audio_file1): + stick_together_audio(quality_audio_file1, german_audio_file, german_audio_file) + if os.path.isfile(quality_audio_file2): + stick_together_audio(german_audio_file, quality_audio_file2, german_audio_file) + + # --- Mux trimmed audio --- + final_file = mux_audio_into_output(Quality_Video, german_audio_file, output_dir) + + print(f"Final file with trimmed audio created at: {final_file}") + + print(f"Removing tmp Audiofiles") + if os.path.isfile(german_audio_file): + os.remove(german_audio_file) + if os.path.isfile(quality_audio_file1): + os.remove(quality_audio_file1) + if os.path.isfile(quality_audio_file2): + os.remove(quality_audio_file2) + +def main(): + output_dir = "/home/honney/mount/Media/tmp/serien/Maid Sama All/test" + # for i in range(1, 14): + for i in [7, 8, 10, 11, 13, 16, 17, 19, 20, 22, 25]: + # --- German_Videouts --- + pattern = f"/home/honney/mount/Media/tmp/serien/Maid Sama All/Maid Sama/S01/{i:02}.mkv" + German_Video = glob.glob(pattern) + pattern = f"/home/honney/mount/Media/tmp/serien/Maid Sama All/Kaichou wa Maid-sama!/S01/{i:02}.mkv" + Quality_Video = glob.glob(pattern) + + if len(German_Video) > 1: + raise ValueError("There are to many videos matching the German wildcard") + if len(Quality_Video) > 1: + raise ValueError("There are to many videos matching the Quality wildcard") + + process_one(Path(German_Video[0]), Path(Quality_Video[0]), Path(output_dir)) + sys.exit() + + +if __name__ == "__main__": + if len(sys.argv) == 3: + print(sys.argv) + print(f"German_Video={sys.argv[1]}") + print(f"Quality_Video={sys.argv[2]}") + process_one(German_Video=Path(sys.argv[1]), Quality_Video=Path(sys.argv[2]), output_dir=Path("./output_dir")) + elif len(sys.argv) == 4: + print(sys.argv) + print(f"German_Video={sys.argv[1]}") + print(f"Quality_Video={sys.argv[2]}") + process_one(German_Video=Path(sys.argv[1]), Quality_Video=Path(sys.argv[2]), output_dir=Path(sys.argv[3])) + else: + # print(len(sys.argv), sys.argv) + # sys.exit() + main()