feat(post-processing): pipe transcriptions through an optional postprocessing script (#479)

* feat(post-processing): pipe transcriptions through user-defined script

Add a configurable post-processing step that passes each transcription
result to an external executable via stdin and uses its stdout as the
text to type. Enables custom transforms (formatting, LLM rewriting, etc.)
without modifying Vocalinux. Falls back to original text on script
failure or timeout.

* test(post-processor): add unit tests and extract apply_post_processing

Move config-reading logic from main.py closure into apply_post_processing()
in post_processor.py so it can be tested independently. Add 11 unit tests
covering PostProcessor.process() and apply_post_processing().

* docs(post-processing): document post-processing script feature

* fix formatting of one file

* fix(post-processing): run the user script with a stripped host environment

Rebase compat: the host_process AST guard requires every subprocess
spawned for a host binary to pass env=host_env() so an AppImage's
LD_LIBRARY_PATH/GI_TYPELIB_PATH/PYTHONHOME do not leak in. Also give
apply_post_processing() a typed ConfigManager parameter and keep
text_callback_wrapper's local variable typed as str for mypy.

* fix(post-processing): preserve paragraph breaks and tighten script handling

Take script stdout verbatim instead of rstripping every trailing
newline, so the "new paragraph" voice command's break survives a
passthrough script. A single trailing newline is still dropped, but
only when the transcription had none — it is almost always just the
line terminator echo/print-style tools append.

Also stop writing transformed output to the log (it can carry
sensitive dictation into retained logs), catch only the expected
process/OS/encoding failures instead of every Exception, and add the
missing signature annotations.

* fix(main): run the post-processing script off the recognition thread

A user script may take seconds per segment, and text callbacks run on
the recognition thread that drains the bounded audio-segment queue;
stalling it drops queued dictation once the queue fills. Submit
post-processing and injection to a dedicated single-worker executor so
the backlog waits instead of losing speech, while keeping injection
order. On timeout or failure the worker falls back to the original
text, and an unexpected error is logged with a traceback and still
injects the original rather than silently dropping the segment.

Also pin the callback tests' get_str mock to no-script so they
exercise the synchronous path deterministically, and add a callback
test proving transformed text (including preserved paragraph breaks)
reaches the injector.

* docs(post-processing): annotate new callbacks and clarify stdout contract

Add the missing type annotations on the new Settings page callbacks
per the repository guide, and document that script stdout is used
verbatim (a lone trailing newline added by line-oriented tools is
dropped only when the transcription had none) and that scripts run on
a dedicated worker.

* test(main): annotate new post-processing callback tests

* fix(main): bind queued dictation to its target app and drain worker on quit

Route every transcription segment through the single post-processing
worker (a pass-through when no script is configured) so clearing the
script path mid-queue can no longer let a later segment overtake an
earlier one still running.

A segment that can sit in the backlog — a script is configured or a job
is still running — is bound to the application focused when it was
dictated; the worker drops it if focus has verifiably moved to another
application, so delayed dictation cannot land in the wrong app.

Wire the worker into the tray quit path via an on_quit hook: it clears
the acceptance flag, cancels pending jobs, and waits — bounded by the
script's own timeout — so nothing injects after the injector stops.

* fix(main): keep voice actions ordered and probe focus off the recognition thread

- Queue voice-command actions on the post-processing worker so an action
  can no longer overtake dictated text still waiting to be injected.
- Move the submit-time focus probe onto a dedicated executor; the
  recognition thread only submits the probe, and the job joins the result
  at inject time, so compositor tools can no longer stall consumption of
  the bounded audio-segment queue.
- Re-check the quit flag after the inject-time focus re-probe, then shut
  both workers down without waiting on a running script so quitting does
  not freeze on the script timeout.

* fix(main): capture submit-time focus on dedicated threads and serialize quit

- Replace the shared focus-probe executor with a fresh daemon thread per
  submission so a slow earlier probe cannot delay a later one past the
  focus change it is meant to capture; jobs read the result off a
  one-slot queue at inject time.
- Give queued voice actions the same submit-time focus binding as text
  segments so a queued shortcut is dropped instead of fired into a newly
  focused application.
- Hold injection_lock across the accept-check plus inject/action call so
  quit waits only for an in-flight injection instead of racing it.
- Queue the IDLE reset of last_injected_text behind pending worker jobs
  so a queued 'delete that' still sees the text it targets.

* fix(main): keep actions instant on a free worker and bound quit wait

- An action now starts a submit-time focus probe only when it actually
  queues behind other jobs; with a free worker there is no delay window
  to bind against, so the probe's compositor calls are skipped and the
  action stays immediate even when a script is configured.
- Probe and executor submission now happen inside pending_jobs_lock so
  queue order matches callback order — a job that saw an empty queue can
  no longer slip behind one that arrived while it was submitting.
- Every inject-path subprocess call (xdotool window/key/type, wtype and
  ydotool type) is bounded by a timeout, and the quit hook's
  injection_lock wait is capped, so quitting can no longer freeze on a
  stalled injector subprocess.

* fix(main): bind every action to submit-time focus without slowing it down

- Every voice action now starts a submit-time focus probe: submission
  never guarantees immediate execution, so skipping the binding left a
  focus-change window in which a shortcut could fire into another app.
  The worker waits on the probe for at most one second — milliseconds
  on a healthy desktop — so immediate actions stay immediate while a
  genuinely stalled probe falls back to the injector's own targeting.
- Quit waits for an in-flight injection without a cap again: every
  inject-path subprocess is individually bounded, so the wait always
  ends with the injection and a long transcription is never cut off.
- The wtype/ydotool typing timeout is now derived from the configured
  per-key delay (a generous multiple), so a legitimately slow type can
  never be cut mid-text; a timed-out type no longer falls through to
  the clipboard fallback, which would duplicate the partial fragment.

* fix(main): drop unverified actions and make quit-time injection abortable

- An action whose submit-time focus probe cannot answer inside its
  one-second bound is now dropped instead of running unverified: a
  stalled probe can no longer leave a shortcut pointing at whatever
  application happens to be focused.
- Quit asks the injector to abort in-flight injections, and injection
  paths honour it: the xdotool chunk loop stops between chunks, the
  wtype/ydotool type call runs as a polling Popen that can be killed,
  and paste/shortcut/backspace entry points exit early. The quit hook
  still waits for an in-flight injection, but the wait is bounded by at
  most one already-running subprocess call — never the injection's full
  length — and no subprocess is ever stopped mid-call by a timeout cap
  on the wait itself.
- Annotate the remaining untyped test helpers.

* fix(main): wait out slow focus probes; restore run() in wayland typing

- An action no longer races its submit-time focus probe on a one-second
  bound: the job waits for the probe's own answer, whose compositor
  calls already carry per-call timeouts and short-circuit absent tools.
  A slow but living probe delays the command instead of discarding it,
  so a valid "undo" can no longer be lost to timing alone, and focus
  verification still decides every drop.
- Wayland typing goes back to subprocess.run so tests keep intercepting
  the same entry point. Abortability is preserved by typing in
  200-character chunks with the shutdown check between calls — a
  whole-string call could not be interrupted, but no single chunk holds
  the injector for more than its own scaled budget.
- The abort flag gains a class-level default so injectors built without
  __init__ in tests still answer the checks; each real instance gets its
  own event in __init__.
- Update call-shape assertions for the typed-call timeout and finish
  annotating the test methods touched by the callback refactor.

* fix(inject): never retype text a failed chunked call already sent

- _PartiallyTyped carries the confirmed-delivered prefix length out of
  the chunked type loops, so every downstream fallback continues from
  the untyped remainder: the Wayland-to-xdotool switch types only the
  tail, and the clipboard fallback copies only the tail, never the
  whole string a second time.
- The xdotool retry loop gains the same accounting — a retry now
  resumes at the first untyped chunk instead of restarting at the top
  and duplicating everything the failed attempt already delivered.
- Annotate the remaining test signature the repository guide requires.

* fix(inject): report partial typing as failure even when clipboard lands

A chunked type call that dies mid-text leaves only a prefix on screen,
and the remainder-fallback keeps it that way: it copies the tail for a
manual paste but now returns False, so the caller no longer records the
full transcription as injected and a following "delete that" cannot
erase text that was never typed.

* fix(inject): keep deletion state aligned with delivered text

inject_text now records how many characters a call confirmed on screen
as last_typed_count: the whole length on success, the typed prefix
after a partial failure, -1 when the delivered amount is unknowable.
The transcription caller uses it to keep last_injected_text honest —
a following "delete that" erases the typed prefix or nothing, never
the previous segment or text that never arrived.

* fix(inject): never replay input a timed-out portal job may have delivered

- RemoteDesktopPortalError.delivered=None marks an unreported job's count
  as unknowable; all three portal fallbacks demote and skip instead of
  replaying, and text injection surfaces as TimeoutExpired so no
  full-text clipboard copy is offered
- _PartiallyTyped counts raised after a portal demote are rebased onto
  the original text (typed_prefix) so the caller clips the real prefix
- evdev: a consumed pure-modifier press is withheld and replayed ahead
  of the first non-target press, so holding RightAlt for AltGr
  composition still reaches the application
- history: extend_latest gains an expected_latest CAS guard so a late
  segment from an older worker merges only into its own session's
  snippet, never into a newer session's entry
- quit path aborts in-flight injections before waiting on the lock so
  quitting cannot freeze behind a long transcription's typing budget
- tests: stop _ensure_ydotoold leaking a real Popen in Wayland tests

* fix(shortcut): replay withheld modifiers across devices and after SYN_DROPPED

- Replay withheld modifier presses from every device, not only the fd the
  character arrived on: split keyboards deliver AltGr and the character on
  separate devices, and a per-fd check forwarded a plain character.
- Let the post-SYN_DROPPED resync run when only withheld modifiers exist
  for the fd, so a press whose release the dropped burst ate is pruned
  instead of replaying a stale modifier on the next ordinary key.

* fix(history,pads): keep late segments pad-bound deliveries and pad deletions correct

- Track the worker behind the ended session's snippet so late segments
  from an older worker cannot merge into a newer session's entry
- Skip the focus-change drop for dictation-pad segments and pad-bound
  editing commands, which never target the focused application
- Refuse 'delete that' when a manual pad edit blurred the segment
  boundaries instead of deleting the stale recorded length

* style: drop unneeded nonlocal for mutated worker set

* fix(shortcut): share one evdev device layer across shortcut listeners

Each KeyboardShortcutManager built its own EvdevKeyboardBackend, and on
evdev every backend opened and grabbed the same physical keyboards: the
first grabber received all events and the rest received none, so
per-language shortcuts could never fire on Wayland.

All backends now bind to a process-wide EvdevDeviceHub that opens each
keyboard once, owns the grabs and the paired uinput clones, and fans
every event out to each registered engine. An event is re-emitted to
applications only when no engine consumed it, so suppression semantics
stay the same while per-engine matching state (combo progress, withheld
modifiers, swallowed presses) remains independent.

* fix(history): own late segments per worker and decide pad routing once

- ended_worker_snippets maps each ended session's worker to its own
  (snippet, commit epoch): late deliveries merge into their session's
  entry however many sessions have committed since, so one session's
  stragglers never split across Recent Snippets entries.
- The leftover-segment commit reads the live history epoch inside the
  session lock: a mid-session clear() no longer makes the stale ended
  epoch refuse valid post-clear dictation.
- The pad-bound routing decision is captured once per job and passed to
  the injector/action handler, so a toggle flip between the focus check
  and delivery cannot reroute text or shortcuts into the focused app.
- Dead recognition workers are pruned from the tracking set and map so
  long runs don't accumulate finished threads.
- The custom-terms switch now suppresses GTK's own state-set on save
  failure so it shows the value that actually persisted.

* fix(shortcut): serialize evdev device layer teardown against restart

The last listener's unregister released the engine lock before joining
the monitor thread and closing devices, so a new listener registering in
that gap opened keyboards the stale teardown then closed — it stayed
active but dead, and briefly grabbed keyboards stopped forwarding input.

A lifecycle lock now covers the whole close/open transition in register,
unregister, and the monitor's unexpected-exit teardown, so a cold start
can only open devices after the previous generation finished closing
(also making the new generation's grabs succeed). A generation stamp
tells a monitor thread that outlived its join() its incarnation is over
even when running has gone True again.

* fix(injection): log focus-probe failures and narrow clipboard fallback catch

A crashed focus probe turned into an unknown result with no logged
error, indistinguishable from unavailable focus information; the
partial-typing clipboard fallback caught every exception. Log the probe
failure with context and narrow the clipboard catch to the exceptions
the fallback path can actually raise.

* fix(shortcut): join the evdev monitor outside the lifecycle lock

If the monitor exited unexpectedly while the last listener unregistered,
its cleanup queued on the lifecycle lock that unregister() held for the
join — stalling shutdown for the join's full 2s timeout. The close now
finishes inside the lock and the join waits after it is released, so the
monitor's own cleanup can run and exit instead of queueing behind it.

Closing before the join also dies the monitor's read/select on the
closed fds right away. A _closed gate on _open_keyboard_device keeps a
rescan from opening a device between detach and close that nothing
would ever close.

* fix(injection): narrow focus-probe catch to the raisable exception set

get_focused_window() reports Optional and never raises; the catch now
covers only what can realistically escape the queue write and logs it
with context, keeping the waiting job released.

* fix(shortcut): tag evdev device fds with the generation that opened them

A monitor that outlived join() could still touch a newer generation's
devices: the loop re-checked the generation only at the top, so mid-iteration
it could dispatch from, forward to, or remove devices a restarted listener
had just opened.

Every fd is now tagged with the generation that opened it, and the tag is
verified at each point a monitor could mutate shared state — fd lookup for
dispatch, clone forwarding, forwarder release, device removal, and rescan
opens — while dispatch re-checks the generation between events and the
per-fd loop breaks on it. Cold starts bump the generation before opening
so fresh devices are tagged for the new generation. Devices an older
generation opened stay tagged stale, so its reader leaves them alone.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
Fabian Streitel
2026-09-30 03:55:17 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent dca937fc21
commit d11bd1bf0d
19 changed files with 2888 additions and 680 deletions
+411 -91
View File
@@ -6,9 +6,11 @@ Main entry point for Vocalinux application.
import argparse
import atexit
import logging
import queue
import sys
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Optional
from .utils.vosk_model_info import SUPPORTED_LANGUAGES
@@ -357,6 +359,7 @@ def main():
# Check for single instance BEFORE any initialization
from . import single_instance
from .post_processor import apply_post_processing
if not single_instance.acquire_lock():
# Another instance is already running - show notification and exit
@@ -424,7 +427,7 @@ def main():
from .common_types import RecognitionState
from .custom_dictionary import CustomDictionaryManager
from .speech_recognition import recognition_manager
from .text_injection import text_injector
from .text_injection import focused_window, text_injector
from .ui import tray_indicator
from .ui.action_handler import ActionHandler
from .ui.config_manager import get_shared_config_manager
@@ -630,15 +633,19 @@ def main():
# TranscriptionHistory.add), so late segments extend that entry
# rather than whichever snippet happens to be newest.
latest_snippet_id: Optional[int] = None
# Worker thread that produced the most-recently-ended session's
# segments; deliveries on that same thread may extend its committed
# snippet once the next session has closed, so an older worker
# finishing two sessions later cannot leak into a newer entry.
ended_session_worker: Optional[threading.Thread] = None
# Commit epoch of latest_snippet_id, for guarded late merges.
latest_snippet_epoch = transcription_history.epoch
# Snippet each ended session's worker owns, as
# ``worker -> (snippet_id, commit epoch)``: a worker's late deliveries
# merge into its own session's entry however many sessions have
# committed since, so one session's stragglers can never leak into a
# newer entry or split across several snippets.
ended_worker_snippets: dict[threading.Thread, tuple[int, int]] = {}
# Every worker that has delivered in-session segments; a delivery on
# a thread never associated with a session is treated as the
# just-ended session's trailing decode, while a worker seen producing
# an earlier session can never merge into a newer entry.
# an earlier session can never merge into a newer entry. Dead
# workers are pruned so long runs don't accumulate finished threads.
session_workers_seen: set[threading.Thread] = set()
# Clear epoch the most-recently-ended session was committed under;
# late segments merging into its snippet are judged against it.
@@ -724,7 +731,8 @@ def main():
captured after the clear are kept, so dictation that continues
across a clear is still recoverable.
"""
nonlocal session_worker, latest_snippet_id, ended_session_worker
nonlocal session_worker, latest_snippet_id, latest_snippet_epoch
nonlocal session_workers_seen, ended_worker_snippets
if not transcription_history.enabled:
return
segment = _normalize_segment_text(segment)
@@ -735,6 +743,12 @@ def main():
# any other thread is a leftover from an older session.
current_worker = getattr(speech_engine, "recognition_thread", None)
with session_lock:
# Dead workers cannot deliver again; drop them so long runs
# don't accumulate finished threads.
session_workers_seen = {t for t in session_workers_seen if t.is_alive()}
ended_worker_snippets = {
w: s for w, s in ended_worker_snippets.items() if w.is_alive()
}
if started_at <= transcription_history.cleared_at:
# Captured before the last clear — must not re-enter.
return
@@ -769,26 +783,27 @@ def main():
session_segments.append((segment, started_at))
return
# Late segment from a session that already ended: merge into
# its own snippet by id — a newer session may already have
# committed on top, so the newest entry is not the target.
# Two kinds are admitted: trickles on the ended session's own
# worker thread (the engine's decode finishing after IDLE),
# and any straggler while a newer session is still open —
# the open session has not committed yet, so the tracked id
# still points to the ended session's own snippet. An older
# worker delivering after that newer session committed forms
# its own entry rather than growing the wrong snippet. Both
# writes are guarded by the ended session's epoch, so a
# clear() landing between that commit and this delivery
# still refuses the text.
# its own session's snippet, found by the worker delivering
# it — a newer session may already have committed on top, so
# the newest entry is not the target. A delivery on a thread
# never associated with a session is treated as the
# just-ended session's trailing decode while a session is
# still open; a worker seen producing an earlier session can
# never merge into a newer entry. Every write is guarded by
# the snippet's own commit epoch, so a clear() landing
# between that commit and this delivery still refuses the
# text.
owner = ended_worker_snippets.get(worker)
if owner is not None and transcription_history.extend_entry(
owner[0], segment, expected_epoch=owner[1]
):
return
if (
latest_snippet_id is not None
and (
worker is ended_session_worker
or (session_open and worker not in session_workers_seen)
)
and session_open
and worker not in session_workers_seen
and transcription_history.extend_entry(
latest_snippet_id, segment, expected_epoch=ended_session_epoch
latest_snippet_id, segment, expected_epoch=latest_snippet_epoch
)
):
return
@@ -797,27 +812,20 @@ def main():
snippet_id = transcription_history.add(segment, expected_epoch=ended_session_epoch)
if snippet_id is not None:
latest_snippet_id = snippet_id
ended_session_worker = worker
latest_snippet_epoch = ended_session_epoch
ended_worker_snippets[worker] = (snippet_id, ended_session_epoch)
session_workers_seen.add(worker)
def text_callback_wrapper(text: str) -> None:
"""Bridge between speech engine text events and the text injector.
Called on the recognition thread with each finalised transcription
segment. Strips leading whitespace and trailing spaces/tabs (but
preserves trailing newlines from voice commands), then either
appends a trailing space (default) or uses the legacy in-session
leading-space separator, and injects via TextInjector.
def inject_transcription(text_to_inject: str, to_pad: Optional[bool] = None) -> None:
"""Apply the separator rules and inject one finalised segment.
Args:
text: Raw transcription segment from the speech engine.
text_to_inject: Post-processed text ready for the text injector.
to_pad: Destination decided when the job's focus check ran;
the delivery must reuse that same decision — re-reading
the live toggle here could disagree with the check and
send the text somewhere it was never verified for.
"""
# Preserve trailing newlines ("new line" / "new paragraph"); only
# strip spaces/tabs that whisper sometimes wraps around tokens.
text_to_inject = _normalize_segment_text(text)
if not text_to_inject:
return
# Read from disk so the Settings toggle applies without restart.
append_trailing_space = _should_append_trailing_space()
@@ -834,7 +842,7 @@ def main():
text_to_inject = " " + text_to_inject
logger.debug("Added space separator before new segment")
captured = dictate_to_pad_enabled()
captured = dictate_to_pad_enabled() if to_pad is None else to_pad
if captured:
# In-app capture: skip cross-application injection entirely
# and land the text in the pad instead (#726).
@@ -845,19 +853,357 @@ def main():
if success:
action_handler.set_last_injected_text(text_to_inject)
last_injected["to_pad"] = captured
else:
# Deletion state must match what reached the app: a partial
# failure counts the confirmed prefix, an unknowable count
# (-1) clears it, and 0 leaves the previous segment's state —
# a failed injection types nothing, so "delete that" still
# means the segment before it.
typed = text_system.last_typed_count
if isinstance(typed, int) and typed > 0:
action_handler.set_last_injected_text(text_to_inject[:typed])
elif isinstance(typed, int) and typed < 0:
action_handler.set_last_injected_text("")
# Post-processing runs a user executable that may take seconds per
# segment. Running it on the recognition thread would stall the
# consumer of the bounded audio-segment queue, and once that fills,
# queued dictation is dropped. A dedicated worker applies the script
# and injects in order; its unbounded backlog waits instead of losing
# speech, and a timed-out script falls back to the original text.
#
# Every segment goes through this one worker — when no script is
# configured apply_post_processing is a pass-through — so clearing the
# script path mid-queue can never let a later segment overtake an
# earlier one still waiting behind a running script.
post_processing_executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="vocalinux-post-processing"
)
# Cleared by the quit hook so a queued or in-flight job drops its
# segment instead of injecting while the injector is being stopped.
accepting_injections = threading.Event()
accepting_injections.set()
# Serialises the flag check and the inject call itself: quitting can
# wait out an injection already in progress while never blocking on a
# queued or running script.
injection_lock = threading.Lock()
# Segments that may sit in the backlog (a script is configured, or a
# job is still running) are bound to the app focused at submit time;
# the worker drops them if focus has moved to another application.
pending_jobs = 0
pending_jobs_lock = threading.Lock()
def _focused_app_unchanged(
target: Optional[focused_window.FocusedWindow],
) -> bool:
"""Return True unless focus verifiably moved to a different app.
A missing baseline or a failed re-probe stays permissive: without
a reliable identity the segment keeps the injector's own targeting.
"""
if target is None:
return True
current = focused_window.get_focused_window()
if current is None:
return True
return current.identity_blob() == target.identity_blob()
def _start_focus_probe() -> queue.Queue[Optional[focused_window.FocusedWindow]]:
"""Probe the focused window now, off the calling thread.
Focus probes shell out to compositor tools under their own
timeouts, so each runs on a fresh daemon thread: the recognition
thread never waits on them, and — unlike a shared worker — an
earlier slow probe cannot delay this one past the focus change it
is meant to capture.
"""
probe_result: queue.Queue[Optional[focused_window.FocusedWindow]] = queue.Queue(
maxsize=1
)
def probe() -> None:
try:
probe_result.put(focused_window.get_focused_window())
except (OSError, RuntimeError, queue.Full) as exc:
# get_focused_window() reports Optional and should never
# raise; if one escapes anyway the failed probe is
# indistinguishable from unavailable focus information
# unless it is logged — and the waiting job must still
# be released.
logger.exception("Focus probe crashed unexpectedly: %s", exc)
probe_result.put(None)
threading.Thread(target=probe, daemon=True, name="vocalinux-focus-probe").start()
return probe_result
def _probe_result(
probe: Optional[queue.Queue[Optional[focused_window.FocusedWindow]]],
) -> Optional[focused_window.FocusedWindow]:
"""Return the focus identity a submit-time probe captured.
The probe thread starts the moment the segment is submitted, so
joining it here adds no wait beyond the probe's own runtime.
"""
if probe is None:
return None
return probe.get()
def post_process_and_inject(
text_to_inject: str,
target_probe: Optional[queue.Queue[Optional[focused_window.FocusedWindow]]],
) -> None:
"""Apply the configured post-processing script, then inject.
Runs on the post-processing worker, never the recognition thread.
An unexpected failure falls back to the unprocessed segment so
dictation is never lost inside the worker.
Args:
text_to_inject: Finalised transcription segment.
target_probe: Probe of the app focused when the segment was
dictated; the segment is dropped if focus has since moved
elsewhere. None keeps the injector's own targeting.
"""
nonlocal pending_jobs
try:
if not accepting_injections.is_set():
return
try:
processed_text = apply_post_processing(text_to_inject, config_manager)
except Exception:
logger.exception("Post-processing raised unexpectedly; injecting original text")
processed_text = text_to_inject
if processed_text is None or not accepting_injections.is_set():
return
# Pad-bound segments land in the dictation pad regardless of
# where focus sits, so a focus change since the segment was
# dictated must not drop them. The destination is decided
# once here and passed to the injector: re-reading the
# toggle at delivery could flip it after the check was
# skipped and send the segment to the focused application.
to_pad = dictate_to_pad_enabled()
if not to_pad and not _focused_app_unchanged(_probe_result(target_probe)):
logger.info("Dropping queued segment: focus moved to another application")
return
with injection_lock:
if not accepting_injections.is_set():
return
inject_transcription(processed_text, to_pad)
finally:
with pending_jobs_lock:
pending_jobs -= 1
def _run_action(
action: str,
target_probe: Optional[queue.Queue[Optional[focused_window.FocusedWindow]]],
) -> bool:
"""Run one voice-command action on the post-processing worker.
Actions send keystrokes through the same injector as transcription
text, so they queue on the same worker in spoken order and are
bound to the app focused when the command was issued: even a
submission that looks immediate can run after a context switch,
so the binding applies to every action. The job waits on the
probe for its full duration rather than racing it — the probe's
compositor calls carry their own one-second timeouts and
short-circuit on tools that are absent, so it answers in
milliseconds on a healthy desktop and always terminates; a valid
command is therefore never discarded over timing, and the
verified result is the only thing that can drop it.
"""
nonlocal pending_jobs
try:
if not accepting_injections.is_set():
return False
# Pad-bound actions run on the dictation pad no matter which
# application is focused, so a focus change since the command
# was issued must not drop them; only app-bound deliveries are
# focus-checked.
if action == "delete_last":
targets_pad = bool(last_injected["to_pad"])
else:
targets_pad = dictate_to_pad_enabled()
if not targets_pad and not _focused_app_unchanged(_probe_result(target_probe)):
logger.info("Dropping action: focus moved to another application")
return False
with injection_lock:
if not accepting_injections.is_set():
return False
# "delete that" follows the segment it removes: the pad
# when the last delivered text went there (even if capture
# was toggled off since) and the focused application when
# it was injected (even if capture was toggled on). Every
# other editing command is handled pad-side while capturing
# so its shortcuts never leak into whichever application
# holds focus.
if action == "delete_last":
if not action_handler.last_injected_text:
return True
if last_injected["to_pad"]:
# Only the pad's own segment bookkeeping may size
# the deletion: a manual edit or a pad "undo"
# blurs the boundaries (last_segment is None), and
# falling back to the recorded text's length could
# erase characters the user typed after dictating.
# Refuse rather than misdelete — the tracking is
# still cleared so a repeated command cannot retry
# the stale length.
target = dictation_pad.last_segment
if target is None:
action_handler.set_last_injected_text("")
last_injected["to_pad"] = False
return True
deleted = dictation_pad.delete_last_chars(len(target))
if deleted:
action_handler.set_last_injected_text("")
last_injected["to_pad"] = False
return True
handled_app: bool = action_handler.handle_action(action)
return handled_app
if targets_pad:
handled = bool(dictation_pad.handle_action(action))
if handled and action in ("undo", "redo") and last_injected["to_pad"]:
# Pad history moved: retarget "delete that" at the
# segment now at the pad's tail. Keep the pad
# destination even when the tail is empty — a
# redo can restore it.
action_handler.set_last_injected_text(dictation_pad.last_segment or "")
return handled
handled_app = action_handler.handle_action(action)
return handled_app
finally:
with pending_jobs_lock:
pending_jobs -= 1
def action_callback_wrapper(action: str) -> Optional[Future]:
"""Queue a voice-command action behind any pending text jobs.
Args:
action: Voice-command action from the speech engine.
Returns:
The queued worker future, or None while the application is
quitting. Callers such as tests can wait on it; the speech
engine ignores the return value.
"""
nonlocal pending_jobs
if not accepting_injections.is_set():
return None
# Every action is bound to the app focused at submit time:
# submission does not guarantee immediate execution, so there is
# always a focus-change window between the two. The probe runs
# off this thread and the worker only waits on it briefly, so
# the binding costs nothing on a healthy desktop.
# Probing and submitting happen inside the lock so queue order
# matches the order these callbacks ran in — a job that saw an
# empty queue cannot end up waiting behind one that arrived while
# its submission was still in flight.
with pending_jobs_lock:
pending_jobs += 1
target_probe = _start_focus_probe()
try:
future: Future = post_processing_executor.submit(
_run_action, action, target_probe
)
except RuntimeError:
# The quit path already shut the worker down.
pending_jobs -= 1
return None
return future
def _reset_last_injected() -> None:
"""Clear the last-injected buffer on the post-processing worker."""
action_handler.set_last_injected_text("")
last_injected["to_pad"] = False
def _shutdown_post_processing() -> None:
"""Stop the post-processing worker for application quit.
Clearing the flag makes queued or in-flight jobs drop their
results, and pending submissions are cancelled without waiting on
a running script — quit must not freeze the tray on the script's
own timeout. An injection already in progress is asked to abort
so the lock wait stays bounded by a single chunk's subprocess
timeout instead of a long transcription's whole budget, and the
injector is never stopped mid-inject.
"""
accepting_injections.clear()
text_system.abort_injections()
with injection_lock:
pass
post_processing_executor.shutdown(wait=False, cancel_futures=True)
def text_callback_wrapper(text: str) -> Optional[Future]:
"""Bridge between speech engine text events and the text injector.
Called on the recognition thread with each finalised transcription
segment. Strips leading whitespace and trailing spaces/tabs (but
preserves trailing newlines from voice commands), then hands the
segment to the single post-processing worker — a pass-through when
no script is configured — which applies the separator rules and
injects via TextInjector in spoken order.
Args:
text: Raw transcription segment from the speech engine.
Returns:
The queued worker future, or None when the segment was dropped
before submission (an empty segment, or the application is
quitting). Callers such as tests can wait on it; the speech
engine ignores the return value.
"""
nonlocal pending_jobs
# Preserve trailing newlines ("new line" / "new paragraph"); only
# strip spaces/tabs that whisper sometimes wraps around tokens.
text_to_inject = _normalize_segment_text(text)
if not text_to_inject or not accepting_injections.is_set():
return None
# A backlog or a configured script means this segment can inject
# long after it was dictated; bind it to the app it targets now so
# it cannot land in whatever the user switched to meanwhile. The
# probe runs off this thread: compositor tools could otherwise
# stall the consumer of the bounded audio-segment queue for seconds
# per segment.
script_configured = bool(config_manager.get_str("post_processing", "script_path", ""))
with pending_jobs_lock:
may_queue = pending_jobs > 0 or script_configured
pending_jobs += 1
target_probe = _start_focus_probe() if may_queue else None
try:
future: Future = post_processing_executor.submit(
post_process_and_inject, text_to_inject, target_probe
)
except RuntimeError:
# The quit path already shut the worker down.
pending_jobs -= 1
return None
return future
def on_state_change(state: RecognitionState) -> None:
"""Reset the last-injected buffer when a listening session ends.
Also commits the just-finished dictation session to the
transcription history as a single snippet.
transcription history as a single snippet. With jobs still queued
the reset is queued behind them, so a "delete that" action
recognised just before the session ended still sees the text it
refers to.
"""
nonlocal session_open, session_worker, latest_snippet_id
nonlocal ended_session_epoch, ended_session_worker, session_started_floor
nonlocal ended_session_epoch, session_started_floor, latest_snippet_epoch
if state in (RecognitionState.IDLE, RecognitionState.ERROR):
if state == RecognitionState.IDLE:
action_handler.set_last_injected_text("")
last_injected["to_pad"] = False
with pending_jobs_lock:
backlog = pending_jobs > 0
if backlog and accepting_injections.is_set():
try:
post_processing_executor.submit(_reset_last_injected)
except RuntimeError:
action_handler.set_last_injected_text("")
last_injected["to_pad"] = False
else:
action_handler.set_last_injected_text("")
last_injected["to_pad"] = False
with session_lock:
session_open = False
closing_worker = session_worker
@@ -879,7 +1225,12 @@ def main():
latest_snippet_id = transcription_history.add(
joined, expected_epoch=ended_session_epoch
)
ended_session_worker = closing_worker
latest_snippet_epoch = ended_session_epoch
if latest_snippet_id is not None and closing_worker is not None:
ended_worker_snippets[closing_worker] = (
latest_snippet_id,
ended_session_epoch,
)
else:
with session_lock:
if not session_open:
@@ -893,15 +1244,25 @@ def main():
# those captured after the last clear().
if session_segments:
cleared_at = transcription_history.cleared_at
# A clear() during the unclosed session bumped the
# epoch since the previous close — judge against
# the live epoch, not the stale ended one, or
# valid post-clear dictation is refused.
stray_epoch = transcription_history.epoch
latest_snippet_id = transcription_history.add(
" ".join(
text
for text, started_at in session_segments
if started_at > cleared_at
),
expected_epoch=ended_session_epoch,
expected_epoch=stray_epoch,
)
ended_session_worker = leftover_worker
latest_snippet_epoch = stray_epoch
if latest_snippet_id is not None and leftover_worker is not None:
ended_worker_snippets[leftover_worker] = (
latest_snippet_id,
stray_epoch,
)
session_segments.clear()
# Connect speech recognition to text injection and action handling.
@@ -910,48 +1271,6 @@ def main():
# injection keeps the plain text callback.
speech_engine.register_segment_callback(record_history_segment)
def action_callback_wrapper(action: str) -> bool:
"""Route editing voice commands to the pad while capture is on.
"delete that" follows the segment it removes: the pad when the
last delivered text went there (even if capture was toggled off
since) and the focused application when it was injected (even if
capture was toggled on). Every other editing command is handled
pad-side while capturing so its shortcuts never leak into
whichever application holds focus.
"""
if action == "delete_last":
if not action_handler.last_injected_text:
return True
if last_injected["to_pad"]:
# Only the pad's own segment bookkeeping may size the
# deletion: a manual edit or a pad "undo" blurs the
# boundaries (last_segment is None), and falling back to
# the recorded text's length could erase characters the
# user typed after dictating. Refuse rather than
# misdelete — the tracking is still cleared so a repeated
# command cannot retry the stale length.
target = dictation_pad.last_segment
if target is None:
action_handler.set_last_injected_text("")
last_injected["to_pad"] = False
return True
deleted = dictation_pad.delete_last_chars(len(target))
if deleted:
action_handler.set_last_injected_text("")
last_injected["to_pad"] = False
return True
return bool(action_handler.handle_action(action))
if dictate_to_pad_enabled():
handled = bool(dictation_pad.handle_action(action))
if handled and action in ("undo", "redo") and last_injected["to_pad"]:
# Pad history moved: retarget "delete that" at the segment
# now at the pad's tail. Keep the pad destination even
# when the tail is empty — a redo can restore it.
action_handler.set_last_injected_text(dictation_pad.last_segment or "")
return handled
return bool(action_handler.handle_action(action))
speech_engine.register_text_callback(text_callback_wrapper)
speech_engine.register_action_callback(action_callback_wrapper)
speech_engine.register_state_callback(on_state_change)
@@ -961,6 +1280,7 @@ def main():
speech_engine=speech_engine,
text_injector=text_system,
transcription_history=transcription_history,
on_quit=_shutdown_post_processing,
dictation_pad=dictation_pad,
)
+70
View File
@@ -0,0 +1,70 @@
"""Post-processing of transcribed text via an external executable."""
import logging
import subprocess
from typing import TYPE_CHECKING, Optional
from .utils.host_process import host_env
if TYPE_CHECKING:
from .ui.config_manager import ConfigManager
logger = logging.getLogger(__name__)
class PostProcessor:
"""Pipes transcribed text through a user-defined executable and returns the output."""
def __init__(self, script_path: str) -> None:
self.script_path = script_path
def process(self, text: str) -> str:
"""Run the script with text on stdin; return stdout. Falls back to original on failure."""
logger.info("Running post-processor: %s", self.script_path)
try:
result = subprocess.run(
[self.script_path],
input=text,
capture_output=True,
text=True,
timeout=10,
env=host_env(),
)
except (OSError, subprocess.SubprocessError, UnicodeError) as e:
logger.warning("Post-processor failed: %s", e)
return text
if result.returncode != 0:
logger.warning("Post-processor exited %d: %s", result.returncode, result.stderr.strip())
return text
# Success is logged without the output text: transformed dictation can
# be sensitive, and logs are retained and exportable.
logger.debug("Post-processor succeeded, %d bytes out", len(result.stdout))
return self._strip_convenience_newline(text, result.stdout)
@staticmethod
def _strip_convenience_newline(original: str, transformed: str) -> str:
"""Drop one trailing newline a script may add, keeping meaningful ones.
Line-oriented tools (``echo``, ``print``) terminate their output with a
newline the input never had; injecting it would press Return after
every segment. Newlines beyond that — like the ``\\n\\n`` a "new
paragraph" voice command leaves — are part of the text and stay.
"""
if transformed.endswith("\n") and not original.endswith("\n"):
return transformed[:-1]
return transformed
def apply_post_processing(text: str, config_manager: "ConfigManager") -> Optional[str]:
"""Apply post-processing script to text if configured.
Returns processed text, or None to signal the caller should skip injection.
Returns original text unchanged when no script is configured.
"""
script_path = config_manager.get_str("post_processing", "script_path", "")
if not script_path:
return text
result = PostProcessor(script_path).process(text)
return result if result else None
@@ -52,10 +52,12 @@ class RemoteDesktopPortalError(RuntimeError):
failure -- characters for ``inject_text``, taps for ``tap_keysym`` and
steps for ``send_shortcut`` -- so the caller can retry only the
remainder through a fallback backend instead of replaying the whole
request.
request. ``None`` means the delivery could not be tracked -- a timed-out
job that never reported back may or may not have emitted portal events,
so the caller must not replay any part of the request.
"""
def __init__(self, message: str = "", delivered: int = 0) -> None:
def __init__(self, message: str = "", delivered: Optional[int] = 0) -> None:
super().__init__(message)
self.delivered = delivered
@@ -382,7 +384,10 @@ class RemoteDesktopPortal:
if "result" in box:
return box["result"]
error = box.get("error")
delivered = getattr(error, "delivered", 0) if error is not None else 0
# A job that never reported back may still have delivered part of
# the request before noticing the cancel -- None marks that count
# as unknowable so the caller does not replay delivered input.
delivered = getattr(error, "delivered", 0) if error is not None else None
raise RemoteDesktopPortalError("Timed out waiting for the portal", delivered=delivered)
if "error" in box:
error = box["error"]
@@ -628,7 +633,7 @@ class RemoteDesktopPortal:
delivered += 1
self._notify_key_state(keysym, _KEY_RELEASED)
except RemoteDesktopPortalError as e:
e.delivered = max(e.delivered, delivered)
e.delivered = max(e.delivered or 0, delivered)
raise
except Exception as e:
raise RemoteDesktopPortalError(str(e), delivered=delivered) from e
@@ -654,7 +659,7 @@ class RemoteDesktopPortal:
delivered = index + 1
self._notify_key_state(keysym, _KEY_RELEASED)
except RemoteDesktopPortalError as e:
e.delivered = max(e.delivered, delivered)
e.delivered = max(e.delivered or 0, delivered)
raise
except Exception as e:
raise RemoteDesktopPortalError(str(e), delivered=delivered) from e
@@ -687,7 +692,7 @@ class RemoteDesktopPortal:
for modifier in reversed(modifier_keysyms):
self._notify_key_state(modifier, _KEY_RELEASED)
except RemoteDesktopPortalError as e:
e.delivered = max(e.delivered, delivered)
e.delivered = max(e.delivered or 0, delivered)
self._release_held_modifiers(modifier_keysyms)
raise
except Exception as e:
+219 -26
View File
@@ -25,7 +25,13 @@ from .ibus_engine import (
is_ibus_available,
is_ibus_daemon_running,
)
from .remote_desktop_portal import KEYSYM_BACKSPACE, RemoteDesktopPortal, RemoteDesktopPortalError
from .remote_desktop_portal import (
KEYSYM_BACKSPACE,
RemoteDesktopPortal,
RemoteDesktopPortalError,
)
_PORTAL_SUBMIT_TIMEOUT_S = 195.0 # portal's _START_TIMEOUT_S + _REQUEST_TIMEOUT_S
logger = logging.getLogger(__name__)
@@ -91,6 +97,22 @@ class DesktopEnvironment(Enum):
UNKNOWN = "unknown"
class _InjectionAborted(Exception):
"""Raised inside injection helpers when shutdown cuts an injection short."""
class _PartiallyTyped(subprocess.CalledProcessError):
"""A chunked type call delivered a prefix of the text before failing.
``typed`` counts the characters already on screen so a fallback can
continue from the remainder instead of typing them a second time.
"""
def __init__(self, typed: int, cause: subprocess.CalledProcessError) -> None:
super().__init__(cause.returncode, cause.cmd, output=cause.output, stderr=cause.stderr)
self.typed = typed
class TextInjector:
"""
Class for injecting text into the active application.
@@ -99,6 +121,16 @@ class TextInjector:
application window, supporting both X11 and Wayland environments.
"""
# Class-level fallback so objects built without __init__ (test helpers
# using __new__) still answer the abort checks; __init__ rebinds a fresh
# per-instance event, so the shared default is never the one that is set.
_abort_injections: threading.Event = threading.Event()
# Characters confirmed delivered by the most recent inject_text call:
# the full length on success, the confirmed prefix after a partial
# failure, -1 when delivery is unknowable, 0 when nothing was typed.
last_typed_count: int = 0
def __init__(self, wayland_mode: bool = False):
"""
Initialize the text injector.
@@ -115,6 +147,8 @@ class TextInjector:
self._ibus_init_failed = False
self._ibus_init_thread: Optional[threading.Thread] = None
self._state_lock = threading.Lock()
self._abort_injections = threading.Event()
self.last_typed_count = 0
self._clipboard_tool_health = {}
self._clipboard_timeout = 0.35
# Overlapping ydotool pastes: bump generation to cancel stale restores;
@@ -1028,13 +1062,15 @@ class TextInjector:
self.environment = DesktopEnvironment.WAYLAND
logger.info(log_message, *args)
def _try_inject_with_portal(self, text: str) -> Tuple[bool, str]:
def _try_inject_with_portal(self, text: str) -> Tuple[bool, Optional[str]]:
"""Send text through the RemoteDesktop portal.
Returns ``(True, "")`` on success. On failure the second element is
the part of ``text`` the portal had not typed yet -- the whole string
when it never got started, the tail when it failed mid-string -- so
the fallback backend does not duplicate delivered characters.
the fallback backend does not duplicate delivered characters. It is
``None`` when the job timed out without reporting its count: whatever
reached the compositor is then unknowable and nothing may be replayed.
"""
portal = getattr(self, "_portal", None)
if portal is None:
@@ -1043,16 +1079,20 @@ class TextInjector:
return bool(portal.inject_text(text)), ""
except RemoteDesktopPortalError as e:
logger.warning(f"RemoteDesktop portal injection failed: {e}")
if e.delivered is None:
return False, None
return False, text[e.delivered :]
except Exception as e:
logger.warning(f"RemoteDesktop portal injection failed: {e}")
return False, text
def _try_portal_keypress(self, keysym: int, count: int = 1) -> Tuple[bool, int]:
def _try_portal_keypress(self, keysym: int, count: int = 1) -> Tuple[bool, Optional[int]]:
"""Tap one keysym ``count`` times through the portal.
Returns ``(True, 0)`` on success; on failure the taps still owed, so
the fallback does not re-send presses the portal already delivered.
``None`` means a timed-out job never reported its count, so no part
of the request may be replayed.
"""
portal = getattr(self, "_portal", None)
if portal is None:
@@ -1062,6 +1102,8 @@ class TextInjector:
return True, 0
except RemoteDesktopPortalError as e:
logger.warning(f"RemoteDesktop portal key event failed: {e}")
if e.delivered is None:
return False, None
return False, max(0, count - e.delivered)
except Exception as e:
logger.warning(f"RemoteDesktop portal key event failed: {e}")
@@ -1069,11 +1111,13 @@ class TextInjector:
def _try_portal_shortcut(
self, steps: List[Tuple[List[str], str]]
) -> Tuple[bool, List[Tuple[List[str], str]]]:
) -> Tuple[bool, Optional[List[Tuple[List[str], str]]]]:
"""Send parsed shortcut steps through the portal.
Returns ``(True, [])`` on success; on failure the steps still to
send, so the fallback does not re-run steps that already fired.
``None`` means a timed-out job never reported its count, so no step
may be replayed.
"""
portal = getattr(self, "_portal", None)
if portal is None:
@@ -1083,6 +1127,8 @@ class TextInjector:
return True, []
except RemoteDesktopPortalError as e:
logger.warning(f"RemoteDesktop portal shortcut failed: {e}")
if e.delivered is None:
return False, None
return False, list(steps[e.delivered :])
except Exception as e:
logger.warning(f"RemoteDesktop portal shortcut failed: {e}")
@@ -1515,6 +1561,17 @@ class TextInjector:
except Exception as e:
logger.debug(f"Could not show clipboard notification: {e}")
def abort_injections(self) -> None:
"""Ask in-flight injections to stop at their next safe boundary.
Called on application quit so a worker holding the injection lock
finishes promptly: chunk loops and the Wayland typing call poll the
flag and raise ``_InjectionAborted`` instead of running to
completion, while every subprocess that is already in flight still
returns on its own timeout.
"""
self._abort_injections.set()
def inject_text(self, text: str) -> bool:
"""
Inject text into the currently focused application.
@@ -1525,10 +1582,15 @@ class TextInjector:
Returns:
True if injection was successful, False otherwise
"""
self.last_typed_count = 0
if not text or not text.strip():
logger.debug("Empty text provided, skipping injection")
return True
if self._abort_injections.is_set():
return False
logger.info(f"Starting text injection: '{text}' (length: {len(text)})")
logger.debug(f"Environment: {self.environment}")
@@ -1591,6 +1653,11 @@ class TextInjector:
try:
self._inject_with_wayland_tool(text)
except subprocess.CalledProcessError as e:
# A chunked type call may have delivered a prefix; the
# fallback continues from the untyped remainder so text
# already on screen is never sent a second time.
typed = e.typed if isinstance(e, _PartiallyTyped) else 0
remaining = text[typed:]
stderr_msg = e.stderr.strip() if e.stderr else "No stderr output"
unsupported_wayland = (
"compositor does not support" in str(e).lower()
@@ -1614,7 +1681,11 @@ class TextInjector:
)
with self._state_lock:
self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
self._inject_with_xdotool(text)
try:
self._inject_with_xdotool(remaining)
except _PartiallyTyped as nested:
# Keep the count relative to the original text.
raise _PartiallyTyped(typed + nested.typed, nested) from nested
else:
raise
logger.info("Text injection completed successfully")
@@ -1626,7 +1697,44 @@ class TextInjector:
daemon=True,
).start()
self.last_typed_count = len(text)
return True
except _InjectionAborted:
self.last_typed_count = -1
logger.info("Injection aborted by shutdown")
return False
except _PartiallyTyped as e:
# A prefix is already on screen; the clipboard fallback must hold
# only the untyped remainder or a manual paste duplicates it.
# Reporting success would mark the whole transcription injected,
# so "delete that" could erase text before the typed prefix.
logger.error(f"Text injection failed after a prefix was typed: {e}")
self.last_typed_count = e.typed
remaining = text[e.typed :]
try:
if self._copy_to_clipboard(remaining):
logger.info(
"Remaining text copied to clipboard as fallback - user can paste manually"
)
self._show_clipboard_fallback_notification()
except (OSError, subprocess.SubprocessError, RuntimeError) as clipboard_error:
logger.debug(f"Clipboard fallback also failed: {clipboard_error}")
try:
from ..ui.audio_feedback import play_error_sound
play_error_sound()
except ImportError:
logger.warning("Could not import audio feedback module")
return False
except subprocess.TimeoutExpired as e:
# A type call that ran past its bound may have delivered only part
# of the text; putting the full text on the clipboard would let a
# manual paste duplicate the fragment already typed. How much
# arrived is unknowable, so the caller must not trust any count.
self.last_typed_count = -1
logger.error(f"Text injection timed out, text may be partially typed: {e}")
return False
except Exception as e:
logger.error(f"Failed to inject text: {e}", exc_info=True)
@@ -1635,7 +1743,7 @@ class TextInjector:
logger.info("Text copied to clipboard as fallback - user can paste manually")
self._show_clipboard_fallback_notification()
return True
except Exception as clipboard_error:
except (OSError, subprocess.SubprocessError, RuntimeError) as clipboard_error:
logger.debug(f"Clipboard fallback also failed: {clipboard_error}")
try:
@@ -1687,6 +1795,7 @@ class TextInjector:
stderr=subprocess.PIPE,
text=True,
check=False,
timeout=2,
)
if active_window.returncode == 0 and active_window.stdout.strip():
@@ -1698,6 +1807,7 @@ class TextInjector:
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
timeout=5,
)
# Wait a moment for the focus to take effect
time.sleep(0.2)
@@ -1709,6 +1819,9 @@ class TextInjector:
max_retries = 2
logger.debug(f"Starting xdotool injection with {max_retries} max retries")
# Retries resume at the first untyped chunk: restarting from the
# top would duplicate the chunks a failed attempt already sent.
typed = 0
for retry in range(max_retries + 1):
try:
# Inject in smaller chunks to avoid issues with very long text
@@ -1718,7 +1831,9 @@ class TextInjector:
f"Splitting text into {total_chunks} chunks of max {chunk_size} chars"
)
for i in range(0, len(text), chunk_size):
for i in range(typed, len(text), chunk_size):
if self._abort_injections.is_set():
raise _InjectionAborted
chunk = text[i : i + chunk_size]
chunk_num = (i // chunk_size) + 1
@@ -1734,6 +1849,7 @@ class TextInjector:
text=True,
timeout=5,
)
typed = min(i + chunk_size, len(text))
# Add a larger delay between chunks
if i + chunk_size < len(text):
@@ -1752,7 +1868,9 @@ class TextInjector:
time.sleep(0.5) # Wait before retry
else:
logger.error(f"Final attempt failed: {chunk_error.stderr}")
raise # Re-raise on final attempt
# typed counts only completed chunks, so the
# clipboard fallback gets the untyped remainder.
raise _PartiallyTyped(typed, chunk_error) from chunk_error
except subprocess.TimeoutExpired:
if retry < max_retries:
logger.warning(
@@ -1784,6 +1902,7 @@ class TextInjector:
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
timeout=2,
)
except Exception:
pass # Ignore any errors from this command
@@ -1961,6 +2080,8 @@ class TextInjector:
"terminal" if use_terminal_paste else "standard",
paste_cmd,
)
if self._abort_injections.is_set():
raise _InjectionAborted
subprocess.run(
paste_cmd,
check=True,
@@ -2452,6 +2573,11 @@ class TextInjector:
# still-held physical modifier, so the wait stays ahead of every tool.
self._wait_for_modifiers_released()
# Characters the portal confirmed before a demote: partial-type counts
# raised later are relative to the shortened text and must be rebased
# by this prefix or the caller replays delivered input.
typed_prefix = 0
if self.wayland_tool == "portal":
portal_ok, remaining_text = self._try_inject_with_portal(text)
if portal_ok:
@@ -2460,10 +2586,21 @@ class TextInjector:
raise RuntimeError(
"RemoteDesktop portal injection failed and no Wayland fallback is available"
)
if remaining_text is None:
# The timed-out job never reported its delivered count, so
# retyping any part could duplicate what already arrived.
# Surface as a timeout: the caller drops the injection rather
# than offering a clipboard copy of the full text.
raise subprocess.TimeoutExpired(cmd="portal", timeout=_PORTAL_SUBMIT_TIMEOUT_S)
if not remaining_text:
return
typed_prefix = len(text) - len(remaining_text)
if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
self._inject_with_xdotool(remaining_text)
try:
self._inject_with_xdotool(remaining_text)
except _PartiallyTyped as nested:
# Keep the count relative to the original text.
raise _PartiallyTyped(typed_prefix + nested.typed, nested) from nested
return
text = remaining_text
@@ -2488,21 +2625,48 @@ class TextInjector:
"(character-by-character; text may be scrambled on non-US layouts)"
)
if self.wayland_tool == "wtype":
cmd = ["wtype", text]
else: # ydotool
# Keep key-delay > 0 to avoid Shift-leak ("Can you" -> "CAN YOu").
# Low delay so fallback typing finishes quickly for long phrases.
key_delay = os.environ.get("VOCALINUX_YDOTOOL_KEY_DELAY", "2")
cmd = ["ydotool", "type", "--key-delay", key_delay, text]
try:
subprocess.run(cmd, check=True, stderr=subprocess.PIPE, text=True, env=host_env())
except subprocess.CalledProcessError as e:
# Re-raise with stderr preserved for better diagnostics
raise subprocess.CalledProcessError(
e.returncode, e.cmd, output=e.output, stderr=e.stderr
) from e
# Type in chunks so shutdown can abort between subprocess calls — one
# whole-string call could not be interrupted for its full
# length-scaled duration. Each chunk's budget is a generous multiple
# of its expected duration: a legitimately slow type must never be cut
# mid-text — only a stall far beyond it is.
chunk_size = 200
for i in range(0, len(text), chunk_size):
if self._abort_injections.is_set():
raise _InjectionAborted
chunk = text[i : i + chunk_size]
if self.wayland_tool == "wtype":
cmd = ["wtype", chunk]
# wtype types at compositor pace; the budget scales with the
# chunk length so only a wedged process can ever hit it.
type_timeout = max(5, len(chunk) * 0.05)
else: # ydotool
# Keep key-delay > 0 to avoid Shift-leak ("Can you" -> "CAN YOu").
# Low delay so fallback typing finishes quickly for long phrases.
key_delay = os.environ.get("VOCALINUX_YDOTOOL_KEY_DELAY", "2")
cmd = ["ydotool", "type", "--key-delay", key_delay, chunk]
type_timeout = max(5, len(chunk) * self._key_delay_seconds(key_delay) * 4)
try:
subprocess.run(
cmd,
check=True,
stderr=subprocess.PIPE,
text=True,
timeout=type_timeout,
env=host_env(),
)
except subprocess.TimeoutExpired:
logger.error(f"{self.wayland_tool} type timed out; text may be partially typed")
raise
except subprocess.CalledProcessError as e:
if i > 0:
# Earlier chunks are already on screen; let the caller
# continue from the untyped remainder.
raise _PartiallyTyped(typed_prefix + i, e) from e
# Re-raise with stderr preserved for better diagnostics
raise subprocess.CalledProcessError(
e.returncode, e.cmd, output=e.output, stderr=e.stderr
) from e
logger.info(
f"Text injected using {self.wayland_tool}: '{text[:20]}...' ({len(text)} chars)"
@@ -2520,6 +2684,9 @@ class TextInjector:
"""
logger.debug(f"Injecting keyboard shortcut: {shortcut}")
if self._abort_injections.is_set():
return False
try:
if (
self.environment == DesktopEnvironment.X11
@@ -2562,12 +2729,22 @@ class TextInjector:
try:
cmd = ["xdotool", "key", "--clearmodifiers", shortcut]
subprocess.run(cmd, env=host_env(env), check=True, stderr=subprocess.PIPE, text=True)
subprocess.run(
cmd,
env=host_env(env),
check=True,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
logger.debug(f"Keyboard shortcut '{shortcut}' injected successfully")
return True
except subprocess.CalledProcessError as e:
logger.error(f"xdotool shortcut error: {e.stderr}")
return False
except subprocess.TimeoutExpired:
logger.error(f"xdotool shortcut timed out: '{shortcut}'")
return False
def _inject_shortcut_with_wayland_tool(self, shortcut: str) -> bool:
"""
@@ -2589,12 +2766,21 @@ class TextInjector:
# wait it out first -- same reason and placement as _inject_with_wayland_tool.
self._wait_for_modifiers_released()
# Characters the portal confirmed before a demote: partial-type counts
# raised later are relative to the shortened text and must be rebased
# by this prefix or the caller replays delivered input.
typed_prefix = 0
if self.wayland_tool == "portal":
portal_ok, remaining_steps = self._try_portal_shortcut(steps)
if portal_ok:
return True
if not self._demote_portal_backend():
return False
if remaining_steps is None:
# Untracked delivery: replaying the shortcut could trigger
# actions the timed-out job already sent.
return False
if not remaining_steps:
return True
steps = remaining_steps
@@ -2821,6 +3007,9 @@ class TextInjector:
if count <= 0:
return True
if self._abort_injections.is_set():
return False
logger.debug(f"Sending {count} backspace key event(s)")
if (
@@ -2869,6 +3058,10 @@ class TextInjector:
return True
if not self._demote_portal_backend():
return False
if remaining is None:
# Untracked delivery: replaying presses could delete text the
# timed-out job already removed.
return False
if remaining <= 0:
return True
return self.press_backspace(remaining)
+3
View File
@@ -207,6 +207,9 @@ DEFAULT_CONFIG = {
"enabled": True, # Keep recent dictation snippets in the tray menu
"max_items": 10, # How many snippets to retain (in-memory only, cleared on quit)
},
"post_processing": {
"script_path": "", # Path to executable; empty = disabled
},
"dictionary": {
# Preserve the custom-dictionary contract used by the accessibility scanner.
"enabled": False,
File diff suppressed because it is too large Load Diff
+67
View File
@@ -2706,6 +2706,7 @@ class SettingsDialog(Gtk.Dialog):
SettingsPage("performance", "Performance", "power-profile-performance-symbolic"),
SettingsPage("application", "Application", "preferences-system-symbolic"),
SettingsPage("advanced", "Advanced", "applications-engineering-symbolic"),
SettingsPage("post-processing", "Post-Processing", "utilities-terminal-symbolic"),
SettingsPage("about", "About", "help-about-symbolic"),
]
pages_by_name = {page.name: page for page in self._pages}
@@ -2720,6 +2721,7 @@ class SettingsDialog(Gtk.Dialog):
self.power_tab = pages_by_name["performance"].box
self.general_tab = pages_by_name["application"].box
self.advanced_tab = pages_by_name["advanced"].box
self.post_processing_tab = pages_by_name["post-processing"].box
self.about_tab = pages_by_name["about"].box
# Each page is wrapped in a vertical ScrolledWindow: without one, the
@@ -2814,6 +2816,7 @@ class SettingsDialog(Gtk.Dialog):
self._build_gpu_section()
self._build_general_section()
self._build_advanced_section()
self._build_post_processing_section()
self._build_about_section()
self._build_sidebar_footer(sidebar_box)
@@ -3567,6 +3570,10 @@ class SettingsDialog(Gtk.Dialog):
return False
if not self.dictionary_manager.set_terms_enabled(bool(state)):
self.dictionary_feedback_label.set_text("Could not save custom terms setting.")
self._refresh_dictionary_ui()
# Returning True suppresses GTK's own state-set so the switch
# shows the value that actually persisted, not the requested one.
return True
self._refresh_dictionary_ui()
return False
@@ -6638,6 +6645,63 @@ class SettingsDialog(Gtk.Dialog):
self.gpu_device_combo.connect("changed", self._on_advanced_param_changed)
def _build_post_processing_section(self) -> None:
"""Build the Post-Processing section."""
group = PreferencesGroup(
title="Post-Processing Script",
description=(
"Run an executable on each transcription result. "
"The script receives the text on stdin and must write the replacement text to stdout."
),
)
path_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self.post_processor_entry = Gtk.Entry()
self.post_processor_entry.set_placeholder_text("/path/to/script.sh")
self.post_processor_entry.set_tooltip_text(
"Path to an executable that transforms transcribed text"
)
self.post_processor_entry.set_hexpand(True)
browse_button = Gtk.Button(label="Browse…")
browse_button.connect("clicked", self._on_post_processor_browse_clicked)
path_box.pack_start(self.post_processor_entry, True, True, 0)
path_box.pack_start(browse_button, False, False, 0)
script_row = PreferenceRow(
title="Script Path",
subtitle="Leave empty to disable post-processing",
widget=path_box,
)
group.add_row(script_row)
self.post_processing_tab.pack_start(group, False, False, 0)
self.post_processor_entry.connect("changed", self._on_post_processor_script_changed)
def _on_post_processor_browse_clicked(self, widget: Gtk.Button) -> None:
dialog = Gtk.FileChooserDialog(
title="Select Post-Processing Script",
parent=self,
action=Gtk.FileChooserAction.OPEN,
)
dialog.add_buttons(
Gtk.STOCK_CANCEL,
Gtk.ResponseType.CANCEL,
Gtk.STOCK_OPEN,
Gtk.ResponseType.OK,
)
if dialog.run() == Gtk.ResponseType.OK:
self.post_processor_entry.set_text(dialog.get_filename())
dialog.destroy()
def _on_post_processor_script_changed(self, widget: Gtk.Entry) -> None:
if self._initializing or self._applying_settings:
return
path = widget.get_text().strip()
self.config_manager.set("post_processing", "script_path", path)
self.config_manager.save_config()
def _build_remote_server_section(self):
"""Build the Remote Server configuration section (shown when Remote API engine is selected)."""
self.remote_server_group = PreferencesGroup(
@@ -7319,6 +7383,9 @@ class SettingsDialog(Gtk.Dialog):
self.disable_internal_hotkey_switch.set_active(disable_internal_hotkey)
self._update_internal_hotkey_sensitivity(disable_internal_hotkey)
post_processing_settings = self.config_manager.get_settings().get("post_processing", {})
self.post_processor_entry.set_text(post_processing_settings.get("script_path", ""))
available_engines = get_available_engines()
available_count = 0
+19 -5
View File
@@ -149,6 +149,7 @@ class TrayIndicator:
speech_engine: SpeechRecognitionManagerProtocol,
text_injector: TextInjectorProtocol,
transcription_history: Optional[TranscriptionHistory] = None,
on_quit: Optional[Callable[[], None]] = None,
dictation_pad: Optional[Any] = None,
) -> None:
"""
@@ -159,12 +160,16 @@ class TrayIndicator:
text_injector: The text injector instance
transcription_history: Optional in-memory store of recent dictation
snippets. When provided, a "Recent Snippets" submenu is shown.
on_quit: Optional hook run during _quit before the text injector
is stopped (drains the post-processing worker so a queued or
in-flight segment cannot inject into a torn-down app)
dictation_pad: Optional in-app Dictation Pad window the tray menu
can open (the Wayland-safe dictation fallback, #726)
"""
self.speech_engine = speech_engine
self.text_injector = text_injector
self.transcription_history = transcription_history
self._on_quit = on_quit
self.dictation_pad = dictation_pad
# Shared with main() and the settings dialog: separate instances would
# overwrite each other's saves with stale in-memory copies.
@@ -388,11 +393,11 @@ class TrayIndicator:
def _setup_language_shortcuts(self) -> None:
"""(Re)build a listener per configured language shortcut (#805).
Each entry gets its own KeyboardShortcutManager because the backends
open the input devices read-only and never grab them, so parallel
listeners do not interfere. A binding whose gesture collides with the
main shortcut or an earlier language binding is skipped: the same
gesture cannot fire both.
Each entry gets its own KeyboardShortcutManager; on evdev the
backends all share one process-wide device layer, so parallel
listeners observe the same keyboards without competing grabs. A
binding whose gesture collides with the main shortcut or an earlier
language binding is skipped: the same gesture cannot fire both.
"""
# Same stop-first protection as _setup_keyboard_shortcuts: rebuilding
# below removes the release callback a held push-to-talk key needs to
@@ -1789,6 +1794,15 @@ class TrayIndicator:
self.overlay.destroy()
self.overlay = None
# Drain the post-processing worker before the injector stops: queued
# segments are cancelled and a running job drops its result, so no
# injection can land once the injector is gone.
if getattr(self, "_on_quit", None) is not None:
try:
self._on_quit()
except Exception:
logger.error("Error draining post-processing worker while quitting", exc_info=True)
# Stop the text injector (restores previous IBus engine)
if hasattr(self, "text_injector") and self.text_injector is not None:
self.text_injector.stop()