From 6e307bd8d0f223c920414c375e0b6ba0f15ad38c Mon Sep 17 00:00:00 2001 From: yusufipk Date: Wed, 2 Sep 2026 12:41:43 +0300 Subject: [PATCH] Let OpenRouter subtitles use a chosen model instead of whisper-1 A timestamped run on OpenRouter always asked openai/whisper-1 for the segments, whatever model was picked for plain transcription. Not every model there returns segment times, so the one to use is now its own setting, openrouter_subtitle_model, shown in the speech-to-text box only when OpenRouter is the provider. Empty keeps the old whisper-1 fallback. Target carries the choice as subtitle_model and timestamp_model() reads it; the other providers are unchanged. --- dikte/api.py | 32 ++++++++++++++++++++++---------- dikte/config.py | 6 +++++- dikte/i18n.py | 5 +++++ dikte/settings_ui.py | 22 ++++++++++++++++++++++ tests/test_api.py | 17 +++++++++++++++++ tests/test_config.py | 13 +++++++++++++ tests/test_ui.py | 14 ++++++++++++++ 7 files changed, 98 insertions(+), 11 deletions(-) diff --git a/dikte/api.py b/dikte/api.py index 61e0857..f74e282 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -48,22 +48,33 @@ LOCAL_TIMEOUT = 3600 # 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") +# branches on. `subtitle_model` is what a timestamped run asks for instead of +# `model`, where the two differ; empty means the provider's own whisper. +Target = collections.namedtuple( + "Target", "provider service api_key base_url model subtitle_model", + defaults=[""]) + +# What answers with segment times on OpenRouter when nothing else was chosen. +OPENROUTER_SUBTITLE_MODEL = "openai/whisper-1" -def timestamp_model(provider, selected=""): +def timestamp_model(provider, selected="", subtitle=""): """Which model answers with segment times. - OpenAI keeps them to whisper-1 and OpenRouter namespaces that id. Everything - Groq transcribes with is a whisper, so the model already chosen does it and - the fallback is only for a provider left on its default. So is everything the - local server runs, whatever the file is called, and there asking for another - model would name one it has never heard of. + OpenAI keeps them to whisper-1. Everything Groq transcribes with is a + whisper, so the model already chosen does it and the fallback is only for a + provider left on its default. So is everything the local server runs, + whatever the file is called, and there asking for another model would name + one it has never heard of. OpenRouter fronts several models that do times + and several that do not, and a request to the wrong one gets a transcript + with no segments in it, so the one to use is a setting of its own + (`subtitle`) and whisper-1 is only where that setting is left empty. """ if provider in ("groq", "local"): return selected or "whisper-large-v3-turbo" - return "openai/whisper-1" if provider == "openrouter" else "whisper-1" + if provider == "openrouter": + return subtitle or OPENROUTER_SUBTITLE_MODEL + return "whisper-1" # What a gateway in front of the model answers of its own accord: the request @@ -444,7 +455,8 @@ def transcribe_segments(target, audio_path, language="", prompt="", timeout=300, aborter=None): """[(start_seconds, end_seconds, text)] using whisper-1's verbose response.""" data = _transcribe_request( - target._replace(model=timestamp_model(target.provider, target.model)), + target._replace(model=timestamp_model(target.provider, target.model, + target.subtitle_model)), audio_path, language, prompt, "verbose_json", granularity="segment", timeout=timeout, aborter=aborter, ) diff --git a/dikte/config.py b/dikte/config.py index 9aa2228..6f96f37 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -397,6 +397,9 @@ DEFAULTS = { "transcribe_model": "gpt-4o-transcribe", # used when provider is openai "groq_transcribe_model": "whisper-large-v3-turbo", "openrouter_transcribe_model": "openai/gpt-4o-transcribe", + # What a timestamped run (subtitles) asks OpenRouter for: not every model + # there returns segment times. Empty -> openai/whisper-1. + "openrouter_subtitle_model": "", "language": "tr", "transcribe_prompt": "", @@ -669,8 +672,9 @@ class Config: # to land on rather than reading it from there. name = "openai" who = TRANSCRIBERS[name] + subtitle = self["openrouter_subtitle_model"] if name == "openrouter" else "" return api.Target(name, who.service, self.api_key(who.key), - self[who.url], self[who.model]) + self[who.url], self[who.model], subtitle.strip()) def transcribe_ready(self): """Whether speech to text could run right now, without opening Settings.""" diff --git a/dikte/i18n.py b/dikte/i18n.py index 23ff6ff..ecfa0e3 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -228,6 +228,11 @@ TR = { "Transcript cleanup": "Transkripti temizleme", "API key": "API anahtarı", "Model": "Model", + "Subtitle model": "Altyazı modeli", + "The model a timestamped run (subtitles) asks for. Not every model on " + "OpenRouter returns segment times; empty means openai/whisper-1.": + "Zaman damgalı bir çeviride (altyazı) istenen model. OpenRouter'daki her " + "model segment zamanı döndürmez; boşsa openai/whisper-1 kullanılır.", "Provider": "Sağlayıcı", "sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)", "gsk_… (falls back to GROQ_API_KEY)": "gsk_… (boşsa GROQ_API_KEY kullanılır)", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 56771f8..4cafda1 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -864,6 +864,15 @@ class SettingsWindow(QDialog): self.transcribe_model_row = self._row(self.transcribe_model, self.refresh_transcribe_models) stt_form.addRow(t("Model"), self.transcribe_model_row) + # OpenRouter only: which of its models a timestamped run asks for. + self.subtitle_model = QComboBox() + self.subtitle_model.setEditable(True) + self.subtitle_model.lineEdit().setPlaceholderText(api.OPENROUTER_SUBTITLE_MODEL) + self.subtitle_model.setToolTip( + t("The model a timestamped run (subtitles) asks for. Not every model " + "on OpenRouter returns segment times; empty means openai/whisper-1.")) + self.subtitle_model_row = self._row(self.subtitle_model) + stt_form.addRow(t("Subtitle model"), self.subtitle_model_row) # A spanning row: 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_status = QLabel("") @@ -1747,6 +1756,7 @@ class SettingsWindow(QDialog): self._shown_provider = "" self._select_data(self.transcribe_provider, conf["transcribe_provider"]) self._provider_changed() # selecting index 0 fires no signal + self.subtitle_model.setCurrentText(conf["openrouter_subtitle_model"]) self.local_gpu.setChecked(conf["local_gpu"]) self.local_preload.setChecked(conf["local_preload"]) self.local_threads.setValue(int(conf["local_threads"])) @@ -1864,6 +1874,7 @@ class SettingsWindow(QDialog): for name, who in cfg.TRANSCRIBERS.items(): conf[who.key] = self._key_fields[name].text().strip() conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model] + conf["openrouter_subtitle_model"] = self.subtitle_model.currentText().strip() conf["gemini_api_key"] = self.gemini_key.text().strip() conf["opencode_api_key"] = self.opencode_key.text().strip() conf["local_model"] = self.local_whisper.selected() @@ -2037,6 +2048,7 @@ class SettingsWindow(QDialog): self._shown_provider = provider local = provider == "local" self.stt_form.setRowVisible(self.transcribe_model_row, not local) + self.stt_form.setRowVisible(self.subtitle_model_row, provider == "openrouter") self.stt_form.setRowVisible(self.transcribe_status, not local) self.stt_form.setRowVisible(self.local_whisper, local) self.stt_form.setRowVisible(self.local_options, local) @@ -2045,8 +2057,16 @@ class SettingsWindow(QDialog): self.transcribe_model.clear() self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider]) self.transcribe_model.setCurrentText(self._models[provider]) + if provider == "openrouter": + self._fill_subtitle_models(TRANSCRIBE_MODELS[provider]) self.transcribe_status.setText("") + def _fill_subtitle_models(self, models): + current = self.subtitle_model.currentText() + self.subtitle_model.clear() + self.subtitle_model.addItems(models) + self.subtitle_model.setCurrentText(current) + def _load_transcribe_models(self): """The model list of whichever provider is selected.""" provider = self.transcribe_provider.currentData() or "openai" @@ -2075,6 +2095,8 @@ class SettingsWindow(QDialog): self.transcribe_model.clear() self.transcribe_model.addItems(models) self.transcribe_model.setCurrentText(current) + if self._shown_provider == "openrouter": + self._fill_subtitle_models(models) self.transcribe_status.setText(t("{count} models loaded.", count=len(models))) def _load_models(self): diff --git a/tests/test_api.py b/tests/test_api.py index c914017..bbc5033 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -53,6 +53,16 @@ class TimestampModel(unittest.TestCase): self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"), "whisper-1") + def test_openrouter_takes_the_subtitle_model_that_was_set(self): + self.assertEqual( + api.timestamp_model("openrouter", "openai/gpt-4o-transcribe", + "openai/whisper-large-v3"), + "openai/whisper-large-v3") + + def test_openrouter_with_no_subtitle_model_falls_back_to_whisper(self): + self.assertEqual(api.timestamp_model("openrouter", "openai/gpt-4o-transcribe", ""), + "openai/whisper-1") + class Explain(DikteTest): def error(self, status): @@ -318,6 +328,13 @@ class TranscribeSegments(DikteTest): api.transcribe_segments(OPENROUTER, self.wav) self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1") + def test_openrouter_asks_for_the_subtitle_model_when_one_is_set(self): + target = OPENROUTER._replace(subtitle_model="mistralai/voxtral-mini-transcribe") + with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls: + api.transcribe_segments(target, self.wav) + self.assertEqual(multipart_fields(calls[0])["model"], + "mistralai/voxtral-mini-transcribe") + def test_groq_stays_on_the_model_it_was_given(self): target = GROQ._replace(model="whisper-large-v3") with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls: diff --git a/tests/test_config.py b/tests/test_config.py index 2c6535f..03bca9c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -220,6 +220,19 @@ class TranscribeTarget(DikteTest): self.assertEqual(target.service, "OpenRouter") self.assertEqual(target.api_key, "sk-or-test") self.assertEqual(target.model, "openai/whisper-1") + self.assertEqual(target.subtitle_model, "") + + def test_openrouter_carries_its_subtitle_model(self): + conf = self.config(transcribe_provider="openrouter", + openrouter_api_key="sk-or-test", + openrouter_subtitle_model=" openai/whisper-large-v3 ") + self.assertEqual(conf.transcribe_target().subtitle_model, + "openai/whisper-large-v3") + + def test_only_openrouter_has_a_subtitle_model(self): + conf = self.config(transcribe_provider="openai", openai_api_key="sk-test", + openrouter_subtitle_model="openai/whisper-large-v3") + self.assertEqual(conf.transcribe_target().subtitle_model, "") def test_groq_when_it_is_picked(self): conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test", diff --git a/tests/test_ui.py b/tests/test_ui.py index 6273447..12751e8 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -504,6 +504,20 @@ class Settings(DikteTest): self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe") self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3") + def test_the_subtitle_model_is_saved_and_only_shown_for_openrouter(self): + self.write_config({"transcribe_provider": "openrouter", + "openrouter_subtitle_model": "openai/whisper-large-v3"}) + conf = cfg.Config() + window = self.window(conf) + self.assertEqual(window.subtitle_model.currentText(), "openai/whisper-large-v3") + self.assertTrue(window.stt_form.isRowVisible(window.subtitle_model_row)) + window.subtitle_model.setCurrentText(" deepgram/nova-3 ") + window._save() + self.assertEqual(conf["openrouter_subtitle_model"], "deepgram/nova-3") + window.transcribe_provider.setCurrentIndex( + window.transcribe_provider.findData("openai")) + self.assertFalse(window.stt_form.isRowVisible(window.subtitle_model_row)) + def test_the_provider_box_offers_every_provider_config_knows(self): window = self.window(cfg.Config()) offered = [window.transcribe_provider.itemData(i)