diff --git a/README.md b/README.md index 5e0ff9a..9b9ceae 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.tr.md b/README.tr.md index 534bbf3..83018c9 100644 --- a/README.tr.md +++ b/README.tr.md @@ -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. diff --git a/api.py b/api.py index d7624b7..246cc8a 100644 --- a/api.py +++ b/api.py @@ -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 diff --git a/filetranscribe.py b/filetranscribe.py index 7754e3c..00c58e1 100644 --- a/filetranscribe.py +++ b/filetranscribe.py @@ -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( diff --git a/i18n.py b/i18n.py index 0750885..6670b38 100644 --- a/i18n.py +++ b/i18n.py @@ -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…", diff --git a/settings_ui.py b/settings_ui.py index 30c01d7..dd1a7fb 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -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)