# for import needed import sys from pathlib import Path sys.path.append(str(Path(__file__).resolve().parent.parent)) from utils import run ### Normal Imports: import subprocess import shutil import tempfile from typing import Tuple, Optional, overload, Union, List import json def extract_audio_segment_file(video: 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: 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. 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", video, "-ss", str(start_time), "-to", str(end_time), "-map", "0:a:0", "-ac", "2", # force stereo "-c:a", "aac", # re-encode out_audio ]) @overload def extract_audio_segment_tmp( file: Path, start_or_section: float, end: float, audio_stream: Optional[int] = None ) -> Path: ... # Overload 2: Using a tuple for the section @overload def extract_audio_segment_tmp( file: Path, start_or_section: tuple[float, float], end: None = None, audio_stream: Optional[int] = None ) -> Path: ... # Implementation def extract_audio_segment_tmp( file: Path, start_or_section: float | tuple[float, float], end: Optional[float] = None, audio_stream: Optional[int] = None ) -> Path: """ Extract audio using (start, duration) from get_intro. Parameters: file: input video file intro: (start, duration) audio_stream: index of audio stream (0 = first audio track, etc.) Returns: Path to temporary audio file """ # 1. Handle Tuple Input: (start, end) 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]) # 2. Handle Float Input: start, end 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), ] # Select specific audio stream if provided if audio_stream is not None: cmd += ["-map", f"0:a:{audio_stream}"] else: cmd += ["-map", "0:a:0"] # default: first audio track cmd += [ "-vn", "-acodec", "pcm_s16le", "-ar", "44100", "-ac", "2", str(tmp_path) ] run(cmd) return tmp_path def get_audio_duration(file_path: Path): """ Get the actual duration of the first audio stream by asking ffprobe to count packets/frames if the stream metadata is missing. Returns duration in seconds. """ # As a last resort, use the format duration result = subprocess.run([ "ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream_tags=DURATION", "-of", "default=noprint_wrappers=1:nokey=1", file_path ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) result = result.stdout.strip().split(":")[::-1] # if DEBUG: print(f"audio duration: {result}") res = 0 for i in range(len(result)): res += float(result[i])*(60**i) return res ### V1 # 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 stick_together_audio(left_audio: Path, right_audio: Path, output_audio: Path) -> None: left_audio = Path(left_audio) right_audio = Path(right_audio) output_audio = Path(output_audio) with tempfile.NamedTemporaryFile("w", delete=False, suffix=".txt") as f: f.write(f"file '{left_audio.resolve()}'\n") f.write(f"file '{right_audio.resolve()}'\n") list_file = f.name try: run([ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_file, "-c", "copy", # no re-encode str(output_audio) ]) except Exception as e: raise BrokenPipeError(f"{e}\n{f.readlines(-1)}") def get_audio_streams(file: Path) -> List[int]: """ Return a list of available audio stream indices (0-based for ffmpeg mapping). Example return: [0, 1] -> corresponds to 0:a:0 and 0:a:1 """ cmd = [ "ffprobe", "-i", str(file), "-select_streams", "a", "-show_entries", "stream=index", "-of", "json", "-loglevel", "error" ] result = run(cmd) data = json.loads(result.stdout) # ffprobe gives absolute stream indices, but we want relative audio indices streams = data.get("streams", []) # Convert to 0-based audio stream numbering return list(range(len(streams))) def get_track_info(file: Path, audioline: int): """Gets codec, bitrate, language, and title.""" cmd = [ "ffprobe", "-v", "error", "-select_streams", f"a:{audioline}", "-show_entries", "stream=codec_name,bit_rate:stream_tags=language,title", "-of", "json", str(file) ] result = subprocess.run(cmd, capture_output=True, text=True) data = json.loads(result.stdout) if "streams" in data and len(data["streams"]) > 0: s = data["streams"][0] return { "codec": s.get("codec_name", "aac"), "bitrate": s.get("bit_rate", "192k"), "lang": s.get("tags", {}).get("language", "eng"), "title": s.get("tags", {}).get("title", f"Track {audioline}") } return {"codec": "aac", "bitrate": "192k", "lang": "eng", "title": "Audio"} def shift_audio(file: Path, audioline: int, offset: float): """ Shifts audio by offset sekonds to the right. """ if not file.is_file(): return info = get_track_info(file, audioline) # mkvmerge uses 0 for video, usually 1+ for audio. # We need the MKV Toolnix Track ID. mkv_id = audioline + 1 temp_mka = file.parent / "temp_shifted.mka" output_mkv = file.parent / "temp_output.mkv" # --- STEP 1: Physical Shift via Filter --- # We convert offset to milliseconds for the 'adelay' filter ms = int(abs(offset) * 1000) if offset > 0: # RIGHT SHIFT: Delay start, then trim the end to keep original length # 'all' applies delay to all channels filter_str = f"adelay={ms}|{ms}|{ms}|{ms}|{ms}|{ms},atrim=0,atrim=duration=base" # Simplified logic below # For reliability, we use a simpler pad/trim approach: filter_chain = f"adelay={ms}:all=1" else: # LEFT SHIFT: Skip the beginning filter_chain = f"atrim=start={abs(offset)}" # Process to MKA # We use the same codec and bitrate detected earlier process_cmd = [ "ffmpeg", "-y", "-i", str(file), "-map", f"0:a:{audioline}", "-af", filter_chain, "-c:a", info['codec'], "-b:a", str(info['bitrate']), str(temp_mka) ] run(process_cmd) # --- STEP 2: Replace in MKV via mkvmerge --- # --audio-tracks !ID excludes the old track # The new MKA is added at the end merge_cmd = [ "mkvmerge", "-o", str(output_mkv), "--audio-tracks", f"!{mkv_id}", str(file), "--language", f"0:{info['lang']}", "--track-name", f"0:{info['title']}", str(temp_mka) ] run(merge_cmd) # Cleanup and Replace shutil.move(str(output_mkv), str(file)) temp_mka.unlink() print(f"Done: {file.name} updated physically.") def add_silence(file: Path, offset: float) -> None: """ Hard-edits an MKA file by adding silence. Positive offset: Adds silence at the beginning. Negative offset: Adds silence at the end. """ if not file.is_file(): print(f"File not found: {file}") return if offset == 0: print("Offset is 0, no silence to add.") return # Create a normal file path right next to the original file new_file = file.parent / f"silenced_{file.name}" # 1. Determine the filter if offset > 0: # Silence at the beginning: delay all channels ms = int(offset * 1000) audio_filter = f"adelay={ms}:all=1" action_text = "beginning" else: # Silence at the end: pad with specific duration in seconds sec = abs(offset) audio_filter = f"apad=pad_dur={sec}" action_text = "end" # 2. Execute FFmpeg cmd = [ "ffmpeg", "-y", "-i", str(file), "-af", audio_filter, "-c:a", "libopus", str(new_file) ] # print(f"Adding {abs(offset)}s of silence to the {action_text} of {file.name}...") result = subprocess.run(cmd, capture_output=True, text=True) # 3. Verify and replace if result.returncode == 0: # Quick safety check to ensure FFmpeg didn't output an empty file if new_file.exists() and new_file.stat().st_size > 0: shutil.move(str(new_file), str(file)) # print(f"Successfully updated: {file.name}") else: print("Error: FFmpeg succeeded, but the output file is empty.") if new_file.exists(): new_file.unlink() else: print(f"FFmpeg Error:\n{result.stderr}") # Clean up the leftover file if the process failed if new_file.exists(): new_file.unlink() def replace_with_shifted_mka(mkv_file: Path, mka_source: Path, audioline: int, offset: float): """ Physically shifts an existing MKA and replaces a track in the MKV. - Codec: Re-encoded to Opus (to ensure physical shift compatibility) - Metadata: Hardcoded to German / German (Opus Stereo) """ if not mkv_file.is_file() or not mka_source.is_file(): print("Error: Input files not found.") return # mkvmerge IDs: 0 is usually video, so audioline 0 is track 1, etc. mkv_track_id = audioline + 1 shifted_mka = mkv_file.parent / "shifted.mka" output_mkv = mkv_file.parent / "final_output.mkv" # --- STEP 1: Physical Shift --- # Positive offset: adelay (adds real silence packets) # Negative offset: atrim (removes packets) + asetpts (resets timestamps) ms = int(abs(offset) * 1000) if offset > 0: filter_chain = f"adelay={ms}:all=1" else: filter_chain = f"atrim=start={abs(offset)},asetpts=PTS-STARTPTS" print(f"Physically shifting {mka_source.name}...") process_cmd = [ "ffmpeg", "-y", "-i", str(mka_source), "-af", filter_chain, "-c:a", "libopus", # Set to Opus as per your track name "-b:a", "160k", # Standard high-quality bitrate for stereo Opus str(shifted_mka) ] subprocess.run(process_cmd, check=True, capture_output=True) # --- STEP 2: Replace track in MKV --- # --audio-tracks !ID excludes the old line from the original MKV print(f"Replacing track {audioline} in {mkv_file.name}...") merge_cmd = [ "mkvmerge", "-o", str(output_mkv), "--audio-tracks", f"!{mkv_track_id}", str(mkv_file), "--language", "0:ger", "--track-name", "0:German (Opus Stereo)", str(shifted_mka) ] subprocess.run(merge_cmd, check=True, capture_output=True) # --- STEP 3: Cleanup and Overwrite --- shutil.move(str(output_mkv), str(mkv_file)) # print(f"Success: Updated {mkv_file.name}")