diff --git a/README.md b/README.md index fe36166..d6fbe1d 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,8 @@ set next to it. An indicator in the screen corner shows a red dot, a live waveform and the elapsed time, then the stage it is on. It never takes focus. Pressing -`Ctrl+Space` again while Dikte is still working does nothing; nothing queues up. +`Ctrl+Space` again while the last dictation is still being cleaned up starts +the next one; it is transcribed and pasted in turn, behind the one still going. A dictation and a command to the agent do wait on each other for the microphone, which is one device, but for nothing else: each has its own indicator, and the second one stacks above the first while both are up. diff --git a/README.tr.md b/README.tr.md index 5e88c69..c22caad 100644 --- a/README.tr.md +++ b/README.tr.md @@ -134,8 +134,9 @@ yanındaki kutudan düşünme seviyesini de seçebilirsin. | Çık | Tepsi menüsü → *Çık*, ya da `dikte quit` | Ekranın köşesindeki gösterge kırmızı kayıt noktasını, canlı ses dalgasını ve -süreyi, ardından hangi aşamada olduğunu gösterir. Odak almaz. Dikte çalışırken -`Ctrl+Space`'e tekrar basmak bir şey yapmaz, sıraya da girmez. Dikte ile ajana +süreyi, ardından hangi aşamada olduğunu gösterir. Odak almaz. Önceki dikte daha +temizlenirken `Ctrl+Space`'e tekrar basmak yenisini başlatır; o da sırası +gelince, öndekinin ardından yazılıp yapıştırılır. Dikte ile ajana verilen komut yalnızca mikrofon için birbirini bekler, o da tek aygıt olduğu için; başka hiçbir şeyde beklemezler. Her birinin kendi göstergesi var, ikisi birden ekrandayken ikincisi birincinin üstüne yerleşir. diff --git a/dikte/app.py b/dikte/app.py index a840356..42d68e7 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -151,6 +151,11 @@ class Dikte: # Which recording is the current one, so a timer set for the run that # started it cannot stop the one that came after. self._run_id = 0 + # Dictations handed to the pipeline and not yet out of it. More than + # one is normal: the microphone is free while a transcript is being + # cleaned up, so the next dictation can already be spoken, and it then + # queues up behind the one still going. + self._transcripts_pending = 0 # The application that was in front when the recording started, which # is where the transcript is meant to go, and the timer watching for # the moment it has to be put back there. macOS only; see @@ -183,9 +188,9 @@ class Dikte: self.recorder.stopped.connect(self._on_recorded) self.recorder.died.connect(self._on_recorder_died) self.recorder.failed.connect(self._on_recorder_error) - self.pipeline.stage.connect(self.overlay.show_busy) + self.pipeline.stage.connect(self._on_stage) self.pipeline.finished.connect(self._on_finished) - self.pipeline.failed.connect(self._on_error) + self.pipeline.failed.connect(self._on_pipeline_failed) self.ask_pipeline.stage.connect(self.ask_overlay.show_busy) self.ask_pipeline.finished.connect(self._on_ask_finished) self.ask_pipeline.failed.connect(self._on_ask_error) @@ -355,13 +360,18 @@ class Dikte: BUSY: ("Working…", "view-refresh", "Dikte: working"), } label, icon, tip = labels[self.state] + if self.state == BUSY: + # Still working, but the microphone is free again: the menu offers + # the next dictation rather than a wait. + label = "Start recording" agent = assistant.display_name(self.conf) self.toggle_action.setText(t(label)) - # Free while the other one is thinking, blocked only while it is holding - # the microphone. + # Blocked only while something is holding the microphone: a transcript + # still being cleaned up queues the next dictation behind it, and the + # agent thinking never blocked it at all. self.toggle_action.setEnabled( - self.state == RECORDING or (self.state == IDLE and not self.recording) + self.state == RECORDING or not self.recording ) asked = i18n.name(agent, "dative") self.ask_action.setText( @@ -612,9 +622,11 @@ class Dikte: return if self.state == RECORDING: self.stop() - elif self.state == IDLE: + else: + # BUSY does not block: the microphone is free while the last + # dictation is being cleaned up, and the next one starts now and + # waits its turn in the pipeline. self.start() - # a request during its own BUSY is ignored; nothing queues up def _toggle_ask(self): if self._repeated("ask"): @@ -646,7 +658,10 @@ class Dikte: return mac_window.frontmost_pid() if sys.platform == "darwin" else None def start(self): - if self.state != IDLE or self.recording: + # Only a held microphone blocks: a previous dictation still being + # transcribed or cleaned up is the pipeline's business, not the + # recorder's. + if self.state == RECORDING or self.recording: return self.front_before = self._the_front() self.overlay.show_recording() @@ -758,7 +773,9 @@ class Dikte: self.ticker.stop() self._clear_pause() self._set_state(BUSY) - self.overlay.show_busy(t("Transcribing…")) + self.overlay.show_busy(t("Waiting for the one before it…") + if self._transcripts_pending + else t("Transcribing…")) self.recorder.stop() def stop_ask(self): @@ -823,7 +840,9 @@ class Dikte: self._settle(ASK, dropped) else: self.overlay.dismiss() - self._set_state(IDLE) + # An earlier dictation may still be in the pipeline; only the + # recording was thrown away. + self._set_state(BUSY if self._transcripts_pending else IDLE) self._settle(DICTATION, dropped) def cancel_ask(self): @@ -1014,28 +1033,51 @@ class Dikte: self.ask_pipeline.run(wav_path, duration, rms_values, ask=True, paste=wants_paste, focus=focus) else: + self._transcripts_pending += 1 self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste, focus=focus) + def _on_stage(self, message): + # The corner belongs to the recording when one is on: the previous + # run's progress must not wipe the waveform mid-sentence. + if self.state != RECORDING: + self.overlay.show_busy(message) + + def _transcript_settled(self, payload): + """One run out of the pipeline; where dictation stands now. + + A request that asked to wait is answered once the queue is empty: with + runs finishing in the order they were spoken, the one it stopped is the + last of them, and an earlier run's result would be the wrong answer. + """ + self._transcripts_pending -= 1 + if self.state != RECORDING: + self._set_state(BUSY if self._transcripts_pending else IDLE) + if not self._transcripts_pending: + self._settle(DICTATION, payload) + def _on_finished(self, _raw, text, warning): if warning: # The text was still pasted, but cleanup did not run. Say so loudly: # a rejected key otherwise looks exactly like working dictation. - self.overlay.show_warning( - t("Pasted raw, cleanup failed: {error}", error=warning.splitlines()[0]) - ) + if self.state != RECORDING: + self.overlay.show_warning( + t("Pasted raw, cleanup failed: {error}", + error=warning.splitlines()[0]) + ) self.tray.showMessage( t("Dikte: cleanup failed"), warning, QSystemTrayIcon.MessageIcon.Warning, 10000, ) - else: + elif self.state != RECORDING: + # While a new recording is on, the flash is skipped: the text + # arriving where the cursor is says everything it would have. action = t("Pasted") if self.conf["auto_paste"] else t("Copied") self.overlay.show_done( t("{action}: {preview}", action=action, preview=_preview(text)) ) - self._set_state(IDLE) - self._settle(DICTATION, {"ok": True, "text": text, "raw": _raw, - "warning": warning}) + self._transcript_settled({"ok": True, "text": text, "raw": _raw, + "warning": warning}) def _on_ask_finished(self, _raw, text, warning): agent = assistant.display_name(self.conf) @@ -1072,6 +1114,17 @@ class Dikte: self.ticker.stop() (self._on_ask_error if owner == ASK else self._on_error)(message) + def _on_pipeline_failed(self, message): + """A run the pipeline gave up on; whatever queued behind it still runs.""" + if self.state == RECORDING: + # The corner belongs to the new recording; the failure still has to + # be seen somewhere. + self.tray.showMessage("Dikte", message, + QSystemTrayIcon.MessageIcon.Warning, 8000) + else: + self._report(message, self.overlay) + self._transcript_settled({"ok": False, "error": message}) + def _on_recorder_died(self): """The capture quit under a live recording: keep what it caught. @@ -1091,8 +1144,9 @@ class Dikte: self.stop() def _on_error(self, message): + """The recorder or the key listener failed; no run reached the pipeline.""" self._report(message, self.overlay) - self._set_state(IDLE) + self._set_state(BUSY if self._transcripts_pending else IDLE) self._settle(DICTATION, {"ok": False, "error": message}) def _on_ask_error(self, message): diff --git a/dikte/i18n.py b/dikte/i18n.py index 4a779ef..9bea8ad 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -69,6 +69,7 @@ TR = { # --- overlay / pipeline ------------------------------------------- "Transcribing…": "Yazıya çevriliyor…", + "Waiting for the one before it…": "Öncekinin bitmesi bekleniyor…", "Cleaning up…": "Temizleniyor…", "Pasting…": "Yapıştırılıyor…", "Pasted": "Yapıştırıldı", diff --git a/dikte/worker.py b/dikte/worker.py index 24b1e1f..46aa0b9 100644 --- a/dikte/worker.py +++ b/dikte/worker.py @@ -6,6 +6,7 @@ whatever came of it: an answer to a question, or a sentence saying what was done. """ +import collections import os import shutil import sys @@ -45,6 +46,13 @@ class Pipeline(QObject): self.conf = conf self._thread = None self._stop = threading.Event() + # Recordings waiting their turn, and whether a thread is working them + # off. The flag rather than the thread's own liveness, because a thread + # stays alive for a moment after deciding it is done, and a job arriving + # in that moment would be left in the queue with nobody coming back. + self._jobs = collections.deque() + self._draining = False + self._jobs_lock = threading.Lock() @property def busy(self): @@ -57,17 +65,31 @@ class Pipeline(QObject): socket, and pasting it into whatever had focus is nobody's intention. `focus` is the application that was in front when the recording began, - as a process id, and is where the paste is meant to land.""" - if self.busy: - return + as a process id, and is where the paste is meant to land. + + A run started while one is going waits its turn rather than being + dropped: the next dictation can be spoken while the last one is still + being cleaned up, and each one is finished, pasted and reported in the + order it was spoken.""" + with self._jobs_lock: + self._jobs.append((wav_path, duration, list(rms_values), ask, paste, + focus)) + if self._draining: + return + self._draining = True self._stop.clear() - self._thread = threading.Thread( - target=self._work, - args=(wav_path, duration, list(rms_values), ask, paste, focus), - daemon=True, - ) + self._thread = threading.Thread(target=self._drain, daemon=True) self._thread.start() + def _drain(self): + while True: + with self._jobs_lock: + if not self._jobs: + self._draining = False + return + job = self._jobs.popleft() + self._work(*job) + def cancel(self): """Give up on a job already under way. diff --git a/tests/test_worker.py b/tests/test_worker.py index c1918dd..725ae91 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -8,6 +8,7 @@ afterwards. A pull request that reorders any of it shows up here. import contextlib import io import os +import threading import unittest from unittest import mock @@ -338,13 +339,41 @@ class Chain(DikteTest): class Busy(DikteTest): - def test_a_second_run_while_one_is_going_is_ignored(self): + def test_a_second_run_while_one_is_going_waits_its_turn(self): + """The microphone is free while a transcript is being cleaned up, so + the next dictation can already have been spoken by then. It has to run + once the first is done, in the order they were spoken, on one thread.""" pipeline = worker.Pipeline(self.config()) - pipeline._thread = mock.Mock(is_alive=lambda: True) - self.assertTrue(pipeline.busy) - with mock.patch.object(worker.threading, "Thread") as thread: - pipeline.run("/tmp/nope.wav", 1.0) - thread.assert_not_called() + order = [] + started, gate = threading.Event(), threading.Event() + + def work(wav_path, *_rest): + order.append(wav_path) + started.set() + if wav_path == "first.wav": + gate.wait(5) + + with mock.patch.object(pipeline, "_work", side_effect=work): + pipeline.run("first.wav", 1.0) + self.assertTrue(started.wait(5)) + pipeline.run("second.wav", 1.0) + # Held, not dropped and not running beside the first. + self.assertEqual(order, ["first.wav"]) + gate.set() + pipeline._thread.join(5) + self.assertEqual(order, ["first.wav", "second.wav"]) + + def test_a_run_arriving_after_the_queue_drained(self): + """The worker thread ends with the queue; the next run brings one.""" + pipeline = worker.Pipeline(self.config()) + order = [] + with mock.patch.object(pipeline, "_work", + side_effect=lambda wav, *rest: order.append(wav)): + pipeline.run("first.wav", 1.0) + pipeline._thread.join(5) + pipeline.run("second.wav", 1.0) + pipeline._thread.join(5) + self.assertEqual(order, ["first.wav", "second.wav"]) def test_the_chunk_length_matches_the_level_meter(self): """The silence thresholds are read in seconds, so the two must agree."""