Files
python_scripts/package/transcribe_client.py
2026-09-24 19:53:05 +00:00

173 lines
6.7 KiB
Python

#!/usr/bin/env python3
#!/usr/bin/env python3
import threading
# --- HOTFIX for Python 3.13.5 _DeleteDummyThreadOnDel spam ---
# The CPython fix in 3.13.4 didn't fully kill it for debugpy/asyncio cases
if hasattr(threading, '_DeleteDummyThreadOnDel'):
threading._DeleteDummyThreadOnDel.__del__ = lambda self: None
import os
import shutil
# ... rest of your imports
import os
import shutil
import subprocess
import json
from pathlib import Path
import wave
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
CHUNK_DURATION_SEC = 600 # target chunk size
OVERLAP_SEC = 8 # overlap to prevent cut words
VAD_SEARCH_WINDOW = 15 # look for silence within 15s of target
WORKER_URL = "http://192.168.5.55:8000/transcribe"
MAX_WORKERS = 4 # perfect for 4070 Ti Super 16GB
def get_duration(video_path: str) -> float:
cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", video_path]
result = subprocess.run(cmd, capture_output=True, text=True)
return float(json.loads(result.stdout)["format"]["duration"])
def find_silence_near(video_path: str, target_time: float) -> float:
"""Find nearest silence within VAD_SEARCH_WINDOW of target_time"""
start_search = max(0, target_time - VAD_SEARCH_WINDOW/2)
cmd = [
"ffmpeg", "-ss", str(start_search), "-t", str(VAD_SEARCH_WINDOW),
"-i", video_path, "-af", "silencedetect=noise=-30dB:d=0.5",
"-f", "null", "-"
]
result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
# Parse last silence end before target
best = target_time
for line in result.stderr.splitlines():
if "silence_end:" in line:
try:
t = float(line.split("silence_end:")[1].split("|")[0].strip())
abs_t = start_search + t
if abs(abs_t - target_time) < abs(best - target_time):
best = abs_t
except: pass
return best
def extract_audio_and_chunk_smart(video_path: str, output_dir: Path):
print(f"🚀 Smart chunking: target {CHUNK_DURATION_SEC}s + {OVERLAP_SEC}s overlap")
output_dir.mkdir(parents=True, exist_ok=True)
total_dur = get_duration(video_path)
print(f"📏 Total duration: {total_dur/60:.1f} min")
chunks = []
start = 0.0
idx = 0
while start < total_dur:
# Find smart cut point
target_end = start + CHUNK_DURATION_SEC
if target_end >= total_dur:
end = total_dur
else:
end = find_silence_near(video_path, target_end)
# clamp
end = max(start + CHUNK_DURATION_SEC * 0.7, min(end, start + CHUNK_DURATION_SEC * 1.3))
chunk_path = output_dir / f"chunk_{idx:03d}.wav"
# Extract with overlap for next chunk
extract_end = end + (OVERLAP_SEC if end < total_dur else 0)
cmd = [
"ffmpeg", "-y", "-ss", str(start), "-to", str(extract_end),
"-i", video_path, "-vn", "-ac", "1", "-ar", "16000", "-acodec", "pcm_s16le",
str(chunk_path)
]
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
chunks.append((chunk_path, start, end)) # path, true_start, true_end (without overlap)
print(f" Chunk {idx}: {start:.1f}s -> {end:.1f}s (file to {extract_end:.1f}s)")
if end >= total_dur:
break
start = end - OVERLAP_SEC/2 # start next chunk with half overlap
idx += 1
return chunks
def format_srt_time(seconds: float):
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int((seconds % 1) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def send_chunk_for_transcription(args):
chunk_path, true_start, true_end, chunk_idx = args
try:
with open(chunk_path, 'rb') as f:
files = {'file': (chunk_path.name, f, 'audio/wav')}
resp = requests.post(WORKER_URL, files=files, timeout=1200)
resp.raise_for_status()
data = resp.json()
# Filter to only keep segments within true window (dedupe overlap)
filtered = []
for seg in data['segments']:
abs_start = true_start + seg['start']
# keep if it starts before the true_end (allow small spill)
if abs_start < true_end + OVERLAP_SEC/2:
# if it's in overlap zone of NEXT chunk, only keep first half
if abs_start < true_end - OVERLAP_SEC/2 or chunk_idx == 0:
filtered.append(seg)
elif seg['start'] < (true_end - true_start) - OVERLAP_SEC/2 + 1.0:
filtered.append(seg)
return chunk_idx, true_start, filtered, None
except Exception as e:
return chunk_idx, true_start, None, str(e)
def transcribe_to_srt_remote(video_path: str, force: bool = False):
video_path_obj = Path(video_path)
srt_path = video_path_obj.with_suffix(".srt")
temp_dir = video_path_obj.parent / f"temp_chunks_{video_path_obj.stem}"
if srt_path.exists() and not force:
print(f"❌ SRT already exists: {srt_path}")
return
if srt_path.exists():
srt_path.unlink()
audio_chunks = extract_audio_and_chunk_smart(str(video_path_obj), temp_dir)
jobs = [(path, s, e, idx) for idx, (path, s, e) in enumerate(audio_chunks)]
results = []
executor = ThreadPoolExecutor(max_workers=MAX_WORKERS)
try:
futures = {executor.submit(send_chunk_for_transcription, job): job for job in jobs}
for future in as_completed(futures):
idx, offset, segments, error = future.result()
if error:
print(f"❌ Chunk {idx} failed: {error}")
else:
print(f"✅ Chunk {idx} done: {len(segments)} segs (offset {offset:.1f}s)")
results.append((idx, offset, segments))
finally:
executor.shutdown(wait=True)
results.sort(key=lambda x: x[0])
print("📝 Writing final SRT with smart overlap dedupe...")
global_idx = 1
with open(srt_path, "w", encoding="utf-8") as srt_file:
for _, time_offset, segments in results:
for seg in segments:
actual_start = seg['start'] + time_offset
actual_end = seg['end'] + time_offset
srt_file.write(f"{global_idx}\n{format_srt_time(actual_start)} --> {format_srt_time(actual_end)}\n{seg['text'].strip()}\n\n")
global_idx += 1
print(f"\n✅ SRT saved: {srt_path} - {global_idx-1} lines")
if temp_dir.exists():
shutil.rmtree(temp_dir)
if __name__ == "__main__":
target_video = "download/videos/2813112936/2813112936.mp4"
transcribe_to_srt_remote(target_video, force=True)