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
+2 -1
View File
@@ -80,7 +80,8 @@ elapsed time, then the stage it is on. It never takes focus. Pressing
dictation is not lost, but the indicator turns amber with the reason instead of
looking like a normal run.
- **Audio and video files** run through the same models under Settings → Audio
file, optionally with `[mm:ss]` timestamps, chunked through ffmpeg when long.
file, optionally with `[mm:ss]` timestamps, chunked through ffmpeg when long,
and saved as `.txt` or as `.srt` subtitles.
- **History** of every dictation under Settings → History, with a size limit and
right-click to delete.
- **Turkish and English interface**, following the system locale by default.
+1 -1
View File
@@ -81,7 +81,7 @@ süreyi, ardından hangi aşamada olduğunu gösterir. Odak almaz. Dikte çalı
normal bir çalışma gibi görünmez.
- **Ses ve video dosyaları** Ayarlar → Ses dosyası sekmesinde aynı modellerden
geçer; istersen `[dd:ss]` zaman damgalarıyla, uzun dosyalar ffmpeg ile
parçalanarak.
parçalanarak, sonuç `.txt` ya da `.srt` altyazı olarak kaydedilerek.
- **Geçmiş** Ayarlar → Geçmiş sekmesinde; boyut sınırı var, sağ tıklayıp
silebilirsin.
- **Türkçe ve İngilizce arayüz**, varsayılan olarak sistem dilini izler.
+5 -3
View File
@@ -151,7 +151,7 @@ def transcribe(target, wav_path, language="", prompt="", timeout=300):
def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
"""[(start_seconds, text)] using whisper-1's verbose response."""
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
data = _transcribe_request(
target._replace(model=timestamp_model(target.provider)),
wav_path, language, prompt, "verbose_json",
@@ -162,12 +162,14 @@ def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
for seg in segments:
text = (seg.get("text") or "").strip()
if text:
out.append((float(seg.get("start") or 0.0), text))
start = float(seg.get("start") or 0.0)
end = float(seg.get("end") or 0.0)
out.append((start, max(end, start), text))
if not out:
text = (data.get("text") or "").strip()
if not text:
raise ApiError(t("Transcript came back empty."))
out = [(0.0, text)]
out = [(0.0, 0.0, text)]
return out
+65 -6
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(
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.extend(
f"[{format_timestamp(start + offset)}] {text}"
for start, text in segments
)
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(
+7
View File
@@ -197,8 +197,15 @@ TR = {
"Stop": "Durdur",
"Copy": "Panoya kopyala",
"Save as .txt": "'.txt' olarak kaydet",
"Save as .srt": "'.srt' olarak kaydet",
"Subtitles, timed from the segments. Needs the timestamps option.":
"Altyazı; zamanlaması bölüm damgalarından gelir. Zaman damgası seçeneği "
"işaretliyken çalışır.",
"No timestamped lines to turn into subtitles.":
"Altyazıya çevrilecek zaman damgalı satır yok.",
"Save transcript": "Transkripti kaydet",
"Text files": "Metin dosyaları",
"Subtitle files": "Altyazı dosyaları",
"Converting audio…": "Ses dönüştürülüyor…",
"Splitting into {count} chunks…": "{count} parçaya bölünüyor…",
"Transcribing chunk {index}/{count}": "{index}/{count} parça yazıya çevriliyor…",
+29 -4
View File
@@ -15,6 +15,7 @@ from PyQt6.QtWidgets import (
import api
import audio
import config as cfg
import filetranscribe
import hotkey
from filetranscribe import FileTranscriber
from i18n import t
@@ -315,9 +316,16 @@ class SettingsWindow(QDialog):
)
save = QPushButton(t("Save as .txt"))
save.clicked.connect(self._save_transcript)
self.file_save_srt = QPushButton(t("Save as .srt"))
self.file_save_srt.setToolTip(
t("Subtitles, timed from the segments. Needs the timestamps option.")
)
self.file_save_srt.setEnabled(False)
self.file_save_srt.clicked.connect(self._save_subtitles)
out_row = QHBoxLayout()
out_row.addWidget(copy)
out_row.addWidget(save)
out_row.addWidget(self.file_save_srt)
out_row.addStretch(1)
layout.addLayout(out_row)
return page
@@ -642,6 +650,8 @@ class SettingsWindow(QDialog):
if not getattr(self, "file_path", "") or self.transcriber.busy:
return
self.file_output.clear()
self.file_segments = []
self.file_save_srt.setEnabled(False)
self.file_run.setEnabled(False)
self.file_stop.setEnabled(True)
self.transcriber.start(
@@ -655,8 +665,10 @@ class SettingsWindow(QDialog):
if message == t("Stopped."):
self._file_idle()
def _on_file_finished(self, text):
def _on_file_finished(self, text, segments):
self.file_output.setPlainText(text)
self.file_segments = segments
self.file_save_srt.setEnabled(bool(segments))
self.file_status.setText(t("Done: {chars} characters.", chars=len(text)))
self._file_idle()
@@ -669,17 +681,30 @@ class SettingsWindow(QDialog):
self.file_stop.setEnabled(False)
def _save_transcript(self):
text = self.file_output.toPlainText()
self._write_transcript(self.file_output.toPlainText(), ".txt",
f"{t('Text files')} (*.txt)")
def _save_subtitles(self):
srt = filetranscribe.to_srt(self.file_output.toPlainText(),
getattr(self, "file_segments", []))
if not srt:
self.file_status.setText(t("No timestamped lines to turn into subtitles."))
return
self._write_transcript(srt, ".srt", f"{t('Subtitle files')} (*.srt)")
def _write_transcript(self, text, suffix, file_filter):
if not text:
return
base = os.path.splitext(os.path.basename(getattr(self, "file_path", "")))[0]
start = os.path.join(self.conf["file_last_dir"] or os.path.expanduser("~"),
f"{base or 'transcript'}.txt")
f"{base or 'transcript'}{suffix}")
path, _ = QFileDialog.getSaveFileName(
self, t("Save transcript"), start, f"{t('Text files')} (*.txt)"
self, t("Save transcript"), start, file_filter
)
if not path:
return
if not path.lower().endswith(suffix):
path += suffix
try:
with open(path, "w", encoding="utf-8") as fh:
fh.write(text)