mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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:
@@ -139,7 +139,8 @@ set next to it.
|
|||||||
|
|
||||||
An indicator in the screen corner shows a red dot, a live waveform and the
|
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
|
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,
|
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
|
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.
|
second one stacks above the first while both are up.
|
||||||
|
|||||||
+3
-2
@@ -134,8 +134,9 @@ yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
|||||||
| Çık | Tepsi menüsü → *Çık*, ya da `dikte quit` |
|
| Çı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
|
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
|
süreyi, ardından hangi aşamada olduğunu gösterir. Odak almaz. Önceki dikte daha
|
||||||
`Ctrl+Space`'e tekrar basmak bir şey yapmaz, sıraya da girmez. Dikte ile ajana
|
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
|
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
|
için; başka hiçbir şeyde beklemezler. Her birinin kendi göstergesi var, ikisi
|
||||||
birden ekrandayken ikincisi birincinin üstüne yerleşir.
|
birden ekrandayken ikincisi birincinin üstüne yerleşir.
|
||||||
|
|||||||
+70
-16
@@ -145,6 +145,11 @@ class Dikte:
|
|||||||
# Which recording is the current one, so a timer set for the run that
|
# Which recording is the current one, so a timer set for the run that
|
||||||
# started it cannot stop the one that came after.
|
# started it cannot stop the one that came after.
|
||||||
self._run_id = 0
|
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"])
|
self.overlay = Overlay(self.conf["overlay_corner"])
|
||||||
# The agent's indicator sits on top of the dictation one when both are
|
# 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.level.connect(self._on_level)
|
||||||
self.recorder.stopped.connect(self._on_recorded)
|
self.recorder.stopped.connect(self._on_recorded)
|
||||||
self.recorder.failed.connect(self._on_recorder_error)
|
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.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.stage.connect(self.ask_overlay.show_busy)
|
||||||
self.ask_pipeline.finished.connect(self._on_ask_finished)
|
self.ask_pipeline.finished.connect(self._on_ask_finished)
|
||||||
self.ask_pipeline.failed.connect(self._on_ask_error)
|
self.ask_pipeline.failed.connect(self._on_ask_error)
|
||||||
@@ -336,13 +341,18 @@ class Dikte:
|
|||||||
BUSY: ("Working…", "view-refresh", "Dikte: working"),
|
BUSY: ("Working…", "view-refresh", "Dikte: working"),
|
||||||
}
|
}
|
||||||
label, icon, tip = labels[self.state]
|
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)
|
agent = assistant.display_name(self.conf)
|
||||||
|
|
||||||
self.toggle_action.setText(t(label))
|
self.toggle_action.setText(t(label))
|
||||||
# Free while the other one is thinking, blocked only while it is holding
|
# Blocked only while something is holding the microphone: a transcript
|
||||||
# the microphone.
|
# still being cleaned up queues the next dictation behind it, and the
|
||||||
|
# agent thinking never blocked it at all.
|
||||||
self.toggle_action.setEnabled(
|
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")
|
asked = i18n.name(agent, "dative")
|
||||||
self.ask_action.setText(
|
self.ask_action.setText(
|
||||||
@@ -587,9 +597,11 @@ class Dikte:
|
|||||||
return
|
return
|
||||||
if self.state == RECORDING:
|
if self.state == RECORDING:
|
||||||
self.stop()
|
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()
|
self.start()
|
||||||
# a request during its own BUSY is ignored; nothing queues up
|
|
||||||
|
|
||||||
def _toggle_ask(self):
|
def _toggle_ask(self):
|
||||||
if self._repeated():
|
if self._repeated():
|
||||||
@@ -606,7 +618,10 @@ class Dikte:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def start(self):
|
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
|
return
|
||||||
self.overlay.show_recording()
|
self.overlay.show_recording()
|
||||||
self._begin_recording(DICTATION)
|
self._begin_recording(DICTATION)
|
||||||
@@ -634,7 +649,9 @@ class Dikte:
|
|||||||
self.ticker.stop()
|
self.ticker.stop()
|
||||||
self._clear_pause()
|
self._clear_pause()
|
||||||
self._set_state(BUSY)
|
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()
|
self.recorder.stop()
|
||||||
|
|
||||||
def stop_ask(self):
|
def stop_ask(self):
|
||||||
@@ -697,7 +714,9 @@ class Dikte:
|
|||||||
self._settle(ASK, dropped)
|
self._settle(ASK, dropped)
|
||||||
else:
|
else:
|
||||||
self.overlay.dismiss()
|
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)
|
self._settle(DICTATION, dropped)
|
||||||
|
|
||||||
def cancel_ask(self):
|
def cancel_ask(self):
|
||||||
@@ -880,26 +899,49 @@ class Dikte:
|
|||||||
self.ask_pipeline.run(wav_path, duration, rms_values, ask=True,
|
self.ask_pipeline.run(wav_path, duration, rms_values, ask=True,
|
||||||
paste=wants_paste)
|
paste=wants_paste)
|
||||||
else:
|
else:
|
||||||
|
self._transcripts_pending += 1
|
||||||
self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste)
|
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):
|
def _on_finished(self, _raw, text, warning):
|
||||||
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.
|
||||||
|
if self.state != RECORDING:
|
||||||
self.overlay.show_warning(
|
self.overlay.show_warning(
|
||||||
t("Pasted raw, cleanup failed: {error}", error=warning.splitlines()[0])
|
t("Pasted raw, cleanup failed: {error}",
|
||||||
|
error=warning.splitlines()[0])
|
||||||
)
|
)
|
||||||
self.tray.showMessage(
|
self.tray.showMessage(
|
||||||
t("Dikte: cleanup failed"), warning,
|
t("Dikte: cleanup failed"), warning,
|
||||||
QSystemTrayIcon.MessageIcon.Warning, 10000,
|
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")
|
action = t("Pasted") if self.conf["auto_paste"] else t("Copied")
|
||||||
self.overlay.show_done(
|
self.overlay.show_done(
|
||||||
t("{action}: {preview}", action=action, preview=_preview(text))
|
t("{action}: {preview}", action=action, preview=_preview(text))
|
||||||
)
|
)
|
||||||
self._set_state(IDLE)
|
self._transcript_settled({"ok": True, "text": text, "raw": _raw,
|
||||||
self._settle(DICTATION, {"ok": True, "text": text, "raw": _raw,
|
|
||||||
"warning": warning})
|
"warning": warning})
|
||||||
|
|
||||||
def _on_ask_finished(self, _raw, text, warning):
|
def _on_ask_finished(self, _raw, text, warning):
|
||||||
@@ -936,9 +978,21 @@ class Dikte:
|
|||||||
self.ticker.stop()
|
self.ticker.stop()
|
||||||
(self._on_ask_error if owner == ASK else self._on_error)(message)
|
(self._on_ask_error if owner == ASK else self._on_error)(message)
|
||||||
|
|
||||||
def _on_error(self, 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._report(message, self.overlay)
|
||||||
self._set_state(IDLE)
|
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(BUSY if self._transcripts_pending else IDLE)
|
||||||
self._settle(DICTATION, {"ok": False, "error": message})
|
self._settle(DICTATION, {"ok": False, "error": message})
|
||||||
|
|
||||||
def _on_ask_error(self, message):
|
def _on_ask_error(self, message):
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ TR = {
|
|||||||
|
|
||||||
# --- overlay / pipeline -------------------------------------------
|
# --- overlay / pipeline -------------------------------------------
|
||||||
"Transcribing…": "Yazıya çevriliyor…",
|
"Transcribing…": "Yazıya çevriliyor…",
|
||||||
|
"Waiting for the one before it…": "Öncekinin bitmesi bekleniyor…",
|
||||||
"Cleaning up…": "Temizleniyor…",
|
"Cleaning up…": "Temizleniyor…",
|
||||||
"Pasting…": "Yapıştırılıyor…",
|
"Pasting…": "Yapıştırılıyor…",
|
||||||
"Pasted": "Yapıştırıldı",
|
"Pasted": "Yapıştırıldı",
|
||||||
|
|||||||
+28
-7
@@ -6,6 +6,7 @@ whatever came of it: an answer to a question, or a sentence saying what was
|
|||||||
done.
|
done.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import collections
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
@@ -45,6 +46,13 @@ class Pipeline(QObject):
|
|||||||
self.conf = conf
|
self.conf = conf
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stop = threading.Event()
|
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
|
@property
|
||||||
def busy(self):
|
def busy(self):
|
||||||
@@ -53,17 +61,30 @@ class Pipeline(QObject):
|
|||||||
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None):
|
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None):
|
||||||
"""`paste` overrides the setting for this one run, which is what a
|
"""`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
|
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."""
|
socket, and pasting it into whatever had focus is nobody's intention.
|
||||||
if self.busy:
|
|
||||||
|
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
|
return
|
||||||
|
self._draining = True
|
||||||
self._stop.clear()
|
self._stop.clear()
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(target=self._drain, daemon=True)
|
||||||
target=self._work,
|
|
||||||
args=(wav_path, duration, list(rms_values), ask, paste),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
self._thread.start()
|
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):
|
def cancel(self):
|
||||||
"""Give up on a job already under way.
|
"""Give up on a job already under way.
|
||||||
|
|
||||||
|
|||||||
+35
-6
@@ -8,6 +8,7 @@ afterwards. A pull request that reorders any of it shows up here.
|
|||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -278,13 +279,41 @@ class Chain(DikteTest):
|
|||||||
|
|
||||||
|
|
||||||
class Busy(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 = worker.Pipeline(self.config())
|
||||||
pipeline._thread = mock.Mock(is_alive=lambda: True)
|
order = []
|
||||||
self.assertTrue(pipeline.busy)
|
started, gate = threading.Event(), threading.Event()
|
||||||
with mock.patch.object(worker.threading, "Thread") as thread:
|
|
||||||
pipeline.run("/tmp/nope.wav", 1.0)
|
def work(wav_path, *_rest):
|
||||||
thread.assert_not_called()
|
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):
|
def test_the_chunk_length_matches_the_level_meter(self):
|
||||||
"""The silence thresholds are read in seconds, so the two must agree."""
|
"""The silence thresholds are read in seconds, so the two must agree."""
|
||||||
|
|||||||
Reference in New Issue
Block a user