Save the transcript as subtitles too, not just as text

whisper-1's verbose response carries a start and an end for every segment,
and the file tab was reading only the start, to build the [mm:ss] prefix.
Keeping the end as well is all an SRT needs.

The text stays the authority on wording and the segments on timing; they
meet at that prefix, which the cleanup model is already told to leave alone.
So a transcript that went through cleanup still turns into properly timed
subtitles. A line whose stamp matches no segment runs until the next line
starts, a line with no stamp at all joins the cue above it, and an end that
would run into the next cue is trimmed back.

The button is dead until a timestamped run finishes, because without
timestamps there are no segments to time anything with.
This commit is contained in:
yusufipk
2026-07-26 22:33:32 +07:00
parent c3f22f0794
commit 37e360743d
6 changed files with 114 additions and 20 deletions
+70 -11
View File
@@ -7,6 +7,7 @@ their timestamps shifted into place.
import contextlib
import os
import re
import shutil
import subprocess
import tempfile
@@ -21,6 +22,10 @@ from i18n import t
CHUNK_SECONDS = 600 # 10 min ≈ 19 MB at 16 kHz mono s16
CLEANUP_CHUNK_CHARS = 12000 # keep each cleanup call comfortably small
RATE = 16000
MIN_SUBTITLE_SECONDS = 1.5 # how long a cue with no end time of its own stays up
# The [mm:ss] or [h:mm:ss] prefix a timestamped line starts with.
STAMP_RE = re.compile(r"^\[(?:(\d+):)?(\d{1,2}):(\d{2})\]\s*")
class Cancelled(Exception):
@@ -29,7 +34,7 @@ class Cancelled(Exception):
class FileTranscriber(QObject):
progress = pyqtSignal(str)
finished = pyqtSignal(str)
finished = pyqtSignal(str, list) # text, [(start, end, text)] when timestamped
failed = pyqtSignal(str)
def __init__(self, conf, parent=None):
@@ -76,22 +81,24 @@ class FileTranscriber(QObject):
target = conf.transcribe_target()
pieces = []
segments = []
for index, (chunk_path, offset) in enumerate(chunks, start=1):
self._check()
self.progress.emit(
t("Transcribing chunk {index}/{count}", index=index, count=len(chunks))
)
if timestamps:
segments = api.transcribe_segments(
target,
chunk_path,
language=conf["language"],
prompt=conf["transcribe_prompt"],
)
pieces.extend(
f"[{format_timestamp(start + offset)}] {text}"
for start, text in segments
segments.extend(
(start + offset, end + offset, line)
for start, end, line in api.transcribe_segments(
target,
chunk_path,
language=conf["language"],
prompt=conf["transcribe_prompt"],
)
)
pieces = [f"[{format_timestamp(start)}] {line}"
for start, _, line in segments]
else:
pieces.append(api.transcribe(
target,
@@ -107,7 +114,7 @@ class FileTranscriber(QObject):
self.progress.emit(t("Cleaning up…"))
text = self._cleanup(text, timestamps)
self.finished.emit(text)
self.finished.emit(text, segments)
except Cancelled:
self.progress.emit(t("Stopped."))
@@ -140,6 +147,58 @@ def format_timestamp(seconds):
return f"{hours}:{minutes:02d}:{secs:02d}" if hours else f"{minutes:02d}:{secs:02d}"
def srt_timestamp(seconds):
millis = int(round(max(seconds, 0.0) * 1000))
hours, rest = divmod(millis, 3600000)
minutes, rest = divmod(rest, 60000)
secs, millis = divmod(rest, 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
def to_srt(text, segments):
"""Turn the timestamped transcript into SRT cues.
The text is the authority on wording, so cleanup edits survive; the segments
are the authority on timing. They meet at the [mm:ss] prefix, which cleanup
is told to leave alone: a line's whole-second stamp finds the segment it came
from, and with it the fractional start and the end time whisper reported. A
line whose stamp finds nothing runs until the next line starts.
"""
cues = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
match = STAMP_RE.match(line)
body = line[match.end():].strip() if match else line
if not match:
if cues and body: # a wrapped line belongs to the cue above it
cues[-1][2] += " " + body
continue
if not body:
continue
hours, minutes, secs = (int(g or 0) for g in match.groups())
cues.append([hours * 3600 + minutes * 60 + secs, None, body])
timing = {}
for start, end, _ in segments:
timing.setdefault(int(start), (start, end))
for cue in cues:
cue[0], cue[1] = timing.get(cue[0], (float(cue[0]), 0.0))
for index, cue in enumerate(cues):
following = cues[index + 1][0] if index + 1 < len(cues) else 0.0
if following > cue[0]:
cue[1] = min(cue[1], following) if cue[1] > cue[0] else following
elif cue[1] <= cue[0]:
cue[1] = cue[0] + MIN_SUBTITLE_SECONDS
blocks = [
f"{number}\n{srt_timestamp(start)} --> {srt_timestamp(end)}\n{body}"
for number, (start, end, body) in enumerate(cues, start=1)
]
return "\n\n".join(blocks) + "\n" if blocks else ""
def _to_wav(path, workdir):
out = os.path.join(workdir, "audio.wav")
res = subprocess.run(