Make auto the default speech language and carry what was detected through

New installs start detecting instead of being locked to one language; a stored
value from before this default still wins. The dictation chain asks
transcribe_detected() in auto mode, records the detected code in history as
speech_language, hands it to the cleanup prompt (a detected Turkish recording
gets the Turkish prompt and glossary rule), and reports it on the socket reply.
The stale comment claiming whisper.cpp's -l auto does not detect is corrected.
This commit is contained in:
sudoeren
2026-08-27 21:40:09 +03:00
parent 1bb5c9ebbc
commit 7da871c567
6 changed files with 115 additions and 30 deletions
+6 -4
View File
@@ -1058,7 +1058,7 @@ class Dikte:
if not self._transcripts_pending: if not self._transcripts_pending:
self._settle(DICTATION, payload) self._settle(DICTATION, payload)
def _on_finished(self, _raw, text, warning): def _on_finished(self, _raw, text, warning, speech_language):
if warning: if warning:
# The text was still pasted, but cleanup did not run. Say so loudly: # The text was still pasted, but cleanup did not run. Say so loudly:
# a rejected key otherwise looks exactly like working dictation. # a rejected key otherwise looks exactly like working dictation.
@@ -1079,9 +1079,10 @@ class Dikte:
t("{action}: {preview}", action=action, preview=_preview(text)) t("{action}: {preview}", action=action, preview=_preview(text))
) )
self._transcript_settled({"ok": True, "text": text, "raw": _raw, self._transcript_settled({"ok": True, "text": text, "raw": _raw,
"warning": warning}) "warning": warning,
"speech_language": speech_language})
def _on_ask_finished(self, _raw, text, warning): def _on_ask_finished(self, _raw, text, warning, speech_language):
agent = assistant.display_name(self.conf) agent = assistant.display_name(self.conf)
if warning: if warning:
# A tool the agent was not allowed to touch otherwise looks exactly # A tool the agent was not allowed to touch otherwise looks exactly
@@ -1102,7 +1103,8 @@ class Dikte:
) )
self._set_ask_state(IDLE) self._set_ask_state(IDLE)
self._settle(ASK, {"ok": True, "answer": text, "question": _raw, self._settle(ASK, {"ok": True, "answer": text, "question": _raw,
"warning": warning, "agent": agent}) "warning": warning, "agent": agent,
"speech_language": speech_language})
def _on_ask_cancelled(self): def _on_ask_cancelled(self):
self.ask_overlay.show_done(t("Stopped."), 2000) self.ask_overlay.show_done(t("Stopped."), 2000)
+14 -3
View File
@@ -395,7 +395,10 @@ DEFAULTS = {
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai "transcribe_model": "gpt-4o-transcribe", # used when provider is openai
"groq_transcribe_model": "whisper-large-v3-turbo", "groq_transcribe_model": "whisper-large-v3-turbo",
"openrouter_transcribe_model": "openai/gpt-4o-transcribe", "openrouter_transcribe_model": "openai/gpt-4o-transcribe",
"language": "tr", # Detect on the machine by default, so a new install needs no language to
# be told. whisper.cpp detects; the hosted providers detect when handed no
# language; a stored value from before this default overrides it.
"language": "auto",
"transcribe_prompt": "", "transcribe_prompt": "",
# --- whisper.cpp, on this machine --------------------------------------- # --- whisper.cpp, on this machine ---------------------------------------
@@ -702,8 +705,16 @@ class Config:
return self["cleanup_provider"] == "local" return self["cleanup_provider"] == "local"
def cleanup_prompt(self, with_timestamps=False, with_speakers=False, def cleanup_prompt(self, with_timestamps=False, with_speakers=False,
subtitles=False): subtitles=False, speech=""):
turkish = i18n.language() == "tr" """`speech` is the two-letter code of the language that was heard, when
the transcription model reported one. The default prompts and the
glossary rule only exist in Turkish and English, so a detected Turkish
recording gets the Turkish prompt and any other detected language — or
none at all — the English one, which is written not to care what
language the transcript is in. Nothing else calls this with it, so the
interface language keeps deciding everywhere the speech was not asked
about."""
turkish = (speech == "tr") if speech else i18n.language() == "tr"
if subtitles: if subtitles:
prompt = (self["file_cleanup_prompt"].strip() prompt = (self["file_cleanup_prompt"].strip()
or default_file_cleanup_prompt()) or default_file_cleanup_prompt())
+4 -3
View File
@@ -967,12 +967,13 @@ def _whisper_args(settings):
binary, "-m", str(model), binary, "-m", str(model),
"--inference-path", INFERENCE_PATH, "--inference-path", INFERENCE_PATH,
# Whatever language the request does not name. api.py leaves the field # Whatever language the request does not name. api.py leaves the field
# out when the language is "auto", and the server's own default is # out when the language is "auto", and the server's own language is
# English rather than detection. # set here: "auto" makes whisper.cpp detect what it hears.
"-l", "auto", "-l", "auto",
# Stock phrases invented for near-silence come from non-speech tokens, # Stock phrases invented for near-silence come from non-speech tokens,
# and verbose_json otherwise pays for a language probability sweep # and verbose_json otherwise pays for a language probability sweep
# nothing here reads. # nobody asked for. A request that wants the detected language switches
# that back on per request.
"-sns", "-nlp", "-sns", "-nlp",
] ]
if int(settings["threads"]) > 0: if int(settings["threads"]) > 0:
+24 -9
View File
@@ -37,7 +37,7 @@ _paste_lock = threading.Lock()
class Pipeline(QObject): class Pipeline(QObject):
stage = pyqtSignal(str) # human-readable progress line stage = pyqtSignal(str) # human-readable progress line
finished = pyqtSignal(str, str, str) # raw transcript, final text, warning finished = pyqtSignal(str, str, str, str) # raw, final text, warning, language
failed = pyqtSignal(str) failed = pyqtSignal(str)
cancelled = pyqtSignal() cancelled = pyqtSignal()
@@ -120,12 +120,24 @@ class Pipeline(QObject):
try: try:
self.stage.emit(t("Transcribing…")) self.stage.emit(t("Transcribing…"))
target = conf.transcribe_target() target = conf.transcribe_target()
raw = api.transcribe( # The spoken language is only knowable after the fact, and only the
target, # local server says what it heard: auto mode asks it there, and
wav_path, # every other run (a fixed language, or a hosted provider that
language=conf["language"], # detects but stays silent) transcribes as before.
prompt=conf["transcribe_prompt"], auto = conf["language"] == "auto"
) if auto:
raw, detected = api.transcribe_detected(
target, wav_path, language=conf["language"],
prompt=conf["transcribe_prompt"],
)
else:
raw = api.transcribe(
target,
wav_path,
language=conf["language"],
prompt=conf["transcribe_prompt"],
)
detected = ""
if conf["filter_hallucinations"] and vad.looks_like_hallucination(raw, duration): if conf["filter_hallucinations"] and vad.looks_like_hallucination(raw, duration):
self._discard(wav_path) self._discard(wav_path)
@@ -145,7 +157,7 @@ class Pipeline(QObject):
self.stage.emit(t("Cleaning up…")) self.stage.emit(t("Cleaning up…"))
cleaned = True cleaned = True
try: try:
text = cleanup.run(raw, conf, conf.cleanup_prompt()) text = cleanup.run(raw, conf, conf.cleanup_prompt(speech=detected))
except api.ApiError as exc: except api.ApiError as exc:
# Keep the transcript, but never let the failure pass unseen: # Keep the transcript, but never let the failure pass unseen:
# a rejected key would otherwise look like working dictation. # a rejected key would otherwise look like working dictation.
@@ -183,6 +195,9 @@ class Pipeline(QObject):
"question": question, "question": question,
"assistant": assistant.provider(conf) if ask else "", "assistant": assistant.provider(conf) if ask else "",
"assistant_model": assistant.model(conf) if ask else "", "assistant_model": assistant.model(conf) if ask else "",
# The language the run actually spoke: the detected code, or the
# configured one when nothing was detected to replace it.
"speech_language": detected or conf["language"],
"raw": raw, "raw": raw,
"text": text, "text": text,
} }
@@ -221,7 +236,7 @@ class Pipeline(QObject):
time.sleep(0.35) time.sleep(0.35)
paste.copy_bytes(previous) paste.copy_bytes(previous)
self.finished.emit(raw, text, warning) self.finished.emit(raw, text, warning, detected)
except assistant.Cancelled: except assistant.Cancelled:
self.cancelled.emit() self.cancelled.emit()
+10
View File
@@ -259,6 +259,16 @@ class CleanupPrompt(DikteTest):
def test_no_glossary_means_no_rule_about_one(self): def test_no_glossary_means_no_rule_about_one(self):
self.assertEqual(cfg.Config().cleanup_prompt(), cfg.CLEANUP_PROMPT_EN) self.assertEqual(cfg.Config().cleanup_prompt(), cfg.CLEANUP_PROMPT_EN)
def test_a_detected_turkish_recording_gets_the_turkish_prompt(self):
"""Auto mode learns what was heard, and that decides the prompt rather
than the interface language."""
self.write_config({"ui_language": "en", "transcribe_prompt": "Paraşüt"})
conf = cfg.Config()
self.assertIn("KONUŞMACININ KULLANDIĞI İSİM VE TERİMLER",
conf.cleanup_prompt(speech="tr"))
self.assertIn("NAMES AND TERMS THE SPEAKER USES",
conf.cleanup_prompt(speech="de"))
def test_subtitles_use_their_own_prompt(self): def test_subtitles_use_their_own_prompt(self):
conf = cfg.Config() conf = cfg.Config()
self.assertNotEqual(conf.cleanup_prompt(subtitles=True), conf.cleanup_prompt()) self.assertNotEqual(conf.cleanup_prompt(subtitles=True), conf.cleanup_prompt())
+57 -11
View File
@@ -35,7 +35,7 @@ class Chain(DikteTest):
cleaned="Book it for Thursday.", cleaned="Book it for Thursday.",
cleanup_error=None, answer=("Booked.", ""), rms=None, cleanup_error=None, answer=("Booked.", ""), rms=None,
clipboard=b"what was there before", paste_error=None, clipboard=b"what was there before", paste_error=None,
focus=None): detected="en", focus=None):
pipeline = worker.Pipeline(self.conf) pipeline = worker.Pipeline(self.conf)
done, failures, stages, cancels = [], [], [], [] done, failures, stages, cancels = [], [], [], []
pipeline.finished.connect(lambda *args: done.append(args)) pipeline.finished.connect(lambda *args: done.append(args))
@@ -45,14 +45,19 @@ class Chain(DikteTest):
cleanup = (mock.Mock(side_effect=cleanup_error) if cleanup_error cleanup = (mock.Mock(side_effect=cleanup_error) if cleanup_error
else mock.Mock(return_value=cleaned)) else mock.Mock(return_value=cleaned))
# Auto mode takes the detection path; a fixed language the plain one.
# Both are mocked so the chain runs either way without a server.
behavior = {"side_effect": transcribe_error} if transcribe_error \
else {"return_value": transcript}
detect_behavior = {"side_effect": transcribe_error} if transcribe_error \
else {"return_value": (transcript, detected)}
calls = {} calls = {}
# The chain reports its own failures on stderr, which a test run has no # The chain reports its own failures on stderr, which a test run has no
# use for. # use for.
with contextlib.redirect_stderr(io.StringIO()), \ with contextlib.redirect_stderr(io.StringIO()), \
mock.patch.object( mock.patch.object(api, "transcribe", **behavior) as tr, \
api, "transcribe", mock.patch.object(api, "transcribe_detected",
**({"side_effect": transcribe_error} if transcribe_error **detect_behavior) as tdet, \
else {"return_value": transcript})) as tr, \
mock.patch.object(api, "cleanup", cleanup), \ mock.patch.object(api, "cleanup", cleanup), \
mock.patch.object(assistant, "ask", return_value=answer) as ask_call, \ mock.patch.object(assistant, "ask", return_value=answer) as ask_call, \
mock.patch.object(paste, "copy") as copy, \ mock.patch.object(paste, "copy") as copy, \
@@ -62,7 +67,8 @@ class Chain(DikteTest):
return_value=clipboard) as read_clipboard, \ return_value=clipboard) as read_clipboard, \
mock.patch.object(worker.time, "sleep", lambda seconds: None): mock.patch.object(worker.time, "sleep", lambda seconds: None):
press.side_effect = paste_error press.side_effect = paste_error
calls = {"transcribe": tr, "cleanup": cleanup, "ask": ask_call, calls = {"transcribe": tr, "transcribe_detected": tdet,
"cleanup": cleanup, "ask": ask_call,
"copy": copy, "copy_bytes": copy_bytes, "press": press, "copy": copy, "copy_bytes": copy_bytes, "press": press,
"read_clipboard": read_clipboard} "read_clipboard": read_clipboard}
pipeline._work(self.wav, duration, pipeline._work(self.wav, duration,
@@ -77,7 +83,8 @@ class Chain(DikteTest):
run = self.run_chain() run = self.run_chain()
self.assertEqual(run["failures"], []) self.assertEqual(run["failures"], [])
self.assertEqual(run["done"][0], self.assertEqual(run["done"][0],
("uh, book it for Thursday", "Book it for Thursday.", "")) ("uh, book it for Thursday", "Book it for Thursday.",
"", "en"))
run["copy"].assert_called_once_with("Book it for Thursday.") run["copy"].assert_called_once_with("Book it for Thursday.")
run["press"].assert_called_once_with(self.conf["paste_shortcut"], run["press"].assert_called_once_with(self.conf["paste_shortcut"],
focus=None) focus=None)
@@ -129,7 +136,7 @@ class Chain(DikteTest):
self.conf["restore_clipboard"] = True self.conf["restore_clipboard"] = True
run = self.run_chain(paste_error=paste.PasteError("not trusted")) run = self.run_chain(paste_error=paste.PasteError("not trusted"))
self.assertEqual(run["failures"], []) self.assertEqual(run["failures"], [])
raw, text, warning = run["done"][0] raw, text, warning, _lang = run["done"][0]
self.assertIn("not trusted", warning) self.assertIn("not trusted", warning)
run["copy_bytes"].assert_not_called() run["copy_bytes"].assert_not_called()
@@ -182,17 +189,41 @@ class Chain(DikteTest):
self.assertEqual(run["transcribe"].call_args.kwargs["language"], "tr") self.assertEqual(run["transcribe"].call_args.kwargs["language"], "tr")
self.assertEqual(run["transcribe"].call_args.kwargs["prompt"], "Paraşüt") self.assertEqual(run["transcribe"].call_args.kwargs["prompt"], "Paraşüt")
def test_auto_mode_asks_for_the_detected_language_and_records_it(self):
run = self.run_chain(detected="tr")
told = run["transcribe_detected"].call_args.kwargs
self.assertEqual(told["language"], "auto")
self.assertEqual(cfg.read_history()[0]["speech_language"], "tr")
self.assertEqual(run["done"][0][3], "tr")
run["transcribe"].assert_not_called()
def test_the_detected_language_is_told_to_the_cleanup_prompt(self):
# The mock stands in for api.cleanup, which the cleanup module calls
# with (text, key, model, system_prompt, …); the prompt is the fourth.
self.conf["transcribe_prompt"] = "Paraşüt"
run = self.run_chain(detected="tr")
prompt = run["cleanup"].call_args.args[3]
# Turkish was detected, so the Turkish glossary rule is appended.
self.assertIn("KONUŞMACININ KULLANDIĞI İSİM VE TERİMLER", prompt)
def test_a_fixed_language_needs_no_detection(self):
self.conf["language"] = "en"
run = self.run_chain()
run["transcribe"].assert_called_once()
run["transcribe_detected"].assert_not_called()
self.assertEqual(cfg.read_history()[0]["speech_language"], "en")
# ---- silence and stock phrases ---------------------------------------- # ---- silence and stock phrases ----------------------------------------
def test_room_tone_costs_no_api_call(self): def test_room_tone_costs_no_api_call(self):
run = self.run_chain(rms=[0.00001] * 60) run = self.run_chain(rms=[0.00001] * 60)
run["transcribe"].assert_not_called() run["transcribe_detected"].assert_not_called()
self.assertIn("No speech", run["failures"][0]) self.assertIn("No speech", run["failures"][0])
def test_the_silence_check_can_be_switched_off(self): def test_the_silence_check_can_be_switched_off(self):
self.conf["skip_silent"] = False self.conf["skip_silent"] = False
run = self.run_chain(rms=[0.00001] * 60) run = self.run_chain(rms=[0.00001] * 60)
run["transcribe"].assert_called_once() run["transcribe_detected"].assert_called_once()
def test_a_stock_phrase_from_a_short_clip_is_thrown_away(self): def test_a_stock_phrase_from_a_short_clip_is_thrown_away(self):
run = self.run_chain(duration=2.0, transcript="Altyazı M.K.") run = self.run_chain(duration=2.0, transcript="Altyazı M.K.")
@@ -208,7 +239,7 @@ class Chain(DikteTest):
def test_a_failed_cleanup_still_pastes_the_transcript(self): def test_a_failed_cleanup_still_pastes_the_transcript(self):
run = self.run_chain(cleanup_error=api.ApiError("rate limited")) run = self.run_chain(cleanup_error=api.ApiError("rate limited"))
_raw, text, warning = run["done"][0] _raw, text, warning, _lang = run["done"][0]
self.assertEqual(text, "uh, book it for Thursday") self.assertEqual(text, "uh, book it for Thursday")
self.assertIn("rate limited", warning) self.assertIn("rate limited", warning)
run["copy"].assert_called_once_with("uh, book it for Thursday") run["copy"].assert_called_once_with("uh, book it for Thursday")
@@ -220,6 +251,9 @@ class Chain(DikteTest):
self.assertEqual(cfg.read_history()[0]["cleanup_error"], "bad key") self.assertEqual(cfg.read_history()[0]["cleanup_error"], "bad key")
def test_a_failed_transcription_ends_the_run(self): def test_a_failed_transcription_ends_the_run(self):
# This path mocks api.transcribe, so it wants
# the plain (fixed-language) transcription.
self.conf["language"] = "tr"
pipeline = worker.Pipeline(self.conf) pipeline = worker.Pipeline(self.conf)
failures = [] failures = []
pipeline.failed.connect(failures.append) pipeline.failed.connect(failures.append)
@@ -231,6 +265,9 @@ class Chain(DikteTest):
copy.assert_not_called() copy.assert_not_called()
def test_a_clipboard_that_will_not_take_it(self): def test_a_clipboard_that_will_not_take_it(self):
# This path mocks api.transcribe, so it wants
# the plain (fixed-language) transcription.
self.conf["language"] = "tr"
pipeline = worker.Pipeline(self.conf) pipeline = worker.Pipeline(self.conf)
failures = [] failures = []
pipeline.failed.connect(failures.append) pipeline.failed.connect(failures.append)
@@ -243,6 +280,9 @@ class Chain(DikteTest):
self.assertIn("wl-copy", failures[0]) self.assertIn("wl-copy", failures[0])
def test_an_unexpected_error_is_reported_rather_than_swallowed(self): def test_an_unexpected_error_is_reported_rather_than_swallowed(self):
# This path mocks api.transcribe, so it wants
# the plain (fixed-language) transcription.
self.conf["language"] = "tr"
pipeline = worker.Pipeline(self.conf) pipeline = worker.Pipeline(self.conf)
failures = [] failures = []
pipeline.failed.connect(failures.append) pipeline.failed.connect(failures.append)
@@ -280,6 +320,9 @@ class Chain(DikteTest):
run["press"].assert_not_called() run["press"].assert_not_called()
def test_a_command_that_was_cancelled(self): def test_a_command_that_was_cancelled(self):
# This path mocks api.transcribe, so it wants
# the plain (fixed-language) transcription.
self.conf["language"] = "tr"
pipeline = worker.Pipeline(self.conf) pipeline = worker.Pipeline(self.conf)
cancels = [] cancels = []
pipeline.cancelled.connect(lambda: cancels.append(True)) pipeline.cancelled.connect(lambda: cancels.append(True))
@@ -289,6 +332,9 @@ class Chain(DikteTest):
self.assertEqual(cancels, [True]) self.assertEqual(cancels, [True])
def test_an_agent_that_is_not_installed(self): def test_an_agent_that_is_not_installed(self):
# This path mocks api.transcribe, so it wants
# the plain (fixed-language) transcription.
self.conf["language"] = "tr"
pipeline = worker.Pipeline(self.conf) pipeline = worker.Pipeline(self.conf)
failures = [] failures = []
pipeline.failed.connect(failures.append) pipeline.failed.connect(failures.append)