mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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:
+6
-4
@@ -1058,7 +1058,7 @@ class Dikte:
|
||||
if not self._transcripts_pending:
|
||||
self._settle(DICTATION, payload)
|
||||
|
||||
def _on_finished(self, _raw, text, warning):
|
||||
def _on_finished(self, _raw, text, warning, speech_language):
|
||||
if warning:
|
||||
# The text was still pasted, but cleanup did not run. Say so loudly:
|
||||
# a rejected key otherwise looks exactly like working dictation.
|
||||
@@ -1079,9 +1079,10 @@ class Dikte:
|
||||
t("{action}: {preview}", action=action, preview=_preview(text))
|
||||
)
|
||||
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)
|
||||
if warning:
|
||||
# A tool the agent was not allowed to touch otherwise looks exactly
|
||||
@@ -1102,7 +1103,8 @@ class Dikte:
|
||||
)
|
||||
self._set_ask_state(IDLE)
|
||||
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):
|
||||
self.ask_overlay.show_done(t("Stopped."), 2000)
|
||||
|
||||
+14
-3
@@ -395,7 +395,10 @@ 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",
|
||||
"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": "",
|
||||
|
||||
# --- whisper.cpp, on this machine ---------------------------------------
|
||||
@@ -702,8 +705,16 @@ class Config:
|
||||
return self["cleanup_provider"] == "local"
|
||||
|
||||
def cleanup_prompt(self, with_timestamps=False, with_speakers=False,
|
||||
subtitles=False):
|
||||
turkish = i18n.language() == "tr"
|
||||
subtitles=False, speech=""):
|
||||
"""`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:
|
||||
prompt = (self["file_cleanup_prompt"].strip()
|
||||
or default_file_cleanup_prompt())
|
||||
|
||||
+4
-3
@@ -967,12 +967,13 @@ def _whisper_args(settings):
|
||||
binary, "-m", str(model),
|
||||
"--inference-path", INFERENCE_PATH,
|
||||
# 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
|
||||
# English rather than detection.
|
||||
# out when the language is "auto", and the server's own language is
|
||||
# set here: "auto" makes whisper.cpp detect what it hears.
|
||||
"-l", "auto",
|
||||
# Stock phrases invented for near-silence come from non-speech tokens,
|
||||
# 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",
|
||||
]
|
||||
if int(settings["threads"]) > 0:
|
||||
|
||||
+18
-3
@@ -37,7 +37,7 @@ _paste_lock = threading.Lock()
|
||||
|
||||
class Pipeline(QObject):
|
||||
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)
|
||||
cancelled = pyqtSignal()
|
||||
|
||||
@@ -120,12 +120,24 @@ class Pipeline(QObject):
|
||||
try:
|
||||
self.stage.emit(t("Transcribing…"))
|
||||
target = conf.transcribe_target()
|
||||
# The spoken language is only knowable after the fact, and only the
|
||||
# local server says what it heard: auto mode asks it there, and
|
||||
# every other run (a fixed language, or a hosted provider that
|
||||
# detects but stays silent) transcribes as before.
|
||||
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):
|
||||
self._discard(wav_path)
|
||||
@@ -145,7 +157,7 @@ class Pipeline(QObject):
|
||||
self.stage.emit(t("Cleaning up…"))
|
||||
cleaned = True
|
||||
try:
|
||||
text = cleanup.run(raw, conf, conf.cleanup_prompt())
|
||||
text = cleanup.run(raw, conf, conf.cleanup_prompt(speech=detected))
|
||||
except api.ApiError as exc:
|
||||
# Keep the transcript, but never let the failure pass unseen:
|
||||
# a rejected key would otherwise look like working dictation.
|
||||
@@ -183,6 +195,9 @@ class Pipeline(QObject):
|
||||
"question": question,
|
||||
"assistant": assistant.provider(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,
|
||||
"text": text,
|
||||
}
|
||||
@@ -221,7 +236,7 @@ class Pipeline(QObject):
|
||||
time.sleep(0.35)
|
||||
paste.copy_bytes(previous)
|
||||
|
||||
self.finished.emit(raw, text, warning)
|
||||
self.finished.emit(raw, text, warning, detected)
|
||||
|
||||
except assistant.Cancelled:
|
||||
self.cancelled.emit()
|
||||
|
||||
@@ -259,6 +259,16 @@ class CleanupPrompt(DikteTest):
|
||||
def test_no_glossary_means_no_rule_about_one(self):
|
||||
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):
|
||||
conf = cfg.Config()
|
||||
self.assertNotEqual(conf.cleanup_prompt(subtitles=True), conf.cleanup_prompt())
|
||||
|
||||
+57
-11
@@ -35,7 +35,7 @@ class Chain(DikteTest):
|
||||
cleaned="Book it for Thursday.",
|
||||
cleanup_error=None, answer=("Booked.", ""), rms=None,
|
||||
clipboard=b"what was there before", paste_error=None,
|
||||
focus=None):
|
||||
detected="en", focus=None):
|
||||
pipeline = worker.Pipeline(self.conf)
|
||||
done, failures, stages, cancels = [], [], [], []
|
||||
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
|
||||
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 = {}
|
||||
# The chain reports its own failures on stderr, which a test run has no
|
||||
# use for.
|
||||
with contextlib.redirect_stderr(io.StringIO()), \
|
||||
mock.patch.object(
|
||||
api, "transcribe",
|
||||
**({"side_effect": transcribe_error} if transcribe_error
|
||||
else {"return_value": transcript})) as tr, \
|
||||
mock.patch.object(api, "transcribe", **behavior) as tr, \
|
||||
mock.patch.object(api, "transcribe_detected",
|
||||
**detect_behavior) as tdet, \
|
||||
mock.patch.object(api, "cleanup", cleanup), \
|
||||
mock.patch.object(assistant, "ask", return_value=answer) as ask_call, \
|
||||
mock.patch.object(paste, "copy") as copy, \
|
||||
@@ -62,7 +67,8 @@ class Chain(DikteTest):
|
||||
return_value=clipboard) as read_clipboard, \
|
||||
mock.patch.object(worker.time, "sleep", lambda seconds: None):
|
||||
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,
|
||||
"read_clipboard": read_clipboard}
|
||||
pipeline._work(self.wav, duration,
|
||||
@@ -77,7 +83,8 @@ class Chain(DikteTest):
|
||||
run = self.run_chain()
|
||||
self.assertEqual(run["failures"], [])
|
||||
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["press"].assert_called_once_with(self.conf["paste_shortcut"],
|
||||
focus=None)
|
||||
@@ -129,7 +136,7 @@ class Chain(DikteTest):
|
||||
self.conf["restore_clipboard"] = True
|
||||
run = self.run_chain(paste_error=paste.PasteError("not trusted"))
|
||||
self.assertEqual(run["failures"], [])
|
||||
raw, text, warning = run["done"][0]
|
||||
raw, text, warning, _lang = run["done"][0]
|
||||
self.assertIn("not trusted", warning)
|
||||
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["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 ----------------------------------------
|
||||
|
||||
def test_room_tone_costs_no_api_call(self):
|
||||
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])
|
||||
|
||||
def test_the_silence_check_can_be_switched_off(self):
|
||||
self.conf["skip_silent"] = False
|
||||
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):
|
||||
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):
|
||||
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.assertIn("rate limited", warning)
|
||||
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")
|
||||
|
||||
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)
|
||||
failures = []
|
||||
pipeline.failed.connect(failures.append)
|
||||
@@ -231,6 +265,9 @@ class Chain(DikteTest):
|
||||
copy.assert_not_called()
|
||||
|
||||
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)
|
||||
failures = []
|
||||
pipeline.failed.connect(failures.append)
|
||||
@@ -243,6 +280,9 @@ class Chain(DikteTest):
|
||||
self.assertIn("wl-copy", failures[0])
|
||||
|
||||
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)
|
||||
failures = []
|
||||
pipeline.failed.connect(failures.append)
|
||||
@@ -280,6 +320,9 @@ class Chain(DikteTest):
|
||||
run["press"].assert_not_called()
|
||||
|
||||
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)
|
||||
cancels = []
|
||||
pipeline.cancelled.connect(lambda: cancels.append(True))
|
||||
@@ -289,6 +332,9 @@ class Chain(DikteTest):
|
||||
self.assertEqual(cancels, [True])
|
||||
|
||||
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)
|
||||
failures = []
|
||||
pipeline.failed.connect(failures.append)
|
||||
|
||||
Reference in New Issue
Block a user