Skip to content

Commit 86b6f2b

Browse files
committed
ai_timestamp_subtitles.py - script to automatically timestamp subtitles
1 parent c6c3caf commit 86b6f2b

1 file changed

Lines changed: 120 additions & 0 deletions

File tree

‎ai_timestamp_subtitles.py‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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

Comments
 (0)