created remote transcriber
This commit is contained in:
1 parent
856ab439f7
commit
38e0cc6764
10 files changed
+718
-68
No files matched your search
@@ -0,0 +1,173 @@
|
||||
#!/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)
|
||||
Reference in new issue
Block a user