|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +""" |
| 4 | +The script takes: |
| 5 | +- a 16 kHz mono input audio file that is assumed to contain vocal track only |
| 6 | + with no other sounds or excessive noise |
| 7 | +- an input text file assumed to contain non-timestamped text matching the vocal |
| 8 | + tracks (e.g. song lyrics for Karaoke) |
| 9 | +
|
| 10 | +then it outputs a timestamped subtitles file in ASS format. |
| 11 | +""" |
| 12 | + |
| 13 | +import whisperx |
| 14 | +import pysubs2 |
| 15 | +import numpy as np |
| 16 | +import os |
| 17 | + |
| 18 | +# The input file must be mono and have 16 kHz sampling rate. We don't |
| 19 | +# do format conversions here for brevity. |
| 20 | +audio_file = "input_16kHz_mono.wav" |
| 21 | +lyrics_file = "input_lyrics.txt" |
| 22 | +output_ass_file = "output_subtitles.ass" |
| 23 | +# We don't read the audio duration from its metadata either, for brevity. |
| 24 | +# Set this to the correct value or write your own pre-processing code for that. |
| 25 | +duration_in_seconds = 359.0 |
| 26 | + |
| 27 | +device = "cuda" |
| 28 | + |
| 29 | +# 1. Load pre-existing correct lyrics, must match the vocal track exactly |
| 30 | +with open(lyrics_file, "r", encoding="utf-8") as f: |
| 31 | + ground_truth_lyrics = [line.strip() for line in f.readlines() if line.strip() and not line.startswith("[")] |
| 32 | + |
| 33 | +# 2. Combine the lines into a single, continuous stream to pass to the alignment model |
| 34 | +full_text_stream = " ".join(ground_truth_lyrics) |
| 35 | + |
| 36 | +# 3. Load Audio and the Phoneme Alignment Engine natively |
| 37 | +audio = whisperx.load_audio(audio_file) |
| 38 | +print("Loading ASR Alignment Model...") |
| 39 | +model_a, metadata = whisperx.load_align_model(language_code="en", device=device) |
| 40 | + |
| 41 | +# 4. Pass the entire text stream as a single continuous block from 0 to the end |
| 42 | +input_segments = [{"text": full_text_stream, "start": 0.0, "end": duration_in_seconds}] |
| 43 | + |
| 44 | +print("Analyzing isolated vocal waves and executing forced word alignment...") |
| 45 | +aligned_result = whisperx.align(input_segments, model_a, metadata, audio, device, |
| 46 | + return_char_alignments=False) |
| 47 | + |
| 48 | +# 5. Flatten every successfully recognized word into a single sequential timeline |
| 49 | +all_recognized_words = [] |
| 50 | +if aligned_result.get("segments"): |
| 51 | + for segment in aligned_result["segments"]: |
| 52 | + for w in segment.get("words", []): |
| 53 | + if "start" in w and "end" in w: |
| 54 | + all_recognized_words.append(w) |
| 55 | + |
| 56 | +# 6. Reconstruct original lines with dynamic statistical adjustments |
| 57 | +subs = pysubs2.SSAFile() |
| 58 | +word_pointer = 0 |
| 59 | + |
| 60 | +print("Rebuilding lyrics layout using generalized statistical heuristics...") |
| 61 | +for line in ground_truth_lyrics: |
| 62 | + line_tokens = line.split() |
| 63 | + line_word_objects = [] |
| 64 | + |
| 65 | + # Extract the slice of word objects belonging to this specific line |
| 66 | + for _ in line_tokens: |
| 67 | + if word_pointer < len(all_recognized_words): |
| 68 | + line_word_objects.append(all_recognized_words[word_pointer]) |
| 69 | + word_pointer += 1 |
| 70 | + |
| 71 | + if not line_word_objects: |
| 72 | + continue |
| 73 | + |
| 74 | + # Calculate base absolute boundaries |
| 75 | + line_start = line_word_objects[0]["start"] |
| 76 | + line_end = line_word_objects[-1]["end"] |
| 77 | + |
| 78 | + # Calculate word durations for statistical outlier analysis |
| 79 | + word_durations = [w["end"] - w["start"] for w in line_word_objects] |
| 80 | + |
| 81 | + # --- HEURISTIC 1: Fix Early Breath/Noise Triggers --- |
| 82 | + if len(line_word_objects) > 1: |
| 83 | + first_word_duration = word_durations[0] |
| 84 | + median_other_duration = np.median(word_durations[1:]) |
| 85 | + |
| 86 | + # If the first word is abnormally long (e.g., 3x longer than the median word), |
| 87 | + # it likely caught pre-vocal breath noise. Clip it closer to the next word. |
| 88 | + if first_word_duration > (median_other_duration * 3.0) and first_word_duration > 1.5: |
| 89 | + # Shift the line start forward to the true phonetic onset |
| 90 | + line_start = line_word_objects[1]["start"] - min(0.3, median_other_duration) |
| 91 | + |
| 92 | + # --- HEURISTIC 2: Fix Vocal Tail Bloat (e.g., Extended Outro Ad-libs) --- |
| 93 | + if len(line_word_objects) > 1: |
| 94 | + last_word_duration = word_durations[-1] |
| 95 | + median_prev_duration = np.median(word_durations[:-1]) |
| 96 | + |
| 97 | + # If the final word stretches out excessively compared to the rest of the line, |
| 98 | + # clamp its end time to prevent reverb trails from bloating the subtitle duration. |
| 99 | + if last_word_duration > (median_prev_duration * 2.5) and last_word_duration > 1.5: |
| 100 | + # Tighten the line end relative to when the final word actually peak-vocalized |
| 101 | + line_end = line_word_objects[-1]["start"] + max(1.0, median_prev_duration * 1.5) |
| 102 | + |
| 103 | + # --- HEURISTIC 3: Enforce 1-Second Minimum legibility duration --- |
| 104 | + if (line_end - line_start) < 1.0: |
| 105 | + line_end = line_start + 1.0 |
| 106 | + |
| 107 | + # Safety gate to ensure end never precedes start after calculations |
| 108 | + if line_end <= line_start: |
| 109 | + line_end = line_start + 1.5 |
| 110 | + |
| 111 | + event = pysubs2.SSAEvent( |
| 112 | + start=int(line_start * 1000), # Convert floats to milliseconds |
| 113 | + end=int(line_end * 1000), |
| 114 | + text=line |
| 115 | + ) |
| 116 | + subs.append(event) |
| 117 | + |
| 118 | +# 7. Save out generalized, polished subtitle track |
| 119 | +subs.save(output_ass_file) |
| 120 | +print(f"Successfully generated speech-aligned subtitles: {output_ass_file}") |
0 commit comments