Let the next dictation start while the last one is still working

Pressing the shortcut while a transcript was being transcribed or cleaned up
did nothing, and the thought you had while waiting was lost. The microphone is
free the moment a recording stops, so the next dictation can now be spoken at
once; the pipeline queues it and each one is finished, pasted and reported in
the order it was spoken. The corner indicator stays with the recording under
way rather than being wiped by the previous run's progress, and a stop that
lands behind an unfinished run says it is waiting its turn.
This commit is contained in:
2026-08-25 14:52:17 +03:00
parent 3663c7fd57
commit be09cc3ac6
6 changed files with 142 additions and 35 deletions
+2 -1
View File
@@ -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.
+3 -2
View File
@@ -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.
+72 -18
View File
@@ -145,6 +145,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
self.overlay = Overlay(self.conf["overlay_corner"])
# The agent's indicator sits on top of the dictation one when both are
@@ -164,9 +169,9 @@ class Dikte:
self.recorder.level.connect(self._on_level)
self.recorder.stopped.connect(self._on_recorded)
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)
@@ -336,13 +341,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(
@@ -587,9 +597,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():
@@ -606,7 +618,10 @@ class Dikte:
return False
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.overlay.show_recording()
self._begin_recording(DICTATION)
@@ -634,7 +649,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):
@@ -697,7 +714,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):
@@ -880,27 +899,50 @@ class Dikte:
self.ask_pipeline.run(wav_path, duration, rms_values, ask=True,
paste=wants_paste)
else:
self._transcripts_pending += 1
self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste)
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)
@@ -936,9 +978,21 @@ 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_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):
+1
View File
@@ -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ı",
+29 -8
View File
@@ -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):
@@ -53,17 +61,30 @@ class Pipeline(QObject):
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None):
"""`paste` overrides the setting for this one run, which is what a
dictation asked for from a terminal wants: the text comes back down the
socket, and pasting it into whatever had focus is nobody's intention."""
if self.busy:
return
socket, and pasting it into whatever had focus is nobody's intention.
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))
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),
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.
+35 -6
View File
@@ -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
@@ -278,13 +279,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."""