From 362c06dab4febb415e83814fbefd555bd49a6fd9 Mon Sep 17 00:00:00 2001 From: David Kifer Date: Sat, 10 Jan 2026 17:08:51 -0500 Subject: [PATCH] AI Transcriber tool --- .gitignore | 1 + video_transcription/ai_transcriber/README.md | 51 ++ .../ai_transcriber/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 197 bytes .../__pycache__/diarizer.cpython-313.pyc | Bin 0 -> 3179 bytes .../__pycache__/extractor.cpython-313.pyc | Bin 0 -> 3629 bytes .../__pycache__/transcriber.cpython-313.pyc | Bin 0 -> 8168 bytes .../__pycache__/translator.cpython-313.pyc | Bin 0 -> 4855 bytes .../__pycache__/utils.cpython-313.pyc | Bin 0 -> 1152 bytes .../ai_transcriber/diarizer.py | 100 ++++ .../ai_transcriber/extractor.py | 102 ++++ video_transcription/ai_transcriber/main.py | 468 ++++++++++++++++++ .../ai_transcriber/requirements.txt | 8 + video_transcription/ai_transcriber/tracker.py | 84 ++++ .../ai_transcriber/transcriber.py | 168 +++++++ .../ai_transcriber/translator.py | 127 +++++ video_transcription/ai_transcriber/utils.py | 28 ++ video_transcription/extract_audio.sh | 89 ++++ video_transcription/recover_and_fix.py | 147 ++++++ video_transcription/run_wizard.py | 158 ++++++ 20 files changed, 1531 insertions(+) create mode 100644 .gitignore create mode 100644 video_transcription/ai_transcriber/README.md create mode 100644 video_transcription/ai_transcriber/__init__.py create mode 100644 video_transcription/ai_transcriber/__pycache__/__init__.cpython-313.pyc create mode 100644 video_transcription/ai_transcriber/__pycache__/diarizer.cpython-313.pyc create mode 100644 video_transcription/ai_transcriber/__pycache__/extractor.cpython-313.pyc create mode 100644 video_transcription/ai_transcriber/__pycache__/transcriber.cpython-313.pyc create mode 100644 video_transcription/ai_transcriber/__pycache__/translator.cpython-313.pyc create mode 100644 video_transcription/ai_transcriber/__pycache__/utils.cpython-313.pyc create mode 100644 video_transcription/ai_transcriber/diarizer.py create mode 100644 video_transcription/ai_transcriber/extractor.py create mode 100644 video_transcription/ai_transcriber/main.py create mode 100644 video_transcription/ai_transcriber/requirements.txt create mode 100644 video_transcription/ai_transcriber/tracker.py create mode 100644 video_transcription/ai_transcriber/transcriber.py create mode 100644 video_transcription/ai_transcriber/translator.py create mode 100644 video_transcription/ai_transcriber/utils.py create mode 100755 video_transcription/extract_audio.sh create mode 100755 video_transcription/recover_and_fix.py create mode 100755 video_transcription/run_wizard.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca5f499 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env_files/ diff --git a/video_transcription/ai_transcriber/README.md b/video_transcription/ai_transcriber/README.md new file mode 100644 index 0000000..b9fac30 --- /dev/null +++ b/video_transcription/ai_transcriber/README.md @@ -0,0 +1,51 @@ +# AI Video Transcriber & Translator + +This tool extracts audio from videos, transcribes it using OpenAI's Whisper model, and translates the transcript using Google's Gemini API. + +## Setup + +1. **Install Dependencies:** + ```bash + pip install -r requirements.txt + ``` + *Note: You need `ffmpeg` installed on your system.* + +2. **API Key:** + Set your Gemini API key as an environment variable: + ```bash + export GEMINI_API_KEY="your_api_key_here" + ``` + +## Usage + +### Quick Start (Wizard) +For a user-friendly, interactive experience, run the wizard script in the root directory: + +```bash +./run_wizard.py +``` +This will guide you through selecting files, languages, and enabling features like cleanup and embedding. + +### Advanced (CLI) +Run the `main.py` script directly: + +```bash +python ai_transcriber/main.py [options] +``` + +### Options: +* `--model`: Whisper model size (`tiny`, `base`, `small`, `medium`, `large`). Default: `base`. +* `--lang`: Target language for translation. Default: `English`. +* `--force`: Overwrite existing transcript/translation files. + +### Examples: + +**Single File:** +```bash +python main.py ../my_video.mp4 +``` + +**Entire Directory (Recursive):** +```bash +python main.py ../videos_folder/ --lang "Spanish" --model small +``` diff --git a/video_transcription/ai_transcriber/__init__.py b/video_transcription/ai_transcriber/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/video_transcription/ai_transcriber/__pycache__/__init__.cpython-313.pyc b/video_transcription/ai_transcriber/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5a3b713a103c8d2c32b3ef814bd2450e29a33a8 GIT binary patch literal 197 zcmX|*K?(vf3`JW41<=P9lD0Lb%Fw>N@Q}7@j!Yj?z6Ubb^7Sz8;Uh+RA zbK7oI(fe$v_E#-G=`YO70uOanTYY*eS(It6*5n{tfg2A9&Sr9O<|J< zWN&-pFk{c5noyF+CW-a26AFpggQfrCf&#{nr)117+^6+^U^n)7kX1QapNbXUjn-P4 FLLb27H(LMz literal 0 HcmV?d00001 diff --git a/video_transcription/ai_transcriber/__pycache__/diarizer.cpython-313.pyc b/video_transcription/ai_transcriber/__pycache__/diarizer.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e6bb14df85a549fe718cc157f59c9939d4e5d95c GIT binary patch literal 3179 zcmZ`*&2JmW6`v({NosdVDiS3*lx&VwB@!)(l;YZri8h67DUl;f6}V=jRt<=4A}I(E^hBR*6*a0{8r_=y3q1vJ(6a=oFtc?51IeW~88VEbx%AC)m!vIa0CHyD zypP{|Z|1!{4TnVpBk-xJZ-x>2j%~c*a~G#0uy}+Nq;Ly}HfN4<3a|L$==U7;DgHR3 z{xvQs{D+Aw75uIwn}YCT`_n6+?-9BLS`^;``j9jH80CCpj0c7$BecqRouLTVxaJC@ zHNJx$XwxsDoIl&;3Uo|LFy~W3H+%*UmiPt15OP8mw7)*G(bp*mU2{nuDitG?rxB__ zG%NQFF&E4ZoaM}gvKP*!#?PUON;K7hWpNrI2|-qZBj_?(fH9eL=GNq^J10fZRbSit1X6fInvvG{=3L}FP02UZakv8FGNDD2mMv^m zh`K>2)^wHXJ8D%o4P3KyV+~ifRl_i=B&F6g-Asw%3|+G_B8K5m@)fnZj>oJjO=j>i zTUX8I2`=g-l5#kWS~>5Uu<)2}R41@mVMf#vNG;XM_rSJU^k7(6FX9cu{KROAuNU)G zbAuS3nfcn<8en^0Ef73Y1Cw{zX_HsXl4^|_F2s^IbWfqN>xaT#=z3TIt?%ZS-0})rm6#w60v<%(++54Q!fx()T@MoNMXaW11coN z&?qZ*l+|myYi_GRoa>pCZLxjJDXXA%$h(bARH2G{A!s){l{#FpgOpgcQq{6~b|q~t zPbJ2~5sOt!_Jq7)Yw1murq|6fNo(q+uBB(qLe06X=}oI(Y9w7D)M5`N4?d8RSz(81 z;3Q@qJTa^S)g8=fRc{gCS*4qR2u)SC?XbJaJ5EsyD456AAQXeo(a8l~i2XY>{!&D8 z>}e=`5K)dpcN@{(@A<9+dFhy6KJg6-v1h%5PkP4>d&e7cs?ifa5m0R61o>lPJ#;Fg zuD*uU{UCEc^F$gxl!l*3BZtyRLykRIxWBL`d?|k+@BjMf!o;yW`F$w-P~6MxP3#vA zB3F(>K>>^{`+EvL-N2W@(E3+ZXbs} z{5INqA^_os7qf8K*E;OJ^40WLKRJk`jzj6E(cT{%rvF(<2+|({xBAduZ%*EtLXZ3U zZcX|hPjKviN}TQSKh8vE!~So=0;~&OYX_lrgKog)5z291^V5JUheqHBjpw&e%6-sS zIRUr~KdgMtidX!G0L43yW9{jea2Cu3U9J$Unp|D0kiO9||kCTU4pd ztMDs-pM>4bQim#5JPOYn*3-_qO17%78J*WhKWi3ur$co-+p`v4f$ll=ik0b> zm8sMXSi5D88B&(#W)|kM`KhVV&psBGUV3yd-_LzpQS-pKc_>c5hp0%P~84p1V*x8HXK&iFHD@XEj^CM=T&UQ)Ww`JMX5* z7PO`WOic;RkqEozE>-fjOk20Nfzt1MXISuMjJ!JN%rpTM`*NAxrhF$-*0x)9b?Ljl zWPPsDKe+e7u2KJ>5qop*t=&8IxfcO+5$^|&hNkO_PvaN&$WeT}zVI|Lyg$9aarpMk zQR3!-JoGG@_-AyoaVhmsY9vPXe{+~f|51KEfcmeT#L>`LT|Ww4cs>poPEtrt9PpP; z0U=8ImX?yd?K3UgZ*5yn{dJu98=_a?yyfB*mh literal 0 HcmV?d00001 diff --git a/video_transcription/ai_transcriber/__pycache__/extractor.cpython-313.pyc b/video_transcription/ai_transcriber/__pycache__/extractor.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cdcfd4bf42ccc90936ddfb3fedc59ad53c2468b2 GIT binary patch literal 3629 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 K|1=#Y*#8^Rb(fU@ literal 0 HcmV?d00001 diff --git a/video_transcription/ai_transcriber/__pycache__/translator.cpython-313.pyc b/video_transcription/ai_transcriber/__pycache__/translator.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fbb4dc12c913a59a5ded5a3f8db501baf7bad832 GIT binary patch literal 4855 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 literal 0 HcmV?d00001 diff --git a/video_transcription/ai_transcriber/diarizer.py b/video_transcription/ai_transcriber/diarizer.py new file mode 100644 index 0000000..934324d --- /dev/null +++ b/video_transcription/ai_transcriber/diarizer.py @@ -0,0 +1,100 @@ +# Note: This feature requires pyannote.audio and a HuggingFace token. +# If these are not present, this module will likely fail or raise errors. +# Due to the complexity and weight of pyannote.audio, this is a placeholder +# for where the logic would sit. Implementing full diarization requires +# downloading models and handling complex segment merging. + +import os +import sys + +def diarize_audio(audio_path, num_speakers=None, hf_token=None): + """ + Performs speaker diarization using pyannote.audio. + + Args: + audio_path (str): Path to audio file. + num_speakers (int, optional): Number of speakers if known. + hf_token (str): HuggingFace Auth Token. + + Returns: + list: List of segments with speaker labels [(start, end, speaker), ...]. + """ + try: + from pyannote.audio import Pipeline + except ImportError: + print("Error: pyannote.audio not installed. Diarization skipped.") + return None + + if not hf_token: + print("Error: HuggingFace Token (HF_TOKEN) not found. Diarization skipped.") + return None + + print(f"Loading Diarization Pipeline (pyannote/speaker-diarization-3.1)...") + try: + # Note: 'use_auth_token' was deprecated in favor of 'token' in recent versions + pipeline = Pipeline.from_pretrained( + "pyannote/speaker-diarization-3.1", + token=hf_token + ) + + # Move to GPU if available + import torch + if torch.cuda.is_available(): + pipeline.to(torch.device("cuda")) + + print(f"Diarizing {audio_path}...") + diarization = pipeline(audio_path, num_speakers=num_speakers) + + results = [] + for turn, _, speaker in diarization.itertracks(yield_label=True): + results.append({ + "start": turn.start, + "end": turn.end, + "speaker": speaker + }) + + return results + + except Exception as e: + print(f"Error during diarization: {e}") + return None + +def merge_diarization_with_transcript(transcript_segments, diarization_segments): + """ + Merges Whisper segments with Diarization speaker labels based on time overlap. + + Args: + transcript_segments (list): Whisper segments [{'start': 0.0, 'end': 1.0, 'text': 'Hi'}, ...] + diarization_segments (list): Diarization segments [{'start': 0.1, 'end': 0.9, 'speaker': 'SPEAKER_00'}] + + Returns: + list: Enhanced transcript segments with 'speaker' key. + """ + if not diarization_segments: + return transcript_segments + + # Simple overlap matching logic + for t_seg in transcript_segments: + # Find diarization segment with max overlap + t_start = t_seg['start'] + t_end = t_seg['end'] + + best_speaker = "Unknown" + max_overlap = 0 + + for d_seg in diarization_segments: + d_start = d_seg['start'] + d_end = d_seg['end'] + + # Calculate intersection + overlap_start = max(t_start, d_start) + overlap_end = min(t_end, d_end) + overlap_duration = max(0, overlap_end - overlap_start) + + if overlap_duration > max_overlap: + max_overlap = overlap_duration + best_speaker = d_seg['speaker'] + + t_seg['speaker'] = best_speaker + + return transcript_segments diff --git a/video_transcription/ai_transcriber/extractor.py b/video_transcription/ai_transcriber/extractor.py new file mode 100644 index 0000000..986e38b --- /dev/null +++ b/video_transcription/ai_transcriber/extractor.py @@ -0,0 +1,102 @@ +import os +import subprocess +import sys + +def extract_audio(video_path, output_path=None): + """ + Extracts audio from a video file using ffmpeg. + + Args: + video_path (str): Path to the input video file. + output_path (str, optional): Path for the output audio file. + If None, defaults to same name with .wav extension. + + Returns: + str: Path to the generated audio file. + """ + if not os.path.exists(video_path): + raise FileNotFoundError(f"Video file not found: {video_path}") + + if output_path is None: + base_name = os.path.splitext(video_path)[0] + output_path = f"{base_name}.wav" + + # Check if output file already exists to avoid redundant processing + if os.path.exists(output_path): + print(f"Audio file already exists: {output_path}") + return output_path + + print(f"Extracting audio from {video_path}...") + + # Command matching the user's preferred settings: 16kHz, Mono, PCM s16le + # -y overwrites without asking (though we checked existence above, this is for safety if we force it) + command = [ + "ffmpeg", + "-i", video_path, + "-ar", "16000", + "-ac", "1", + "-c:a", "pcm_s16le", + "-y", + "-v", "error", # Less verbose + output_path + ] + + try: + subprocess.run(command, check=True) + print(f"Audio extracted to: {output_path}") + return output_path + except subprocess.CalledProcessError as e: + print(f"Error extracting audio: {e}") + sys.exit(1) + +def embed_subtitles(video_path, srt_path, output_path=None): + """ + Embeds subtitles into the video file (Soft Subs) and sets them as primary. + + Args: + video_path (str): Path to the input video. + srt_path (str): Path to the SRT file. + output_path (str, optional): Path for the output video. + """ + if not os.path.exists(video_path) or not os.path.exists(srt_path): + print("Error: Video or SRT file not found for embedding.") + return + + if output_path is None: + base, ext = os.path.splitext(video_path) + output_path = f"{base}.subbed{ext}" + + print(f"Embedding subtitles into: {output_path}...") + + # Determine subtitle codec based on container + sub_codec = "mov_text" if video_path.lower().endswith(".mp4") else "srt" + + # Command breakdown: + # -map 0:v -map 0:a -> Keep all video and audio from source + # -map 1:0 -> Add the subtitle from the 2nd input (srt_path) + # -c copy -> Copy video/audio streams (no re-encoding) + # -disposition:s:0 default -> Make the first subtitle track (ours) the default + # -metadata:s:s:0 -> Set metadata for the first subtitle stream + + command = [ + "ffmpeg", + "-i", video_path, + "-i", srt_path, + "-map", "0:v", + "-map", "0:a", + "-map", "1:0", + "-c", "copy", + "-c:s", sub_codec, + "-disposition:s:0", "default", + "-metadata:s:s:0", "language=eng", + "-metadata:s:s:0", "title=English (AI Translated)", + "-y", + "-v", "error", + output_path + ] + + try: + subprocess.run(command, check=True) + print(f"Subtitles embedded successfully: {output_path} (Set as primary)") + except subprocess.CalledProcessError as e: + print(f"Error embedding subtitles: {e}") diff --git a/video_transcription/ai_transcriber/main.py b/video_transcription/ai_transcriber/main.py new file mode 100644 index 0000000..7eb9ff8 --- /dev/null +++ b/video_transcription/ai_transcriber/main.py @@ -0,0 +1,468 @@ +import argparse +import os +import sys +from dotenv import load_dotenv + +# Load environment variables from central .env_files directory +# Path: .../personal_development/video_transcription/ai_transcriber/main.py +# Target: .../personal_development/.env_files/.env.aitranscribe +script_dir = os.path.dirname(os.path.abspath(__file__)) +env_path = os.path.abspath(os.path.join(script_dir, '../../.env_files/.env.aitranscribe')) + +if os.path.exists(env_path): + load_dotenv(env_path) + # print(f"Loaded configuration from: {env_path}") # Optional: Uncomment for debugging +else: + # Fallback: check local .env + local_env = os.path.join(script_dir, '.env') + if os.path.exists(local_env): + load_dotenv(local_env) + else: + # Last resort: just try loading generic (cwd) + load_dotenv() + +from extractor import extract_audio, embed_subtitles +from transcriber import transcribe_audio, save_as_srt +from translator import translate_srt +from utils import validate_and_repair_srt +from diarizer import diarize_audio, merge_diarization_with_transcript + +def save_srt_with_speakers(segments, output_path): + """Helper to save SRT with speaker labels prepended to text.""" + def format_timestamp(seconds: float): + whole_seconds = int(seconds) + milliseconds = int((seconds - whole_seconds) * 1000) + hours = whole_seconds // 3600 + minutes = (whole_seconds % 3600) // 60 + seconds = whole_seconds % 60 + return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}" + + with open(output_path, "w", encoding="utf-8") as f: + for i, segment in enumerate(segments, start=1): + start = format_timestamp(segment["start"]) + end = format_timestamp(segment["end"]) + text = segment["text"].strip() + speaker = segment.get("speaker", "") + + # Prepend speaker if present and not "Unknown" + if speaker and speaker != "Unknown": + text = f"[{speaker}]: {text}" + + f.write(f"{i}\n") + f.write(f"{start} --> {end}\n") + f.write(f"{text}\n\n") + print(f"SRT saved to: {output_path}") + +def process_file(file_path, args, source_lang=None): + print(f"\n=== Processing: {file_path} ===") + + # 1. Extract Audio + audio_path = extract_audio(file_path) + + # 2. Transcribe (Generate SRT) + transcript_file = os.path.splitext(file_path)[0] + ".srt" + transcript_exists = os.path.exists(transcript_file) and not args.force + + # Variable to hold final SRT path for embedding + final_srt_path = transcript_file + + if transcript_exists: + print(f"Transcript exists: {transcript_file}. Skipping transcription.") + with open(transcript_file, "r", encoding="utf-8") as f: + srt_content = f.read() + else: + # Transcribe + result = transcribe_audio(audio_path, model_size=args.model, language=source_lang) + segments = result["segments"] + + # Optional: Diarization + if args.diarize: + hf_token = args.hf_token or os.getenv("HF_TOKEN") + if hf_token: + print("Running Speaker Diarization...") + diar_segments = diarize_audio(audio_path, hf_token=hf_token) + if diar_segments: + segments = merge_diarization_with_transcript(segments, diar_segments) + print("Diarization merged into transcript.") + else: + print("Warning: --diarize requested but HF_TOKEN not provided. Skipping.") + + # Save SRT + # Use simple save if no speakers, or custom if speakers + if args.diarize: + save_srt_with_speakers(segments, transcript_file) + else: + save_as_srt(result, transcript_file) + + # Validation + validate_and_repair_srt(transcript_file) + + with open(transcript_file, "r", encoding="utf-8") as f: + srt_content = f.read() + + # 3. Translate (Generate Translated SRT) + translated_file = os.path.splitext(file_path)[0] + f".{args.lang}.srt" + + translation_success = False + + if os.path.exists(translated_file) and not args.force: + print(f"Translation exists: {translated_file}. Skipping translation.") + final_srt_path = translated_file + translation_success = True + else: + # Only translate if there is content + if srt_content: + translated_srt_content = translate_srt(srt_content, target_language=args.lang) + if translated_srt_content: + with open(translated_file, "w", encoding="utf-8") as f: + f.write(translated_srt_content) + print(f"Translation saved to: {translated_file}") + validate_and_repair_srt(translated_file) + final_srt_path = translated_file + translation_success = True + else: + print("⚠️ TRANSLATION FAILED.") + translation_success = False + + # 4. Embed Subtitles + # SAFETY: If translation was intended but failed, do NOT embed/delete to prevent + # replacing the video with one containing only untranslated subtitles. + should_embed = args.embed + if args.embed and not translation_success: + print("\n❌ SAFETY HALT: Translation failed. Skipping embedding and deletion to preserve original file.") + should_embed = False + + if should_embed: + embed_subtitles(file_path, final_srt_path) + + import argparse + import os + import sys + from dotenv import load_dotenv + + # Load environment variables from central .env_files directory + # Path: .../personal_development/video_transcription/ai_transcriber/main.py + # Target: .../personal_development/.env_files/.env.aitranscribe + script_dir = os.path.dirname(os.path.abspath(__file__)) + env_path = os.path.abspath(os.path.join(script_dir, '../../.env_files/.env.aitranscribe')) + + if os.path.exists(env_path): + load_dotenv(env_path) + print(f"Loaded configuration from: {env_path}") + else: + # Fallback: check local .env + local_env = os.path.join(script_dir, '.env') + if os.path.exists(local_env): + load_dotenv(local_env) + else: + # Last resort: just try loading generic (cwd) + load_dotenv() + + from extractor import extract_audio, embed_subtitles + from transcriber import transcribe_audio, save_as_srt + from translator import translate_srt + from utils import validate_and_repair_srt + from diarizer import diarize_audio, merge_diarization_with_transcript + import tracker + from tracker import JobStatus + + def save_srt_with_speakers(segments, output_path): + """Helper to save SRT with speaker labels prepended to text.""" + def format_timestamp(seconds: float): + whole_seconds = int(seconds) + milliseconds = int((seconds - whole_seconds) * 1000) + hours = whole_seconds // 3600 + minutes = (whole_seconds % 3600) // 60 + seconds = whole_seconds % 60 + return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}" + + with open(output_path, "w", encoding="utf-8") as f: + for i, segment in enumerate(segments, start=1): + start = format_timestamp(segment["start"]) + end = format_timestamp(segment["end"]) + text = segment["text"].strip() + speaker = segment.get("speaker", "") + + # Prepend speaker if present and not "Unknown" + if speaker and speaker != "Unknown": + text = f"[{speaker}]: {text}" + + f.write(f"{i}\n") + f.write(f"{start} --> {end}\n") + f.write(f"{text}\n\n") + print(f"SRT saved to: {output_path}") + + def process_file(file_path, args, source_lang=None): + tracker.logger.info(f"=== Processing: {file_path} ===") + + # Initialize Job + job = tracker.get_job(file_path) + + if job.status == JobStatus.COMPLETED and not args.force: + tracker.logger.info("Job already completed. Skipping.") + return + + tracker.update_job_status(file_path, JobStatus.PROCESSING) + + try: + # 1. Extract Audio + tracker.update_step(file_path, "step_extract", "processing") + audio_path = extract_audio(file_path) + tracker.update_step(file_path, "step_extract", "done") + + # 2. Transcribe (Generate SRT) + tracker.update_step(file_path, "step_transcribe", "processing") + transcript_file = os.path.splitext(file_path)[0] + ".srt" + transcript_exists = os.path.exists(transcript_file) and not args.force + + # Variable to hold final SRT path for embedding + final_srt_path = transcript_file + + if transcript_exists: + tracker.logger.info(f"Transcript exists: {transcript_file}. Skipping transcription.") + with open(transcript_file, "r", encoding="utf-8") as f: + srt_content = f.read() + else: + # Transcribe + result = transcribe_audio(audio_path, model_size=args.model, language=source_lang) + segments = result["segments"] + + # Optional: Diarization + if args.diarize: + hf_token = args.hf_token or os.getenv("HF_TOKEN") + if hf_token: + tracker.logger.info("Running Speaker Diarization...") + diar_segments = diarize_audio(audio_path, hf_token=hf_token) + if diar_segments: + segments = merge_diarization_with_transcript(segments, diar_segments) + tracker.logger.info("Diarization merged into transcript.") + else: + tracker.logger.warning("Warning: --diarize requested but HF_TOKEN not provided. Skipping.") + + # Save SRT + if args.diarize: + save_srt_with_speakers(segments, transcript_file) + else: + save_as_srt(result, transcript_file) + + # Validation + validate_and_repair_srt(transcript_file) + + with open(transcript_file, "r", encoding="utf-8") as f: + srt_content = f.read() + tracker.update_step(file_path, "step_transcribe", "done") + + # 3. Translate (Generate Translated SRT) + tracker.update_step(file_path, "step_translate", "processing") + translated_file = os.path.splitext(file_path)[0] + f".{args.lang}.srt" + + translation_success = False + + if os.path.exists(translated_file) and not args.force: + tracker.logger.info(f"Translation exists: {translated_file}. Skipping translation.") + final_srt_path = translated_file + translation_success = True + else: + # Only translate if there is content + if srt_content: + translated_srt_content = translate_srt(srt_content, target_language=args.lang) + if translated_srt_content: + with open(translated_file, "w", encoding="utf-8") as f: + f.write(translated_srt_content) + tracker.logger.info(f"Translation saved to: {translated_file}") + validate_and_repair_srt(translated_file) + final_srt_path = translated_file + translation_success = True + else: + tracker.logger.error("TRANSLATION FAILED.") + tracker.update_step(file_path, "step_translate", "failed") + translation_success = False + + if translation_success: + tracker.update_step(file_path, "step_translate", "done") + + # 4. Embed Subtitles + tracker.update_step(file_path, "step_embed", "processing") + should_embed = args.embed + if args.embed and not translation_success: + tracker.logger.warning("SAFETY HALT: Translation failed. Skipping embedding and deletion to preserve original file.") + should_embed = False + + if should_embed: + embed_subtitles(file_path, final_srt_path) + + # 5. Delete Source File (Optional & Risky) + if args.delete_source: + if args.embed: + # Safety: Ensure the new subbed video exists before deleting the old one + base, ext = os.path.splitext(file_path) + expected_output = f"{base}.subbed{ext}" + + if os.path.exists(expected_output): + try: + os.remove(file_path) + tracker.logger.info(f"SOURCE DELETED: Original file '{file_path}' has been removed.") + except OSError as e: + tracker.logger.error(f"Error: Could not delete source file: {e}") + else: + tracker.logger.error(f"SAFETY ABORT: Source file NOT deleted. Could not find expected output '{expected_output}'.") + else: + tracker.logger.warning("SAFETY ABORT: Source file NOT deleted. You must enable --embed to safely replace the video.") + tracker.update_step(file_path, "step_embed", "done") + + # 5. Cleanup Audio + if args.cleanup: + try: + os.remove(audio_path) + tracker.logger.info(f"Cleanup: Removed temporary audio file {audio_path}") + except OSError as e: + tracker.logger.warning(f"Warning: Could not remove audio file: {e}") + + # Mark Complete + if translation_success: + tracker.update_job_status(file_path, JobStatus.COMPLETED) + else: + # If translation failed but we didn't crash, we technically finished the run but result is partial + tracker.update_job_status(file_path, JobStatus.FAILED, error="Translation failed") + + except Exception as e: + tracker.logger.exception(f"Job Failed for {file_path}") + tracker.update_job_status(file_path, JobStatus.FAILED, error=str(e)) + # Don't exit, allow other files to process + return + + def main(): + parser = argparse.ArgumentParser(description="AI Video Transcriber & Translator") + parser.add_argument("input", nargs='?', help="Path to video file or directory") + parser.add_argument("--model", default="auto", choices=["auto", "tiny", "base", "small", "medium", "large"], help="Whisper model size (default: auto)") + parser.add_argument("--lang", default="English", help="Target language for translation (default: English)") + parser.add_argument("--source-lang", help="Source language of the audio (e.g., 'fr', 'es'). If omitted, you will be prompted.") + parser.add_argument("--force", action="store_true", help="Overwrite existing transcript/translation files") + + # New Arguments + parser.add_argument("--cleanup", action="store_true", help="Delete the temporary .wav file after processing") + parser.add_argument("--embed", action="store_true", help="Embed the final subtitles into the video (Soft Subs)") + parser.add_argument("--diarize", action="store_true", help="Enable speaker diarization (requires HF_TOKEN)") + parser.add_argument("--hf-token", help="HuggingFace Token for pyannote.audio (or set HF_TOKEN env var)") + parser.add_argument("--delete-source", action="store_true", help="Delete the original video file AFTER successful embedding") + parser.add_argument("--retry-failed", action="store_true", help="Retry only jobs marked as FAILED in the database") + + args = parser.parse_args() + + if not os.getenv("GEMINI_API_KEY"): + print("Warning: GEMINI_API_KEY environment variable not set. Translation step will fail.") + + # Handling Retry Logic + if args.retry_failed: + print("Retrying failed jobs from database...") + failed_files = tracker.get_failed_jobs() + if not failed_files: + print("No failed jobs found.") + return + + # We need args.source_lang logic here too if needed, but for retries we might assume context + # For simplicity, we'll prompt if missing just like normal run + + # Determine source language (Prompt if missing) + source_lang = args.source_lang + if not source_lang: + print("\n--- Audio Configuration ---") + user_input = input("Enter the source language of the video(s) (e.g., 'French', 'es').\nPress Enter to use Whisper's auto-detection: ").strip() + if user_input: + source_lang = user_input + else: + source_lang = None # Let Whisper auto-detect + print("Selected: Auto-detect") + + for file_path in failed_files: + if os.path.exists(file_path): + process_file(file_path, args, source_lang) + else: + print(f"Skipping missing file: {file_path}") + return + + # Normal Logic + if not args.input: + parser.print_help() + sys.exit(1) + + # Determine source language (Prompt if missing) + source_lang = args.source_lang + if not source_lang: + print("\n--- Audio Configuration ---") + user_input = input("Enter the source language of the video(s) (e.g., 'French', 'es').\nPress Enter to use Whisper's auto-detection: ").strip() + if user_input: + source_lang = user_input + else: + source_lang = None # Let Whisper auto-detect + print("Selected: Auto-detect") + + if os.path.isfile(args.input): + process_file(args.input, args, source_lang) + elif os.path.isdir(args.input): + video_extensions = ('.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv', '.m4v') + found_files = False + for root, dirs, files in os.walk(args.input): + for file in files: + if file.lower().endswith(video_extensions): + found_files = True + file_path = os.path.join(root, file) + process_file(file_path, args, source_lang) + if not found_files: + print(f"No video files found in {args.input}") + else: + print(f"Error: Invalid input path '{args.input}'") + sys.exit(1) + + if __name__ == "__main__": + main() +def main(): + parser = argparse.ArgumentParser(description="AI Video Transcriber & Translator") + parser.add_argument("input", help="Path to video file or directory") + parser.add_argument("--model", default="auto", choices=["auto", "tiny", "base", "small", "medium", "large"], help="Whisper model size (default: auto)") + parser.add_argument("--lang", default="English", help="Target language for translation (default: English)") + parser.add_argument("--source-lang", help="Source language of the audio (e.g., 'fr', 'es'). If omitted, you will be prompted.") + parser.add_argument("--force", action="store_true", help="Overwrite existing transcript/translation files") + + # New Arguments + parser.add_argument("--cleanup", action="store_true", help="Delete the temporary .wav file after processing") + parser.add_argument("--embed", action="store_true", help="Embed the final subtitles into the video (Soft Subs)") + parser.add_argument("--diarize", action="store_true", help="Enable speaker diarization (requires HF_TOKEN)") + parser.add_argument("--hf-token", help="HuggingFace Token for pyannote.audio (or set HF_TOKEN env var)") + parser.add_argument("--delete-source", action="store_true", help="Delete the original video file AFTER successful embedding") + + args = parser.parse_args() + + if not os.getenv("GEMINI_API_KEY"): + print("Warning: GEMINI_API_KEY environment variable not set. Translation step will fail.") + + # Determine source language (Prompt if missing) + source_lang = args.source_lang + if not source_lang: + print("\n--- Audio Configuration ---") + user_input = input("Enter the source language of the video(s) (e.g., 'French', 'es').\nPress Enter to use Whisper's auto-detection: ").strip() + if user_input: + source_lang = user_input + else: + source_lang = None # Let Whisper auto-detect + print("Selected: Auto-detect") + + if os.path.isfile(args.input): + process_file(args.input, args, source_lang) + elif os.path.isdir(args.input): + video_extensions = ('.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv', '.m4v') + found_files = False + for root, dirs, files in os.walk(args.input): + for file in files: + if file.lower().endswith(video_extensions): + found_files = True + file_path = os.path.join(root, file) + process_file(file_path, args, source_lang) + if not found_files: + print(f"No video files found in {args.input}") + else: + print(f"Error: Invalid input path '{args.input}'") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/video_transcription/ai_transcriber/requirements.txt b/video_transcription/ai_transcriber/requirements.txt new file mode 100644 index 0000000..a9d3c11 --- /dev/null +++ b/video_transcription/ai_transcriber/requirements.txt @@ -0,0 +1,8 @@ +openai-whisper +google-generativeai +ffmpeg-python +torch +numpy +tenacity +pysubs2 +pyannote.audio diff --git a/video_transcription/ai_transcriber/tracker.py b/video_transcription/ai_transcriber/tracker.py new file mode 100644 index 0000000..234e0e1 --- /dev/null +++ b/video_transcription/ai_transcriber/tracker.py @@ -0,0 +1,84 @@ +import logging +import os +from datetime import datetime +from sqlalchemy import create_engine, Column, Integer, String, DateTime, Enum, Text +from sqlalchemy.orm import declarative_base, sessionmaker +import enum + +# Setup Logging +log_dir = "logs" +os.makedirs(log_dir, exist_ok=True) +log_file = os.path.join(log_dir, f"transcriber_{datetime.now().strftime('%Y%m%d')}.log") + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(log_file), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +# Database Setup +Base = declarative_base() +DB_FILE = "job_history.db" + +class JobStatus(enum.Enum): + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + +class Job(Base): + __tablename__ = 'jobs' + + id = Column(Integer, primary_key=True) + file_path = Column(String, unique=True, nullable=False) + status = Column(Enum(JobStatus), default=JobStatus.PENDING) + error_message = Column(Text, nullable=True) + last_updated = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Track progress of individual steps + step_extract = Column(String, default="pending") # pending, done, failed + step_transcribe = Column(String, default="pending") + step_translate = Column(String, default="pending") + step_embed = Column(String, default="pending") + +engine = create_engine(f'sqlite:///{DB_FILE}') +Base.metadata.create_all(engine) +Session = sessionmaker(bind=engine) + +def get_job(file_path): + session = Session() + job = session.query(Job).filter_by(file_path=file_path).first() + if not job: + job = Job(file_path=file_path) + session.add(job) + session.commit() + session.close() + return job + +def update_job_status(file_path, status, error=None): + session = Session() + job = session.query(Job).filter_by(file_path=file_path).first() + if job: + job.status = status + if error: + job.error_message = str(error) + session.commit() + session.close() + +def update_step(file_path, step_name, status): + session = Session() + job = session.query(Job).filter_by(file_path=file_path).first() + if job: + setattr(job, step_name, status) + session.commit() + session.close() + +def get_failed_jobs(): + session = Session() + jobs = session.query(Job).filter_by(status=JobStatus.FAILED).all() + session.close() + return [j.file_path for j in jobs] diff --git a/video_transcription/ai_transcriber/transcriber.py b/video_transcription/ai_transcriber/transcriber.py new file mode 100644 index 0000000..f202022 --- /dev/null +++ b/video_transcription/ai_transcriber/transcriber.py @@ -0,0 +1,168 @@ +import whisper +import os +import sys +import subprocess +import torch + +def check_gpu_health(): + """ + Performs a robust check for GPU availability and prints detailed troubleshooting + info if issues are detected, specific to Bazzite/VS Code environments. + """ + print("Checking GPU health...") + + # 1. Check if the OS/Driver sees the GPU + nvidia_smi_ok = False + try: + subprocess.run(["nvidia-smi"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + nvidia_smi_ok = True + except (subprocess.CalledProcessError, FileNotFoundError): + nvidia_smi_ok = False + + # 2. Check if PyTorch sees the GPU + torch_cuda_ok = torch.cuda.is_available() + + if torch_cuda_ok: + print(f"✅ GPU is accessible: {torch.cuda.get_device_name(0)}") + print(f" CUDA Version: {torch.version.cuda}") + return True + + # --- Troubleshooting Block --- + print("\n⚠️ WARNING: GPU not detected by PyTorch. Falling back to CPU.") + print(" Transcription will be significantly slower.\n") + + print("--- Diagnostic Report ---") + if nvidia_smi_ok: + print("1. [OK] 'nvidia-smi' command works. The system driver is installed and visible.") + print("2. [FAIL] PyTorch cannot see the GPU.") + print(" -> Likely Cause: You might have installed the CPU-only version of PyTorch.") + print(" -> Solution: Reinstall PyTorch with CUDA support:") + print(" pip uninstall torch torchvision torchaudio") + print(" pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118") + else: + print("1. [FAIL] 'nvidia-smi' command failed or not found.") + print(" -> Likely Cause: Nvidia drivers are missing, or the container/sandbox cannot access the GPU.") + + print("\n --- Bazzite / VS Code / Container Specific Checks ---") + print(" a. If you are running inside a dev container (DevBox/Distrobox/Toolbox):") + print(" Ensure the container was created with nvidia support.") + print(" (Bazzite usually handles this for 'distrobox', but check your config).") + print(" b. If you are using VS Code Flatpak:") + print(" Flatpak might be restricting access. Check Flatseal permissions for VS Code.") + print(" c. Driver Check:") + print(" Run 'rpm -qa | grep nvidia' in your host terminal to verify drivers are installed.") + + print("-------------------------\n") + return False + +def get_vram_gb(): + """Returns the total VRAM in GB of the first CUDA device, or 0 if no CUDA.""" + if not torch.cuda.is_available(): + return 0 + try: + # returns bytes + total_mem = torch.cuda.get_device_properties(0).total_memory + return total_mem / (1024 ** 3) + except Exception: + return 0 + +def get_optimal_model_size(): + """ + Determines the best Whisper model based on available VRAM. + Rough estimates for VRAM usage (fp16): + - large: ~10 GB + - medium: ~5 GB + - small: ~2 GB + - base: ~1 GB + - tiny: ~1 GB + """ + vram = get_vram_gb() + if vram == 0: + return "base" + + print(f"Detected GPU with {vram:.2f} GB VRAM.") + + if vram >= 11: + return "large" + elif vram >= 6: + return "medium" + elif vram >= 3: + return "small" + else: + return "base" + +def format_timestamp(seconds: float): + """Converts seconds to SRT timestamp format (HH:MM:SS,mmm).""" + whole_seconds = int(seconds) + milliseconds = int((seconds - whole_seconds) * 1000) + + hours = whole_seconds // 3600 + minutes = (whole_seconds % 3600) // 60 + seconds = whole_seconds % 60 + + return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}" + +def save_as_srt(result, output_path): + """Saves the Whisper transcription result as an SRT file.""" + with open(output_path, "w", encoding="utf-8") as f: + for i, segment in enumerate(result["segments"], start=1): + start = format_timestamp(segment["start"]) + end = format_timestamp(segment["end"]) + text = segment["text"].strip() + + f.write(f"{i}\n") + f.write(f"{start} --> {end}\n") + f.write(f"{text}\n\n") + print(f"SRT saved to: {output_path}") + +def transcribe_audio(audio_path, model_size="auto", language=None): + """ + Transcribes an audio file using OpenAI's Whisper model. + + Args: + audio_path (str): Path to the input audio file. + model_size (str): Size of the Whisper model to use. If "auto", selects based on VRAM. + language (str, optional): Language code (e.g., "en", "fr", "es"). If None, auto-detects. + + Returns: + dict: The full transcription result containing segments and text. + """ + if not os.path.exists(audio_path): + raise FileNotFoundError(f"Audio file not found: {audio_path}") + + # Run health check once + check_gpu_health() + + # Determine model size if auto + if model_size == "auto": + model_size = get_optimal_model_size() + print(f"Auto-selected model: '{model_size}'") + + print(f"Loading Whisper model ('{model_size}')...") + + # Check for GPU availability + device = "cuda" if torch.cuda.is_available() else "cpu" + print(f"Using device: {device}") + + try: + model = whisper.load_model(model_size, device=device) + except RuntimeError as e: + if "out of memory" in str(e).lower(): + print("Error: GPU Out of Memory. Try using a smaller model size.") + else: + print(f"Error loading model: {e}") + sys.exit(1) + except Exception as e: + print(f"Error loading model: {e}") + sys.exit(1) + + print(f"Transcribing {audio_path}...") + try: + # fp16=False is needed for CPU, but we can let whisper handle defaults usually. + # language=None allows auto-detection. + result = model.transcribe(audio_path, language=language) + print("Transcription complete.") + return result + except Exception as e: + print(f"Error during transcription: {e}") + sys.exit(1) diff --git a/video_transcription/ai_transcriber/translator.py b/video_transcription/ai_transcriber/translator.py new file mode 100644 index 0000000..021c32f --- /dev/null +++ b/video_transcription/ai_transcriber/translator.py @@ -0,0 +1,127 @@ +import os +import sys +import google.generativeai as genai +from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type + +# Define a retry decorator +# Waits 2^x * 1 seconds between retries (1s, 2s, 4s, 8s, 16s, 32s...) +# With max=60, it will cap at waiting 60s per try. +# Stop after 15 attempts (approx 15 minutes of trying before giving up) +retry_policy = retry( + stop=stop_after_attempt(15), + wait=wait_exponential(multiplier=1, min=2, max=60), + retry=retry_if_exception_type(Exception), + reraise=True +) + +@retry_policy +def _generate_with_retry(model, prompt): + """Internal function to wrap the API call with retry logic.""" + try: + return model.generate_content(prompt) + except Exception as e: + if "429" in str(e) or "Resource has been exhausted" in str(e): + print(f" [Rate Limit Hit] Waiting for quota reset... ({e})") + raise e + +def get_best_available_model(): + """ + Queries the API to find the best available model for text generation. + Priority: gemini-1.5-flash > gemini-1.5-pro > gemini-pro > any 'generateContent' model + """ + try: + available_models = [] + for m in genai.list_models(): + if 'generateContent' in m.supported_generation_methods: + available_models.append(m.name) + + # Priority list + priorities = [ + "models/gemini-1.5-flash", + "models/gemini-1.5-pro", + "models/gemini-pro" + ] + + # Check for priorities first + for p in priorities: + if p in available_models: + return p + + # Fallback: check for aliases without 'models/' prefix just in case + for p in priorities: + short_name = p.replace("models/", "") + # Some libraries might return short names, or custom handling + # But genai.list_models() usually returns 'models/name' + pass + + # If priority not found, pick the first available gemini model + for m in available_models: + if "gemini" in m: + return m + + if available_models: + return available_models[0] + + except Exception as e: + print(f"Warning: Could not list models ({e}). Defaulting to 'gemini-pro'.") + + return "gemini-pro" + +def translate_srt(srt_content, target_language="English", api_key=None): + """ + Translates SRT subtitle content using the Gemini API, preserving timestamps. + + Args: + srt_content (str): The raw text content of the SRT file. + target_language (str): The target language for translation. + api_key (str): Google Gemini API key. If None, checks env var GEMINI_API_KEY. + + Returns: + str: The translated SRT content. + """ + if not srt_content: + return "" + + key = api_key or os.getenv("GEMINI_API_KEY") + if not key: + print("Error: GEMINI_API_KEY not found. Please set the environment variable or pass the key.") + sys.exit(1) + + genai.configure(api_key=key) + + # Automatically select the best model + model_name = get_best_available_model() + print(f"Using Gemini Model: {model_name}") + + model = genai.GenerativeModel(model_name) + + prompt = ( + "You are a professional subtitle translator. Your task is to translate the following SRT subtitle file " + f"into {target_language}.\n\n" + "RULES:\n" + "1. PRESERVE the SRT format exactly. Do not modify timestamps (e.g., 00:00:01,000 --> 00:00:04,000) or sequence numbers.\n" + "2. Only translate the dialogue text.\n" + "3. Maintain the original tone and context.\n" + "4. Output ONLY the translated SRT content, no markdown code blocks or explanations.\n\n" + "SRT Content:\n" + f"{srt_content}" + ) + + print(f"Translating subtitles to {target_language} (with retries)...") + try: + # Call the retried internal function + response = _generate_with_retry(model, prompt) + print("Translation complete.") + + # Cleanup: sometimes models wrap output in ```srt ... ``` or ``` ... ``` + cleaned_text = response.text.strip() + if cleaned_text.startswith("```"): + # Remove first line (```srt or ```) and last line (```) + lines = cleaned_text.split('\n') + if len(lines) >= 2: + cleaned_text = '\n'.join(lines[1:-1]) + + return cleaned_text + except Exception as e: + print(f"Error during translation after retries: {e}") + return None diff --git a/video_transcription/ai_transcriber/utils.py b/video_transcription/ai_transcriber/utils.py new file mode 100644 index 0000000..8c452bf --- /dev/null +++ b/video_transcription/ai_transcriber/utils.py @@ -0,0 +1,28 @@ +import pysubs2 +import os + +def validate_and_repair_srt(srt_path): + """ + Validates an SRT file and attempts to repair it using pysubs2. + + Args: + srt_path (str): Path to the SRT file. + + Returns: + bool: True if valid/repaired, False if critical error. + """ + if not os.path.exists(srt_path): + return False + + print(f"Validating SRT: {srt_path}...") + try: + # Load the subtitle file. pysubs2 parser is robust and handles many errors automatically. + subs = pysubs2.load(srt_path) + + # Save it back ensures consistent formatting and fixes minor syntax issues + subs.save(srt_path) + print("SRT validation passed (file re-saved with correct formatting).") + return True + except Exception as e: + print(f"Warning: SRT validation failed: {e}") + return False diff --git a/video_transcription/extract_audio.sh b/video_transcription/extract_audio.sh new file mode 100755 index 0000000..8067c31 --- /dev/null +++ b/video_transcription/extract_audio.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +# Function to handle single file conversion +convert_file() { + local INPUT_FILE="$1" + + # Check if file exists + if [ ! -f "$INPUT_FILE" ]; then + echo "Warning: File '$INPUT_FILE' not found. Skipping." + return + fi + + # Get path and filename info + local DIRNAME=$(dirname -- "$INPUT_FILE") + local FILENAME=$(basename -- "$INPUT_FILE") + local FILENAME_NO_EXT="${FILENAME%.*}" + + # Define output filename in the same directory + local OUTPUT_FILE="${DIRNAME}/${FILENAME_NO_EXT}.wav" + + # Check if we are trying to convert a wav to wav (avoid redundant work or loops) + if [[ "$INPUT_FILE" == *.wav ]]; then + echo "Skipping .wav file: $INPUT_FILE" + return + fi + + echo "Processing '$INPUT_FILE' -> '$OUTPUT_FILE'..." + + # Run ffmpeg command + # -y overwrites output files without asking + # -v error -stats reduces output verbosity but keeps progress/errors + ffmpeg -i "$INPUT_FILE" -ar 16000 -ac 1 -c:a pcm_s16le -y "$OUTPUT_FILE" < /dev/null -v error -stats + + if [ $? -eq 0 ]; then + echo -e "\nSuccess: '$OUTPUT_FILE' created." + else + echo -e "\nError converting '$INPUT_FILE'." + fi + echo "----------------------------------------" +} + +# Check if at least one input is provided +if [ -z "$1" ]; then + echo "Usage: $0 [file_or_folder2 ...]" + exit 1 +fi + +# Loop through all provided arguments +for ARG in "$@"; do + # Check for SMB/Network URIs which standard shell tools don't support directly + if [[ "$ARG" == smb://* ]]; then + echo "Error: Network URI '$ARG' detected." + echo "This script works on filesystem paths. Please mount the network share first." + echo " - Linux (GVFS): Check /run/user/\$UID/gvfs/" + echo " - macOS: Check /Volumes/" + echo " - Windows (WSL): Mount the drive to a letter or /mnt/" + continue + fi + + if [ -d "$ARG" ]; then + # It's a directory: find files recursively + echo "Scanning directory '$ARG' for video files..." + + # Find common video files (case insensitive) + # Using -print0 and while read loop handles filenames with spaces correctly + find "$ARG" -type f \( \ + -iname "*.mp4" -o \ + -iname "*.mkv" -o \ + -iname "*.mov" -o \ + -iname "*.avi" -o \ + -iname "*.webm" -o \ + -iname "*.flv" -o \ + -iname "*.wmv" -o \ + -iname "*.m4v" -o \ + -iname "*.mpg" -o \ + -iname "*.mpeg" -o \ + -iname "*.3gp" -o \ + -iname "*.ts" \ + \) -print0 | while IFS= read -r -d '' FOUND_FILE; do + convert_file "$FOUND_FILE" + done + + elif [ -f "$ARG" ]; then + # It's a single file + convert_file "$ARG" + else + echo "Warning: '$ARG' is not a valid file or directory." + fi +done diff --git a/video_transcription/recover_and_fix.py b/video_transcription/recover_and_fix.py new file mode 100755 index 0000000..5a3e2cd --- /dev/null +++ b/video_transcription/recover_and_fix.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +import os +import sys +import argparse +import subprocess +from dotenv import load_dotenv + +# Load config +script_dir = os.path.dirname(os.path.abspath(__file__)) +env_path = os.path.abspath(os.path.join(script_dir, '../.env_files/.env.aitranscribe')) +if os.path.exists(env_path): + load_dotenv(env_path) +else: + load_dotenv() + +# Add ai_transcriber to path so we can import modules +sys.path.append(os.path.join(script_dir, 'ai_transcriber')) + +from ai_transcriber.translator import translate_srt +from ai_transcriber.utils import validate_and_repair_srt + +def re_embed_subtitles(video_path, srt_path, output_path=None): + """ + Re-embeds subtitles into an EXISTING video file, replacing the old tracks. + Similar to embed_subtitles but we output to a temp file then overwrite. + """ + if not os.path.exists(video_path) or not os.path.exists(srt_path): + print("Error: Video or SRT file not found.") + return False + + temp_output = video_path + ".temp.mp4" + + print(f"Re-embedding subtitles into: {video_path}...") + + # We map 0:v and 0:a from source, but ignore existing subs (0:s?) + # Actually, simpler is to just map 0:v, 0:a and 1:0 (new sub) + # and tell ffmpeg to make the new one default. + # If the old file has subs, they might be kept unless we use -map -0:s (negative mapping) + + sub_codec = "mov_text" if video_path.lower().endswith(".mp4") else "srt" + + command = [ + "ffmpeg", + "-i", video_path, + "-i", srt_path, + "-map", "0:v", + "-map", "0:a", + "-map", "1:0", # The new subtitle + "-c", "copy", # Copy AV + "-c:s", sub_codec, # Encode Sub + "-disposition:s:0", "default", + "-metadata:s:s:0", "language=eng", + "-metadata:s:s:0", "title=English (AI Translated)", + "-y", + "-v", "error", + temp_output + ] + + try: + subprocess.run(command, check=True) + # Move temp over original + os.replace(temp_output, video_path) + print(f"✅ Fixed: {video_path}") + return True + except subprocess.CalledProcessError as e: + print(f"Error re-embedding: {e}") + if os.path.exists(temp_output): + os.remove(temp_output) + return False + +def process_recovery(folder_path, target_lang="English"): + print(f"Scanning {folder_path} for incomplete translations...") + + count_fixed = 0 + count_skipped = 0 + + video_extensions = ('.mp4', '.mkv', '.mov', '.avi') + + for root, dirs, files in os.walk(folder_path): + for file in files: + # We are looking for the SOURCE SRT files primarily + if file.endswith(".srt") and not file.endswith(f".{target_lang}.srt"): + source_srt_path = os.path.join(root, file) + base_name = os.path.splitext(file)[0] # e.g. "movie" or "movie.subbed" + + # Check if this is a "subbed" file's srt? + # Usually source srt is "movie.srt". + # Video might be "movie.subbed.mp4" (if original was deleted) + # OR "movie.mp4" (if not deleted) + + # Check if Translation exists + expected_translation = os.path.join(root, f"{base_name}.{target_lang}.srt") + + if os.path.exists(expected_translation): + # It's already translated, nothing to do unless we force re-embed + continue + + print(f"\nFound untranslated transcript: {file}") + + # Try to translate + with open(source_srt_path, "r", encoding="utf-8") as f: + content = f.read() + + new_srt_content = translate_srt(content, target_language=target_lang) + + if not new_srt_content: + print("❌ Translation failed (API Limit?). Skipping.") + continue + + # Save translation + with open(expected_translation, "w", encoding="utf-8") as f: + f.write(new_srt_content) + validate_and_repair_srt(expected_translation) + + # Now, find the video file to update + # Case 1: Original name + video_candidates = [ + os.path.join(root, base_name + ".mp4"), + os.path.join(root, base_name + ".mkv"), + # Case 2: .subbed name (if original deleted) + os.path.join(root, base_name + ".subbed.mp4"), + ] + + found_video = None + for v in video_candidates: + if os.path.exists(v): + found_video = v + break + + if found_video: + print(f"Found video to fix: {found_video}") + if re_embed_subtitles(found_video, expected_translation): + count_fixed += 1 + else: + print("Warning: Could not find a corresponding video file to embed into.") + + print(f"\nRecovery Complete. Fixed {count_fixed} files.") + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: ./recover_and_fix.py [target_lang]") + sys.exit(1) + + folder = sys.argv[1] + lang = sys.argv[2] if len(sys.argv) > 2 else "English" + + process_recovery(folder, lang) diff --git a/video_transcription/run_wizard.py b/video_transcription/run_wizard.py new file mode 100755 index 0000000..08c8d8c --- /dev/null +++ b/video_transcription/run_wizard.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +import os +import sys +import subprocess +import shutil +from pathlib import Path + +# Try to load the .env file so the wizard knows what's already configured +try: + from dotenv import load_dotenv + # Path logic matching main.py + script_dir = os.path.dirname(os.path.abspath(__file__)) + # Expected: .../video_transcription/../.env_files -> .../personal_development/.env_files + env_path = os.path.abspath(os.path.join(script_dir, '../.env_files/.env.aitranscribe')) + if os.path.exists(env_path): + load_dotenv(env_path) +except ImportError: + pass + +def clear_screen(): + os.system('cls' if os.name == 'nt' else 'clear') + +def get_input(prompt, default=None): + """Helper to get input with a default value.""" + if default: + user_input = input(f"{prompt} [{default}]: ").strip() + return user_input if user_input else default + else: + return input(f"{prompt}: ").strip() + +def get_yes_no(prompt, default="y"): + """Helper to get boolean input.""" + display_default = "Y/n" if default.lower() in ["y", "yes"] else "y/N" + choice = get_input(f"{prompt} ({display_default})", default).lower() + return choice in ["y", "yes", "true", "1"] + +def print_header(): + print("==========================================") + print(" AI Video Transcriber & Translator Wizard") + print("==========================================") + print("") + +def main(): + clear_screen() + print_header() + + # 1. Input File/Folder + while True: + input_path = get_input("Enter the path to the video file or folder") + + # Clean up input: + # 1. Remove surrounding quotes (common when pasting paths) + input_path = input_path.strip('"\'') + # 2. Handle escaped spaces (e.g., "My\ Folder" -> "My Folder") + input_path = input_path.replace(r'\ ', ' ') + + # Expand user (~) and resolve absolute path + input_path = os.path.abspath(os.path.expanduser(input_path)) + + if os.path.exists(input_path): + break + print(f"Error: Path '{input_path}' does not exist. Please try again.\n") + + print(f"Selected: {input_path}\n") + + # 2. Languages + source_lang = get_input("Source Language (e.g., French, es)", default="auto") + target_lang = get_input("Target Language for translation", default="English") + print("") + + # 3. Model Size + print("Model Size Options: tiny, base, small, medium, large, auto") + model_size = get_input("Whisper Model Size", default="auto") + print("") + + # 4. Features + do_cleanup = get_yes_no("Cleanup temporary audio files after processing?", default="y") + do_embed = get_yes_no("Embed subtitles into the video file (Soft Subs)?", default="y") + do_diarize = get_yes_no("Enable Speaker Diarization (Identify speakers)?", default="n") + + do_delete_source = False + if do_embed: + print("\n⚠️ WARNING: Using this next option will PERMANENTLY DELETE the original video files.") + print(" It will only run if the new subtitled video is successfully created.") + do_delete_source = get_yes_no("Delete original source files after embedding?", default="n") + + hf_token = None + if do_diarize: + if not os.getenv("HF_TOKEN"): + print("\nSpeaker Diarization requires a HuggingFace Token.") + hf_token = get_input("Enter your HuggingFace Token (hidden)", default="") + # In a real app we might use getpass, but standard input is fine for this wizard level + else: + print("Using HF_TOKEN from environment.") + + # 5. Build Command + # script is in ai_transcriber/main.py relative to this script + script_dir = os.path.dirname(os.path.abspath(__file__)) + main_script = os.path.join(script_dir, "ai_transcriber", "main.py") + + cmd = [sys.executable, main_script, input_path] + + cmd.extend(["--lang", target_lang]) + cmd.extend(["--model", model_size]) + + if source_lang != "auto": + cmd.extend(["--source-lang", source_lang]) + + if do_cleanup: + cmd.append("--cleanup") + + if do_embed: + cmd.append("--embed") + + if do_delete_source: + cmd.append("--delete-source") + + if do_diarize: + cmd.append("--diarize") + if hf_token: + cmd.extend(["--hf-token", hf_token]) + + # 6. Confirmation and Execution + clear_screen() + print_header() + print("Configuration Complete!") + print("-" * 30) + print(f"Input: {input_path}") + print(f"Source Lang: {source_lang}") + print(f"Target Lang: {target_lang}") + print(f"Model: {model_size}") + print(f"Cleanup: {do_cleanup}") + print(f"Embed Subs: {do_embed}") + print(f"Delete Src: {do_delete_source}") + print(f"Diarization: {do_diarize}") + print("-" * 30) + + if not get_yes_no("Run this job now?", default="y"): + print("Aborted.") + sys.exit(0) + + print("\nStarting Job...\n") + + try: + # Pass environment variables including HF_TOKEN if set + env = os.environ.copy() + if hf_token: + env["HF_TOKEN"] = hf_token + + subprocess.run(cmd, check=True, env=env) + print("\n✅ Job Complete!") + except subprocess.CalledProcessError as e: + print(f"\n❌ Job Failed with error code {e.returncode}") + except KeyboardInterrupt: + print("\nJob interrupted by user.") + +if __name__ == "__main__": + main()