mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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:
+70
-11
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user