Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 65 additions & 16 deletions app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@

from app.config import Config, DEFAULTS, VALID_HOTKEY_KEYS
from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS
from app.writing_presets import WRITING_PRESET_KEYS, get_preset, preset_index
from app.writing_presets import (
CUSTOM_PRESET_KEY,
WRITING_PRESET_KEYS,
get_preset,
)
from app.hotkey_service import HotkeyWorker, hotkey_display_name
from app.audio_recorder import AudioRecorder, AudioRecorderError
from app.transcribe import transcribe, TranscribeError
Expand Down Expand Up @@ -286,8 +290,17 @@ def init_ui(self) -> None:

self.combo_writing_preset = QComboBox()
for key in WRITING_PRESET_KEYS:
if key == CUSTOM_PRESET_KEY:
self.combo_writing_preset.insertSeparator(
self.combo_writing_preset.count()
)
self.combo_writing_preset.addItem(t(f"preset.{key}.name"), key)
self.combo_writing_preset.setCurrentIndex(preset_index(self.config.writing_preset))
selected_preset = self.combo_writing_preset.findData(
self.config.writing_preset
)
self.combo_writing_preset.setCurrentIndex(
selected_preset if selected_preset >= 0 else 0
)

self.edit_compose_custom_preset = QPlainTextEdit()
self.edit_compose_custom_preset.setPlainText(self.config.compose_custom_preset_text)
Expand Down Expand Up @@ -554,6 +567,7 @@ def __init__(
autopaste: bool,
paste_service: PasteService,
custom_terms: Optional[list[str]] = None,
route_to_compose: bool = False,
) -> None:
super().__init__()
self.signals = _WorkerSignals()
Expand All @@ -566,6 +580,7 @@ def __init__(
self.autopaste = autopaste
self.paste_service = paste_service
self.custom_terms = list(custom_terms or [])
self.route_to_compose = route_to_compose

def _emit(self, signal_name: str, *args) -> None:
try:
Expand All @@ -587,19 +602,22 @@ def run(self) -> None:
if not transcript or not transcript.strip():
raise TranscribeError("Keine Sprache im Audio erkannt.")

# LLM rewrite if it is an LLM workflow. rewrite() meldet einen
# fehlenden API-Key selbst als LLMServiceError mit Env-Var-Hinweis.
if self.workflow in LLM_WORKFLOWS:
# Compose routing always receives the raw recognized text; the
# compose window owns any later rewrite workflow selected there.
# rewrite() reports a missing API key as LLMServiceError itself.
if self.workflow in LLM_WORKFLOWS and not self.route_to_compose:
self._emit("status_changed", "rewriting")
result_text = self.llm_service.rewrite(self.workflow, transcript)
else:
result_text = transcript

# Paste
if self.autopaste:
self.paste_service.paste(result_text)
else:
self.paste_service.clipboard_only(result_text)
# Compose routing is delivered by the GUI coordinator. Keeping it
# out of PasteService preserves the focused application's contents.
if not self.route_to_compose:
if self.autopaste:
self.paste_service.paste(result_text)
else:
self.paste_service.clipboard_only(result_text)

self._emit("result", result_text)
except Exception as e:
Expand Down Expand Up @@ -633,6 +651,7 @@ def __init__(self, app: QApplication) -> None:
self.current_workflow: Optional[WorkflowType] = None
self._tray_error_message: Optional[str] = None
self._active_workers: list[_TranscribeWorker] = []
self._recording_routes_to_compose = False

# Diktat-/Verlauf-/TTS-Zustand
self._dictation_mode = False
Expand Down Expand Up @@ -668,8 +687,15 @@ def _build_llm_service(self) -> LLMService:
writing_preset=self.config.writing_preset,
base_url=base_url,
model=self.config.llm_model,
writing_custom_prompt=self.config.compose_custom_preset_text,
)

def _rebuild_llm_service(self) -> None:
"""Baut den Service neu und aktualisiert ein bereits offenes Compose."""
self.llm_service = self._build_llm_service()
if self._compose_window is not None:
self._compose_window.set_llm_service(self.llm_service)

def setup_tray(self) -> None:
self.tray_icon = QSystemTrayIcon(self)
self._tray_icons = {
Expand Down Expand Up @@ -735,6 +761,8 @@ def setup_tray(self) -> None:
self.preset_action_group.setExclusive(True)
self.preset_actions: dict[str, QAction] = {}
for key in WRITING_PRESET_KEYS:
if key == CUSTOM_PRESET_KEY:
self.menu_preset.addSeparator()
preset_action = QAction(t(f"preset.{key}.name"), self)
preset_action.setCheckable(True)
preset_action.triggered.connect(
Expand Down Expand Up @@ -868,7 +896,7 @@ def _on_writing_preset_selected(self, key: str) -> None:
return
self.config.writing_preset = key
self.config.save()
self.llm_service = self._build_llm_service()
self._rebuild_llm_service()
self.update_menu_availability()
if self._main_window is not None:
self._main_window.set_preset(key)
Expand All @@ -880,7 +908,7 @@ def main_window_preset_changed(self, key: str) -> None:
return
self.config.writing_preset = key
self.config.save()
self.llm_service = self._build_llm_service()
self._rebuild_llm_service()
self.update_menu_availability()
self._refresh_preset_menu()
logger.info("Writing preset changed via main window: %s", key)
Expand Down Expand Up @@ -915,7 +943,7 @@ def show_settings_dialog(self) -> None:
dialog = SettingsDialog(self.config)
if dialog.exec() == QDialog.DialogCode.Accepted:
# Update LLM Service parameters from saved configuration
self.llm_service = self._build_llm_service()
self._rebuild_llm_service()
self._refresh_i18n_texts()
self.update_menu_availability()
# Preset kann im Dialog geändert worden sein -> Häkchen + Combo angleichen.
Expand Down Expand Up @@ -962,13 +990,16 @@ def _on_workflow_triggered(self, workflow: WorkflowType) -> None:
logger.info("Ignored hotkey trigger %s while busy", workflow)

def _start_recording(self, workflow: WorkflowType) -> None:
self._recording_routes_to_compose = False
try:
self.audio_recorder.start(device=self.config.audio_device)
self._recording_routes_to_compose = self._compose_voice_routing_enabled()
Comment thread
TimInTech marked this conversation as resolved.
self.current_workflow = workflow
self._set_state("RECORDING", f"workflow {workflow.value} started")
except AudioRecorderError as e:
logger.error("Failed to start recording: %s", e)
self.show_tray_error(t("error.recording.title"), t("error.recording.start_failed").format(error=e))
self._recording_routes_to_compose = False
self.current_workflow = None
self._set_state("IDLE", "recording start failed")

Expand All @@ -988,6 +1019,7 @@ def gui_discard(self) -> None:
"""Laufende Aufnahme verwerfen, ohne zu transkribieren."""
if self.state == "RECORDING":
self.audio_recorder.discard()
self._recording_routes_to_compose = False
self.current_workflow = None
self._set_state("IDLE", "discarded via gui")

Expand All @@ -1002,6 +1034,8 @@ def _on_recording_stop(self) -> None:

def _stop_recording_and_process(self) -> None:
try:
route_to_compose = self._recording_routes_to_compose
self._recording_routes_to_compose = False
wav_path = self.audio_recorder.stop()
if not wav_path:
logger.warning("No audio was recorded")
Expand All @@ -1027,10 +1061,15 @@ def _stop_recording_and_process(self) -> None:
autopaste=self.config.autopaste,
paste_service=self.paste_service,
custom_terms=self.config.custom_terms,
route_to_compose=route_to_compose,
)

worker.signals.status_changed.connect(self._on_worker_status_changed)
worker.signals.result.connect(self._on_worker_result)
worker.signals.result.connect(
lambda result_text, routed=route_to_compose: self._on_worker_result(
result_text, route_to_compose=routed
)
)
worker.signals.error.connect(self._on_worker_error)
worker.signals.finished.connect(self._on_worker_finished)

Expand All @@ -1040,6 +1079,7 @@ def _stop_recording_and_process(self) -> None:
except AudioRecorderError as e:
logger.error("Failed to stop recording: %s", e)
self.show_tray_error(t("error.recording.title"), t("error.recording.stop_failed").format(error=e))
self._recording_routes_to_compose = False
self.current_workflow = None
self._set_state("IDLE", "recording stop failed")

Expand All @@ -1052,9 +1092,10 @@ def _on_worker_status_changed(self, status: str) -> None:
else:
self.update_tray_state()

@pyqtSlot(str)
def _on_worker_result(self, result_text: str) -> None:
def _on_worker_result(self, result_text: str, route_to_compose: bool = False) -> None:
logger.info("Transcription/Rewrite success. Result length: %d chars", len(result_text))
if route_to_compose:
self._ensure_compose_window().set_input_text(result_text)
Comment thread
TimInTech marked this conversation as resolved.
self._add_to_history(result_text, is_dictation=self._dictation_mode)
if self._dictation_mode:
notify_service.notify(
Expand Down Expand Up @@ -1160,6 +1201,14 @@ def show_compose_window(self, text: str = "") -> None:
window.raise_()
window.activateWindow()

def _compose_voice_routing_enabled(self) -> bool:
window = self._compose_window
return bool(
window is not None
and window.isVisible()
and window.voice_routing_enabled()
)

def _on_tts_closed(self, _result: int) -> None:
self._tts_window = None

Expand Down
Loading