diff --git a/README.md b/README.md index 7f84f7e..5e0ff9a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Dikte -Press `Ctrl+Space`, talk, press again. The recording goes to OpenAI for -transcription, a model on OpenRouter cleans it up (dropping the *uh*s, the +Press `Ctrl+Space`, talk, press again. The recording goes to OpenAI or OpenRouter +for transcription, a model on OpenRouter cleans it up (dropping the *uh*s, the restarts, the missing punctuation), and the result lands in your clipboard and is pasted into whatever window you were typing in. @@ -32,12 +32,12 @@ dikte # the settings window opens on first run `install.sh` adds the `dikte` command, a menu entry, an autostart entry and the KDE shortcut. -Two keys go in the settings window: **OpenAI** for speech to text -(`gpt-4o-transcribe`) and **OpenRouter** for the cleanup -(`google/gemini-3.5-flash-lite` by default, any model on the list works). They -fall back to `OPENAI_API_KEY` and `OPENROUTER_API_KEY`, and are stored in -`~/.config/dikte/config.json`, mode 600. Cleanup can be switched off, in which -case the raw transcript is pasted. +Two keys go in the settings window: **OpenAI** and **OpenRouter**. Speech to text +runs on either one (`gpt-4o-transcribe` by default), cleanup always on +OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key can +cover both. They fall back to `OPENAI_API_KEY` and `OPENROUTER_API_KEY`, and are +stored in `~/.config/dikte/config.json`, mode 600. Cleanup can be switched off, +in which case the raw transcript is pasted. ## Using it @@ -99,7 +99,7 @@ needs your user in the `input` group: `sudo usermod -aG input $USER`. ``` dikte.py entry point, tray icon, state machine, IPC audio.py raw PCM capture through pw-record plus the level meter -api.py OpenAI transcription and OpenRouter cleanup (stdlib only) +api.py transcription on either provider, OpenRouter cleanup (stdlib only) worker.py transcribe → clean up → clipboard → paste vad.py deciding whether a recording holds speech at all filetranscribe.py file transcription: ffmpeg, chunking, timestamps diff --git a/README.tr.md b/README.tr.md index c7d5213..534bbf3 100644 --- a/README.tr.md +++ b/README.tr.md @@ -1,8 +1,9 @@ # Dikte -`Ctrl+Space`'e bas, konuş, tekrar bas. Ses OpenAI'ye gidip yazıya çevrilir, -OpenRouter'daki bir model transkripti temizler (ıı'lar, tekrarlar, eksik -noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır. +`Ctrl+Space`'e bas, konuş, tekrar bas. Ses OpenAI'ye ya da OpenRouter'a gidip +yazıya çevrilir, OpenRouter'daki bir model transkripti temizler (ıı'lar, +tekrarlar, eksik noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye +yapıştırılır. KDE Plasma 6 / Wayland için yazıldı. Sistem paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi ve PyQt6. @@ -31,12 +32,13 @@ dikte # ilk açılışta ayarlar penceresi gelir `install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik başlatmayı ve KDE kısayolunu kurar. -Ayarlar penceresinde iki anahtar istenir: sesi yazıya çeviren **OpenAI** -(`gpt-4o-transcribe`) ve transkripti temizleyen **OpenRouter** (varsayılan -`google/gemini-3.5-flash-lite`, listedeki her model çalışır). Boş bırakırsan -`OPENAI_API_KEY` ve `OPENROUTER_API_KEY` kullanılır; anahtarlar -`~/.config/dikte/config.json` içinde, izinler 600. Temizlemeyi tamamen -kapatabilirsin, o zaman ham transkript yapıştırılır. +Ayarlar penceresinde iki anahtar istenir: **OpenAI** ve **OpenRouter**. Sesi +yazıya çevirme ikisinden birinde çalışır (varsayılan `gpt-4o-transcribe`), +temizleme her zaman OpenRouter'da (`google/gemini-3.5-flash-lite`), yani tek bir +OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY` ve +`OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json` +içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript +yapıştırılır. ## Kullanım @@ -98,7 +100,7 @@ grubunda olmasını gerektirir: `sudo usermod -aG input $USER`. ``` dikte.py giriş noktası, tepsi simgesi, durum makinesi, IPC audio.py pw-record ile ham PCM kaydı ve seviye ölçer -api.py OpenAI transkript + OpenRouter temizleme (yalnız stdlib) +api.py iki sağlayıcıda transkript + OpenRouter temizleme (yalnız stdlib) worker.py transkript → temizleme → pano → yapıştırma vad.py kayıtta gerçekten konuşma var mı kararı filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları diff --git a/api.py b/api.py index fb765b9..d7624b7 100644 --- a/api.py +++ b/api.py @@ -1,5 +1,12 @@ -"""OpenAI (transcription) and OpenRouter (cleanup) calls, stdlib only.""" +"""OpenAI and OpenRouter calls, stdlib only. +Transcription runs on either provider: OpenRouter mirrors OpenAI's +/audio/transcriptions endpoint field for field, so one multipart request serves +both and only the key, the base URL and the model id change. Cleanup is always +OpenRouter. +""" + +import collections import json import mimetypes import os @@ -9,11 +16,20 @@ import urllib.request from i18n import t -USER_AGENT = "dikte/1.0 (+https://github.com/yusufipk/dikte)" +APP_URL = "https://github.com/yusufipk/dikte" +USER_AGENT = f"dikte/1.0 (+{APP_URL})" +OPENAI_URL = "https://api.openai.com/v1" OPENROUTER_URL = "https://openrouter.ai/api/v1" -# Only whisper-1 returns segment-level timestamps. -TIMESTAMP_MODEL = "whisper-1" +# Where a transcription request goes; built by config.Config.transcribe_target(). +# `service` is the name the user sees in an error, `provider` the one the code +# branches on. +Target = collections.namedtuple("Target", "provider service api_key base_url model") + + +def timestamp_model(provider): + """Only whisper-1 returns segment times, and OpenRouter namespaces the id.""" + return "openai/whisper-1" if provider == "openrouter" else "whisper-1" class ApiError(Exception): @@ -89,35 +105,44 @@ def _multipart(fields, file_field, file_path): return bytes(out), f"multipart/form-data; boundary={boundary}" -def _transcribe_request(wav_path, api_key, model, language, prompt, base_url, - response_format, granularity=None, timeout=300): - if not api_key: - raise ApiError(t("OpenAI API key is empty. Add it in Settings.")) - fields = [("model", model), ("response_format", response_format)] +def _headers(provider, api_key, content_type=None): + headers = {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT} + if content_type: + headers["Content-Type"] = content_type + if provider == "openrouter": + # What OpenRouter attributes the calls to on its app leaderboard. + headers["HTTP-Referer"] = APP_URL + headers["X-Title"] = "Dikte" + return headers + + +def _transcribe_request(target, wav_path, language, prompt, response_format, + granularity=None, timeout=300): + if not target.api_key: + raise ApiError(t("{service} API key is empty. Add it in Settings.", + service=target.service)) + fields = [("model", target.model), ("response_format", response_format)] if language and language != "auto": fields.append(("language", language)) - if prompt: + # OpenRouter takes the hint field and throws it away, so spare it the bytes. + # The same words still reach the cleanup model as a glossary. + if prompt and target.provider == "openai": fields.append(("prompt", prompt)) if granularity: fields.append(("timestamp_granularities[]", granularity)) body, ctype = _multipart(fields, "file", wav_path) - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": ctype, - "User-Agent": USER_AGENT, - } try: return _request( - f"{base_url.rstrip('/')}/audio/transcriptions", body, headers, timeout=timeout + f"{target.base_url.rstrip('/')}/audio/transcriptions", body, + _headers(target.provider, target.api_key, ctype), timeout=timeout, ) except ApiError as exc: - raise explain(exc, "OpenAI") from None + raise explain(exc, target.service) from None -def transcribe(wav_path, api_key, model="gpt-4o-transcribe", language="", prompt="", - base_url="https://api.openai.com/v1", timeout=300): +def transcribe(target, wav_path, language="", prompt="", timeout=300): data = _transcribe_request( - wav_path, api_key, model, language, prompt, base_url, "json", timeout=timeout + target, wav_path, language, prompt, "json", timeout=timeout ) text = (data.get("text") or "").strip() if not text: @@ -125,12 +150,12 @@ def transcribe(wav_path, api_key, model="gpt-4o-transcribe", language="", prompt return text -def transcribe_segments(wav_path, api_key, language="", prompt="", - base_url="https://api.openai.com/v1", timeout=300): +def transcribe_segments(target, wav_path, language="", prompt="", timeout=300): """[(start_seconds, text)] using whisper-1's verbose response.""" data = _transcribe_request( - wav_path, api_key, TIMESTAMP_MODEL, language, prompt, base_url, - "verbose_json", granularity="segment", timeout=timeout, + target._replace(model=timestamp_model(target.provider)), + wav_path, language, prompt, "verbose_json", + granularity="segment", timeout=timeout, ) segments = data.get("segments") or [] out = [] @@ -148,7 +173,8 @@ def transcribe_segments(wav_path, api_key, language="", prompt="", def cleanup(text, api_key, model, system_prompt, base_url=OPENROUTER_URL, timeout=180): if not api_key: - raise ApiError(t("OpenRouter API key is empty. Add it in Settings.")) + raise ApiError(t("{service} API key is empty. Add it in Settings.", + service="OpenRouter")) payload = { "model": model, "temperature": 0, @@ -157,18 +183,11 @@ def cleanup(text, api_key, model, system_prompt, base_url=OPENROUTER_URL, timeou {"role": "user", "content": f"\n{text}\n"}, ], } - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - "User-Agent": USER_AGENT, - "HTTP-Referer": "https://github.com/yusufipk/dikte", - "X-Title": "Dikte", - } try: data = _request( f"{base_url.rstrip('/')}/chat/completions", json.dumps(payload).encode("utf-8"), - headers, + _headers("openrouter", api_key, "application/json"), timeout=timeout, ) except ApiError as exc: @@ -199,7 +218,8 @@ def _get_json(url, headers, timeout=20): def openrouter_key_status(api_key): """Check the key against OpenRouter's own /key endpoint.""" if not api_key: - raise ApiError(t("OpenRouter API key is empty. Add it in Settings.")) + raise ApiError(t("{service} API key is empty. Add it in Settings.", + service="OpenRouter")) try: data = _get_json(f"{OPENROUTER_URL}/key", {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT}) @@ -213,18 +233,32 @@ def openrouter_key_status(api_key): usage=round(float(usage or 0), 3), limit=round(float(limit), 3)) -def openrouter_models(api_key=""): - """Model ids available on OpenRouter (no key required).""" +def openrouter_models(api_key="", transcription=False): + """Model ids available on OpenRouter (no key required). + + `transcription` narrows the list to the speech-to-text models, the only ones + /audio/transcriptions accepts. The filter is applied again on the result, + because a query parameter the API stops honouring would otherwise quietly + hand back all several hundred models. + """ + url = f"{OPENROUTER_URL}/models" + if transcription: + url += "?output_modalities=transcription" headers = {"User-Agent": USER_AGENT} if api_key: headers["Authorization"] = f"Bearer {api_key}" - data = _get_json(f"{OPENROUTER_URL}/models", headers) - return sorted(m["id"] for m in data.get("data", []) if m.get("id")) + models = _get_json(url, headers).get("data", []) + if transcription: + models = [m for m in models + if "transcription" in (m.get("architecture") or {}).get( + "output_modalities", [])] + return sorted(m["id"] for m in models if m.get("id")) -def openai_models(api_key, base_url="https://api.openai.com/v1"): +def openai_models(api_key, base_url=OPENAI_URL): if not api_key: - raise ApiError(t("OpenAI API key is empty. Add it in Settings.")) + raise ApiError(t("{service} API key is empty. Add it in Settings.", + service="OpenAI")) try: data = _get_json( f"{base_url.rstrip('/')}/models", diff --git a/config.py b/config.py index 594cc76..40d11cf 100644 --- a/config.py +++ b/config.py @@ -5,6 +5,7 @@ import json import os import pathlib +import api import i18n @@ -92,7 +93,9 @@ DEFAULTS = { "openai_base_url": "https://api.openai.com/v1", "openrouter_api_key": "", "openrouter_base_url": "https://openrouter.ai/api/v1", - "transcribe_model": "gpt-4o-transcribe", + "transcribe_provider": "openai", # openai | openrouter + "transcribe_model": "gpt-4o-transcribe", # used when provider is openai + "openrouter_transcribe_model": "openai/gpt-4o-transcribe", "language": "tr", "transcribe_prompt": "", "cleanup_enabled": True, @@ -184,6 +187,15 @@ class Config: def openrouter_key(self): return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip() + def transcribe_target(self): + """Key, endpoint and model for whichever provider does speech to text.""" + if self["transcribe_provider"] == "openrouter": + return api.Target("openrouter", "OpenRouter", self.openrouter_key(), + self["openrouter_base_url"], + self["openrouter_transcribe_model"]) + return api.Target("openai", "OpenAI", self.openai_key(), + self["openai_base_url"], self["transcribe_model"]) + def cleanup_prompt(self, with_timestamps=False): turkish = i18n.language() == "tr" prompt = self["cleanup_prompt"].strip() or default_cleanup_prompt() diff --git a/dikte.py b/dikte.py index 1c9da2f..2af09a2 100755 --- a/dikte.py +++ b/dikte.py @@ -320,7 +320,9 @@ def main(): server.newConnection.connect(on_connection) app.aboutToQuit.connect(dikte.shutdown) - if command == "settings" or not dikte.conf.openai_key(): + # No key for the chosen transcription provider means nothing can work yet, + # so the settings window is the only useful thing to open. + if command == "settings" or not dikte.conf.transcribe_target().api_key: dikte.open_settings() elif command == "toggle": QTimer.singleShot(0, dikte.toggle) diff --git a/filetranscribe.py b/filetranscribe.py index 6ae43ff..7754e3c 100644 --- a/filetranscribe.py +++ b/filetranscribe.py @@ -74,6 +74,7 @@ class FileTranscriber(QObject): if len(chunks) > 1: self.progress.emit(t("Splitting into {count} chunks…", count=len(chunks))) + target = conf.transcribe_target() pieces = [] for index, (chunk_path, offset) in enumerate(chunks, start=1): self._check() @@ -82,11 +83,10 @@ class FileTranscriber(QObject): ) if timestamps: segments = api.transcribe_segments( + target, chunk_path, - conf.openai_key(), language=conf["language"], prompt=conf["transcribe_prompt"], - base_url=conf["openai_base_url"], ) pieces.extend( f"[{format_timestamp(start + offset)}] {text}" @@ -94,12 +94,10 @@ class FileTranscriber(QObject): ) else: pieces.append(api.transcribe( + target, chunk_path, - conf.openai_key(), - model=conf["transcribe_model"], language=conf["language"], prompt=conf["transcribe_prompt"], - base_url=conf["openai_base_url"], )) text = "\n".join(pieces) if timestamps else " ".join(pieces) diff --git a/i18n.py b/i18n.py index 3c88fe2..5b18e97 100644 --- a/i18n.py +++ b/i18n.py @@ -93,10 +93,8 @@ TR = { "ydotool hatası: {error}\nydotoold çalışıyor mu? (systemctl --user status ydotool)", # --- api errors ---------------------------------------------------- - "OpenAI API key is empty. Add it in Settings.": - "OpenAI API anahtarı boş. Ayarlar'dan gir.", - "OpenRouter API key is empty. Add it in Settings.": - "OpenRouter API anahtarı boş. Ayarlar'dan gir.", + "{service} API key is empty. Add it in Settings.": + "{service} API anahtarı boş. Ayarlar'dan gir.", "Transcript came back empty.": "Transkript boş döndü.", "The cleanup model returned an empty reply.": "Temizleme modeli boş yanıt döndü.", "Could not connect: {reason}": "Bağlantı kurulamadı: {reason}", @@ -146,14 +144,23 @@ TR = { "Ses kayıtlarını sakla (~/.local/share/dikte/recordings)", # --- settings: api -------------------------------------------------- - "OpenAI: speech to text": "OpenAI: sesi yazıya çevirme", - "OpenRouter: transcript cleanup": "OpenRouter: transkripti temizleme", + "Keys": "Anahtarlar", + "Speech to text": "Sesi yazıya çevirme", + "Transcript cleanup": "Transkripti temizleme", "API key": "API anahtarı", "Model": "Model", + "Provider": "Sağlayıcı", "sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)", "sk-or-… (falls back to OPENROUTER_API_KEY)": "sk-or-… (boşsa OPENROUTER_API_KEY kullanılır)", - "Test key": "Anahtarı test et", + "Test": "Test et", "Trying…": "Deneniyor…", + "Runs on OpenAI, with the key above.": + "Yukarıdaki anahtarla OpenAI üzerinde çalışır.", + "Runs on OpenRouter, with the key above. Word hints reach the cleanup " + "model only.": + "Yukarıdaki anahtarla OpenRouter üzerinde çalışır. Kelime ipuçları " + "yalnızca temizleme modeline gider.", + "Runs on OpenRouter.": "OpenRouter üzerinde çalışır.", "Connection works. {count} audio models visible.": "Bağlantı tamam. {count} ses modeli görünüyor.", "Clean the transcript with a model": "Transkripti bir modelle temizle", @@ -187,10 +194,10 @@ TR = { "Audio and video files": "Ses ve video dosyaları", "All files": "Tüm dosyalar", "Add timestamps": "Zaman damgası ekle", - "Prefixes every segment with [mm:ss]. Uses whisper-1, the only model that " - "returns segment times.": + "Prefixes every segment with [mm:ss]. Uses whisper-1 on whichever provider " + "you picked, the only model that returns segment times.": "Her bölümün başına [dd:ss] koyar. Bölüm zamanı döndüren tek model olan " - "whisper-1 kullanılır.", + "whisper-1, seçtiğin sağlayıcı üzerinden kullanılır.", "Run the cleanup model afterwards": "Sonrasında temizleme modelinden geçir", "Transcribe": "Yazıya çevir", "Stop": "Durdur", diff --git a/settings_ui.py b/settings_ui.py index e4dee80..fef12e5 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -25,7 +25,18 @@ LANGUAGES = [ ("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"), ] CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"] -TRANSCRIBE_MODELS = ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"] +TRANSCRIBE_PROVIDERS = [("OpenAI", "openai"), ("OpenRouter", "openrouter")] +# Starting points for the model box; "Fetch model list" replaces them with +# whatever the provider offers today. +TRANSCRIBE_MODELS = { + "openai": ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"], + "openrouter": [ + "openai/gpt-4o-transcribe", "openai/gpt-4o-mini-transcribe", + "openai/whisper-1", "openai/whisper-large-v3", + "openai/whisper-large-v3-turbo", "mistralai/voxtral-mini-transcribe", + "deepgram/nova-3", "google/chirp-3", + ], +} CLEANUP_MODELS = [ "google/gemini-3.5-flash-lite", "google/gemini-3.1-flash-lite", "google/gemini-2.5-flash-lite", "anthropic/claude-haiku-4.5", @@ -40,6 +51,7 @@ class SettingsWindow(QDialog): applied = pyqtSignal() _models_loaded = pyqtSignal(list, str) + _transcribe_models_loaded = pyqtSignal(list, str) _test_done = pyqtSignal(bool, str) _or_test_done = pyqtSignal(bool, str) @@ -47,6 +59,10 @@ class SettingsWindow(QDialog): super().__init__(parent) self.conf = conf self.launch_command = launch_command + # Each provider keeps its own transcription model, so switching the + # provider back and forth never overwrites the other one's. + self._models = {"openai": "", "openrouter": ""} + self._shown_provider = "" self.transcriber = FileTranscriber(conf, self) self.setWindowTitle(t("Dikte Settings")) self.resize(680, 640) @@ -71,6 +87,7 @@ class SettingsWindow(QDialog): layout.addWidget(buttons) self._models_loaded.connect(self._on_models_loaded) + self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded) self._test_done.connect(self._on_test_done) self._or_test_done.connect(self._on_or_test_done) self.transcriber.progress.connect(self._on_file_progress) @@ -156,56 +173,69 @@ class SettingsWindow(QDialog): page = QWidget() outer = QVBoxLayout(page) - oai = QGroupBox(t("OpenAI: speech to text")) - oai_form = QFormLayout(oai) + # Keys first, then the two jobs, because OpenRouter can now do both of + # them and a key no longer belongs to a single job. + keys = QGroupBox(t("Keys")) + keys_form = QFormLayout(keys) self.openai_key = QLineEdit() self.openai_key.setEchoMode(QLineEdit.EchoMode.Password) self.openai_key.setPlaceholderText(t("sk-… (falls back to OPENAI_API_KEY)")) - oai_form.addRow(t("API key"), self.openai_key) - - self.transcribe_model = QComboBox() - self.transcribe_model.setEditable(True) - self.transcribe_model.addItems(TRANSCRIBE_MODELS) - oai_form.addRow(t("Model"), self.transcribe_model) - - self.test_button = QPushButton(t("Test key")) + self.test_button = QPushButton(t("Test")) self.test_button.clicked.connect(self._test_openai) self.test_label = QLabel("") self.test_label.setWordWrap(True) - row = QHBoxLayout() - row.addWidget(self.test_button) - row.addWidget(self.test_label, 1) - oai_form.addRow("", self._wrap(row)) - outer.addWidget(oai) - - orr = QGroupBox(t("OpenRouter: transcript cleanup")) - orr_form = QFormLayout(orr) - self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model")) - orr_form.addRow("", self.cleanup_enabled) + keys_form.addRow("OpenAI", self._row(self.openai_key, self.test_button)) + keys_form.addRow("", self.test_label) self.openrouter_key = QLineEdit() self.openrouter_key.setEchoMode(QLineEdit.EchoMode.Password) self.openrouter_key.setPlaceholderText(t("sk-or-… (falls back to OPENROUTER_API_KEY)")) - orr_form.addRow(t("API key"), self.openrouter_key) + self.or_test_button = QPushButton(t("Test")) + self.or_test_button.clicked.connect(self._test_openrouter) + self.or_test_label = QLabel("") + self.or_test_label.setWordWrap(True) + keys_form.addRow("OpenRouter", self._row(self.openrouter_key, self.or_test_button)) + keys_form.addRow("", self.or_test_label) + outer.addWidget(keys) + + stt = QGroupBox(t("Speech to text")) + stt_form = QFormLayout(stt) + self.transcribe_provider = QComboBox() + for label, value in TRANSCRIBE_PROVIDERS: + self.transcribe_provider.addItem(label, value) + stt_form.addRow(t("Provider"), self.transcribe_provider) + + self.transcribe_model = QComboBox() + self.transcribe_model.setEditable(True) + self.refresh_transcribe_models = QPushButton(t("Fetch model list")) + self.refresh_transcribe_models.clicked.connect(self._load_transcribe_models) + stt_form.addRow(t("Model"), + self._row(self.transcribe_model, self.refresh_transcribe_models)) + # Spanning rows: in the narrow field column a wrapped label gets a height + # that fits one line, and the rest of the text is cut off. + self.transcribe_note = QLabel("") + self.transcribe_note.setWordWrap(True) + stt_form.addRow(self.transcribe_note) + self.transcribe_status = QLabel("") + self.transcribe_status.setWordWrap(True) + stt_form.addRow(self.transcribe_status) + self.transcribe_provider.currentIndexChanged.connect(self._provider_changed) + outer.addWidget(stt) + + orr = QGroupBox(t("Transcript cleanup")) + orr_form = QFormLayout(orr) + self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model")) + orr_form.addRow("", self.cleanup_enabled) self.cleanup_model = QComboBox() self.cleanup_model.setEditable(True) self.cleanup_model.addItems(CLEANUP_MODELS) self.refresh_models = QPushButton(t("Fetch model list")) self.refresh_models.clicked.connect(self._load_models) - model_row = QHBoxLayout() - model_row.addWidget(self.cleanup_model, 1) - model_row.addWidget(self.refresh_models) - orr_form.addRow(t("Model"), self._wrap(model_row)) - - self.or_test_button = QPushButton(t("Test key")) - self.or_test_button.clicked.connect(self._test_openrouter) - self.models_label = QLabel("") + orr_form.addRow(t("Model"), self._row(self.cleanup_model, self.refresh_models)) + self.models_label = QLabel(t("Runs on OpenRouter.")) self.models_label.setWordWrap(True) - test_row = QHBoxLayout() - test_row.addWidget(self.or_test_button) - test_row.addWidget(self.models_label, 1) - orr_form.addRow("", self._wrap(test_row)) + orr_form.addRow(self.models_label) outer.addWidget(orr) outer.addStretch(1) return page @@ -255,8 +285,8 @@ class SettingsWindow(QDialog): self.file_timestamps = QCheckBox(t("Add timestamps")) self.file_timestamps.setToolTip( - t("Prefixes every segment with [mm:ss]. Uses whisper-1, the only model " - "that returns segment times.") + t("Prefixes every segment with [mm:ss]. Uses whisper-1 on whichever " + "provider you picked, the only model that returns segment times.") ) layout.addWidget(self.file_timestamps) @@ -385,10 +415,15 @@ class SettingsWindow(QDialog): return page @staticmethod - def _wrap(layout): - widget = QWidget() - widget.setLayout(layout) - return widget + def _row(*widgets): + """Widgets side by side in one form row; the first one takes the space.""" + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + for index, widget in enumerate(widgets): + layout.addWidget(widget, 1 if index == 0 else 0) + holder = QWidget() + holder.setLayout(layout) + return holder # ---- load / save ---------------------------------------------------- @@ -408,9 +443,13 @@ class SettingsWindow(QDialog): self.keep_audio.setChecked(conf["keep_audio"]) self.openai_key.setText(conf["openai_api_key"]) - self.transcribe_model.setCurrentText(conf["transcribe_model"]) - self.cleanup_enabled.setChecked(conf["cleanup_enabled"]) self.openrouter_key.setText(conf["openrouter_api_key"]) + self._models = {"openai": conf["transcribe_model"], + "openrouter": conf["openrouter_transcribe_model"]} + self._shown_provider = "" + self._select_data(self.transcribe_provider, conf["transcribe_provider"]) + self._provider_changed() # selecting index 0 fires no signal + self.cleanup_enabled.setChecked(conf["cleanup_enabled"]) self.cleanup_model.setCurrentText(conf["cleanup_model"]) self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt()) self.transcribe_prompt.setPlainText(conf["transcribe_prompt"]) @@ -443,9 +482,16 @@ class SettingsWindow(QDialog): conf["keep_audio"] = self.keep_audio.isChecked() conf["openai_api_key"] = self.openai_key.text().strip() - conf["transcribe_model"] = self.transcribe_model.currentText().strip() - conf["cleanup_enabled"] = self.cleanup_enabled.isChecked() conf["openrouter_api_key"] = self.openrouter_key.text().strip() + + provider = self.transcribe_provider.currentData() or "openai" + self._models[provider] = self.transcribe_model.currentText().strip() + conf["transcribe_provider"] = provider + for key, name in (("openai", "transcribe_model"), + ("openrouter", "openrouter_transcribe_model")): + conf[name] = self._models[key].strip() or cfg.DEFAULTS[name] + + conf["cleanup_enabled"] = self.cleanup_enabled.isChecked() conf["cleanup_model"] = self.cleanup_model.currentText().strip() # Store an empty prompt when it matches the default, so switching the @@ -477,6 +523,54 @@ class SettingsWindow(QDialog): # ---- api helpers ----------------------------------------------------- + def _provider_changed(self): + """Swap the model box over to the newly chosen provider's own model.""" + if self._shown_provider: + self._models[self._shown_provider] = self.transcribe_model.currentText().strip() + provider = self.transcribe_provider.currentData() or "openai" + self._shown_provider = provider + self.transcribe_model.clear() + self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider]) + self.transcribe_model.setCurrentText(self._models[provider]) + self.transcribe_status.setText("") + self.transcribe_note.setText( + t("Runs on OpenRouter, with the key above. Word hints reach the " + "cleanup model only.") + if provider == "openrouter" else + t("Runs on OpenAI, with the key above.") + ) + + def _load_transcribe_models(self): + """The model list of whichever provider is selected.""" + provider = self.transcribe_provider.currentData() or "openai" + self.refresh_transcribe_models.setEnabled(False) + self.transcribe_status.setText(t("Fetching model list…")) + openai_key = self.openai_key.text().strip() or self.conf.openai_key() + openrouter_key = self.openrouter_key.text().strip() or self.conf.openrouter_key() + base = self.conf["openai_base_url"] + + def work(): + try: + models = (api.openrouter_models(openrouter_key, transcription=True) + if provider == "openrouter" + else api.openai_models(openai_key, base)) + self._transcribe_models_loaded.emit(models, "") + except api.ApiError as exc: + self._transcribe_models_loaded.emit([], str(exc)) + + threading.Thread(target=work, daemon=True).start() + + def _on_transcribe_models_loaded(self, models, error): + self.refresh_transcribe_models.setEnabled(True) + if error: + self.transcribe_status.setText(t("Could not fetch the list: {error}", error=error)) + return + current = self.transcribe_model.currentText() + self.transcribe_model.clear() + self.transcribe_model.addItems(models) + self.transcribe_model.setCurrentText(current) + self.transcribe_status.setText(t("{count} models loaded.", count=len(models))) + def _load_models(self): self.refresh_models.setEnabled(False) self.models_label.setText(t("Fetching model list…")) @@ -520,7 +614,7 @@ class SettingsWindow(QDialog): def _test_openrouter(self): self.or_test_button.setEnabled(False) - self.models_label.setText(t("Trying…")) + self.or_test_label.setText(t("Trying…")) key = self.openrouter_key.text().strip() or self.conf.openrouter_key() def work(): @@ -533,7 +627,7 @@ class SettingsWindow(QDialog): def _on_or_test_done(self, ok, message): self.or_test_button.setEnabled(True) - self.models_label.setText(("✓ " if ok else "✗ ") + message) + self.or_test_label.setText(("✓ " if ok else "✗ ") + message) def _on_test_done(self, ok, message): self.test_button.setEnabled(True) diff --git a/worker.py b/worker.py index 5bbc8c2..d7ec483 100644 --- a/worker.py +++ b/worker.py @@ -60,13 +60,12 @@ class Pipeline(QObject): try: self.stage.emit(t("Transcribing…")) + target = conf.transcribe_target() raw = api.transcribe( + target, wav_path, - conf.openai_key(), - model=conf["transcribe_model"], language=conf["language"], prompt=conf["transcribe_prompt"], - base_url=conf["openai_base_url"], ) if conf["filter_hallucinations"] and vad.looks_like_hallucination(raw, duration): @@ -107,7 +106,7 @@ class Pipeline(QObject): "ts": time.strftime("%Y-%m-%d %H:%M:%S"), "duration": round(duration, 1), "elapsed": round(time.monotonic() - started, 1), - "model": conf["transcribe_model"], + "model": target.model, "cleanup_model": conf["cleanup_model"] if conf["cleanup_enabled"] else "", "cleanup_error": warning, "raw": raw,