Skip to content

Commit 757bddf

Browse files
committed
chore: add operation timing logs
1 parent 52da5d2 commit 757bddf

6 files changed

Lines changed: 156 additions & 132 deletions

File tree

src/audio.py

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
sd = None # type: ignore[assignment]
2020

2121
from src.config import DEFAULT_RMS_THRESHOLD
22-
from src.utils import AppError, ScreamerError
22+
from src.utils import AppError, ScreamerError, log_duration
2323

2424
log = logging.getLogger(__name__)
2525

@@ -122,22 +122,22 @@ def calibrate(self, duration: float = 2.0) -> float:
122122
"""Record ambient noise and return a usable silence-gate threshold."""
123123
_require_sd()
124124
try:
125-
log.info("Calibrating RMS threshold for %.1fs...", duration)
126-
recording = sd.rec(
127-
int(duration * self._sample_rate),
128-
samplerate=self._sample_rate,
129-
channels=CHANNELS,
130-
dtype=DTYPE,
131-
device=self._device_id,
132-
)
133-
sd.wait()
134-
noise_floor = float(np.sqrt(np.mean(recording.astype(np.float64) ** 2)))
135-
threshold = noise_floor * 2.0
136-
if threshold < DEFAULT_RMS_THRESHOLD:
137-
threshold = DEFAULT_RMS_THRESHOLD
138-
self._rms_threshold = threshold
139-
log.info("Calibration done: noise_floor=%.1f, threshold=%.1f", noise_floor, threshold)
140-
return threshold
125+
with log_duration(log, f"Calibration for {duration:.1f}s"):
126+
recording = sd.rec(
127+
int(duration * self._sample_rate),
128+
samplerate=self._sample_rate,
129+
channels=CHANNELS,
130+
dtype=DTYPE,
131+
device=self._device_id,
132+
)
133+
sd.wait()
134+
noise_floor = float(np.sqrt(np.mean(recording.astype(np.float64) ** 2)))
135+
threshold = noise_floor * 2.0
136+
if threshold < DEFAULT_RMS_THRESHOLD:
137+
threshold = DEFAULT_RMS_THRESHOLD
138+
self._rms_threshold = threshold
139+
log.info("Calibration done: noise_floor=%.1f, threshold=%.1f", noise_floor, threshold)
140+
return threshold
141141
except Exception as e:
142142
log.warning("Calibration failed: %s; using fallback %.1f", e, DEFAULT_RMS_THRESHOLD)
143143
self._rms_threshold = DEFAULT_RMS_THRESHOLD

src/injector.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import platform
77
import time
88

9-
from src.utils import AppError, ScreamerError
9+
from src.utils import AppError, ScreamerError, log_duration
1010

1111
log = logging.getLogger(__name__)
1212

@@ -111,21 +111,22 @@ def _utf16_units(value: str) -> list[str]:
111111
return [chr(int.from_bytes(encoded[i : i + 2], "little")) for i in range(0, len(encoded), 2)]
112112

113113
try:
114-
log.info("Typing %d characters", len(text))
115-
for ch in _utf16_units(text):
116-
_send_unicode(ch)
117-
_send_unicode(ch, key_up=True)
118-
119-
# Post-type key with 0.05s delay.
120-
if post_key and post_key != "none":
121-
vk = _POST_KEY_VK.get(post_key.lower())
122-
if vk is not None:
123-
time.sleep(0.05)
124-
_send_vk(vk)
125-
_send_vk(vk, key_up=True)
126-
log.info("Post-type key pressed: %s", post_key)
127-
else:
128-
log.warning("Unknown post-type key: %s", post_key)
114+
with log_duration(log, f"Text injection ({len(text)} chars)"):
115+
log.info("Typing %d characters", len(text))
116+
for ch in _utf16_units(text):
117+
_send_unicode(ch)
118+
_send_unicode(ch, key_up=True)
119+
120+
# Post-type key with 0.05s delay.
121+
if post_key and post_key != "none":
122+
vk = _POST_KEY_VK.get(post_key.lower())
123+
if vk is not None:
124+
time.sleep(0.05)
125+
_send_vk(vk)
126+
_send_vk(vk, key_up=True)
127+
log.info("Post-type key pressed: %s", post_key)
128+
else:
129+
log.warning("Unknown post-type key: %s", post_key)
129130

130131
except ScreamerError:
131132
raise

src/rewrite.py

Lines changed: 38 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import httpx
88

99
from src.config import AppConfig, ProviderConfig, parse_custom_headers
10-
from src.utils import AppError, PipelineResult, ScreamerError
10+
from src.utils import AppError, PipelineResult, ScreamerError, log_duration
1111

1212
log = logging.getLogger(__name__)
1313

@@ -18,35 +18,36 @@ def rewrite(text: str, config: AppConfig) -> PipelineResult:
1818
Returns input text unchanged in ``PipelineResult.text`` if ``config.llm_enabled`` is False.
1919
Provider errors return the original text with ``AppError.LLM_FAILED`` as a warning.
2020
"""
21-
if not config.llm_enabled:
22-
return PipelineResult(text=text)
23-
24-
system_prompt = config.llm_system_prompt or ""
25-
language = getattr(config, "stt_language", "")
26-
if language:
27-
system_prompt += f"\nThe speech language is {language}."
28-
29-
primary = config.llm_provider()
30-
fallback = config.llm_fallback_provider()
31-
32-
for is_fallback, provider in ((False, primary), (True, fallback.provider)):
33-
if is_fallback and not fallback.enabled:
34-
continue
35-
if not provider.is_complete:
36-
continue
37-
38-
try:
39-
result = _call_llm(provider=provider, system_prompt=system_prompt, user_text=text)
40-
if result:
41-
log.debug("%s LLM rewrite: %r → %r", "Fallback" if is_fallback else "Primary", text[:60], result[:60])
42-
return PipelineResult(text=result)
43-
except Exception as e:
44-
log.warning("%s LLM failed: %s", "Fallback" if is_fallback else "Primary", e)
45-
if not fallback.enabled:
46-
return PipelineResult(text=text, warnings=[AppError.LLM_FAILED])
47-
48-
log.debug("LLM rewrite failed or returned empty; using original text")
49-
return PipelineResult(text=text, warnings=[AppError.LLM_FAILED])
21+
with log_duration(log, "LLM rewrite"):
22+
if not config.llm_enabled:
23+
return PipelineResult(text=text)
24+
25+
system_prompt = config.llm_system_prompt or ""
26+
language = getattr(config, "stt_language", "")
27+
if language:
28+
system_prompt += f"\nThe speech language is {language}."
29+
30+
primary = config.llm_provider()
31+
fallback = config.llm_fallback_provider()
32+
33+
for is_fallback, provider in ((False, primary), (True, fallback.provider)):
34+
if is_fallback and not fallback.enabled:
35+
continue
36+
if not provider.is_complete:
37+
continue
38+
39+
try:
40+
result = _call_llm(provider=provider, system_prompt=system_prompt, user_text=text)
41+
if result:
42+
log.debug("%s LLM rewrite: %r → %r", "Fallback" if is_fallback else "Primary", text[:60], result[:60])
43+
return PipelineResult(text=result)
44+
except Exception as e:
45+
log.warning("%s LLM failed: %s", "Fallback" if is_fallback else "Primary", e)
46+
if not fallback.enabled:
47+
return PipelineResult(text=text, warnings=[AppError.LLM_FAILED])
48+
49+
log.debug("LLM rewrite failed or returned empty; using original text")
50+
return PipelineResult(text=text, warnings=[AppError.LLM_FAILED])
5051

5152

5253
def _call_llm(
@@ -78,13 +79,14 @@ def _call_llm(
7879
"temperature": 0.0,
7980
}
8081

81-
log.info("LLM request: url=%s model=%s", url, provider.model)
82-
resp = httpx.post(url, headers=headers, json=body, timeout=30.0)
83-
resp.raise_for_status()
82+
with log_duration(log, f"LLM request ({provider.model})"):
83+
log.info("LLM request: url=%s model=%s", url, provider.model)
84+
resp = httpx.post(url, headers=headers, json=body, timeout=30.0)
85+
resp.raise_for_status()
8486

85-
data = resp.json()
86-
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
87-
return content.strip() if content else None
87+
data = resp.json()
88+
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
89+
return content.strip() if content else None
8890

8991

9092
# ---------------------------------------------------------------------------

src/settings_dialog.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
save_config,
4444
validate_config,
4545
)
46-
from src.utils import APP_NAME
46+
from src.utils import APP_NAME, log_duration
4747
from src.startup import is_supported
4848

4949
log = logging.getLogger(__name__)
@@ -462,26 +462,29 @@ def _validate_and_accept(self) -> None:
462462

463463
def _on_import_env(self) -> None:
464464
"""Import .env into the working copy (empty fields only)."""
465-
self._collect()
466-
self._working = import_from_env(self._working)
467-
self._populate(self._working)
468-
log.info("Imported .env values into settings")
465+
with log_duration(log, "Settings import from .env"):
466+
self._collect()
467+
self._working = import_from_env(self._working)
468+
self._populate(self._working)
469+
log.info("Imported .env values into settings")
469470

470471
def _on_reset(self) -> None:
471472
"""Reset all fields to defaults."""
472-
self._working = reset_config()
473-
self._populate(self._working)
474-
log.info("Settings reset to defaults")
473+
with log_duration(log, "Settings reset to defaults"):
474+
self._working = reset_config()
475+
self._populate(self._working)
476+
log.info("Settings reset to defaults")
475477

476478
def _on_apply(self) -> None:
477479
"""Apply: collect and persist without closing."""
478-
self._collect()
479-
if not self._show_validation_issue():
480-
return
481-
if not self._sync_startup_or_warn():
482-
return
483-
save_config(self._working)
484-
log.info("Settings applied")
480+
with log_duration(log, "Settings apply"):
481+
self._collect()
482+
if not self._show_validation_issue():
483+
return
484+
if not self._sync_startup_or_warn():
485+
return
486+
save_config(self._working)
487+
log.info("Settings applied")
485488

486489
# ------------------------------------------------------------------
487490
# Overrides

src/stt.py

Lines changed: 50 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import httpx
88

99
from src.config import AppConfig, ProviderConfig, parse_custom_headers
10-
from src.utils import AppError, PipelineResult, ScreamerError
10+
from src.utils import AppError, PipelineResult, ScreamerError, log_duration
1111

1212
log = logging.getLogger(__name__)
1313

@@ -23,37 +23,38 @@ def transcribe(audio_wav: bytes, config: AppConfig) -> PipelineResult:
2323
2424
*config* supplies primary and fallback providers via ``AppConfig``.
2525
"""
26-
warnings: list[AppError] = []
27-
28-
primary = config.stt_provider()
29-
fallback = config.stt_fallback_provider()
30-
31-
if not primary.is_complete and not fallback.is_complete:
32-
raise ScreamerError(AppError.STT_FAILED, "No STT API key configured")
33-
34-
for is_fallback, provider, language in (
35-
(False, primary, config.stt_language),
36-
(True, fallback.provider, ""),
37-
):
38-
if is_fallback and not fallback.enabled:
39-
continue
40-
if not provider.is_complete:
41-
continue
42-
43-
try:
44-
text = _call_stt(provider=provider, language=language, audio_wav=audio_wav)
45-
if text is not None:
46-
if is_fallback:
47-
warnings.append(AppError.STT_FALLBACK_USED)
48-
return PipelineResult(text=text, warnings=warnings)
49-
except ScreamerError:
50-
raise
51-
except Exception as e:
52-
log.warning("%s STT failed: %s", "Fallback" if is_fallback else "Primary", e)
53-
if not fallback.enabled:
54-
raise ScreamerError(AppError.STT_FAILED, str(e)) from e
55-
56-
raise ScreamerError(AppError.STT_FAILED, "Both primary and fallback STT failed or returned no speech")
26+
with log_duration(log, "STT transcription"):
27+
warnings: list[AppError] = []
28+
29+
primary = config.stt_provider()
30+
fallback = config.stt_fallback_provider()
31+
32+
if not primary.is_complete and not fallback.is_complete:
33+
raise ScreamerError(AppError.STT_FAILED, "No STT API key configured")
34+
35+
for is_fallback, provider, language in (
36+
(False, primary, config.stt_language),
37+
(True, fallback.provider, ""),
38+
):
39+
if is_fallback and not fallback.enabled:
40+
continue
41+
if not provider.is_complete:
42+
continue
43+
44+
try:
45+
text = _call_stt(provider=provider, language=language, audio_wav=audio_wav)
46+
if text is not None:
47+
if is_fallback:
48+
warnings.append(AppError.STT_FALLBACK_USED)
49+
return PipelineResult(text=text, warnings=warnings)
50+
except ScreamerError:
51+
raise
52+
except Exception as e:
53+
log.warning("%s STT failed: %s", "Fallback" if is_fallback else "Primary", e)
54+
if not fallback.enabled:
55+
raise ScreamerError(AppError.STT_FAILED, str(e)) from e
56+
57+
raise ScreamerError(AppError.STT_FAILED, "Both primary and fallback STT failed or returned no speech")
5758

5859

5960
def _call_stt(
@@ -79,26 +80,27 @@ def _call_stt(
7980

8081
files = {"file": ("recording.wav", audio_wav, "audio/wav")}
8182

82-
log.info("STT request: url=%s model=%s", url, provider.model)
83-
resp = httpx.post(url, headers=headers, data=data, files=files, timeout=60.0)
84-
resp.raise_for_status()
83+
with log_duration(log, f"STT request ({provider.model})"):
84+
log.info("STT request: url=%s model=%s", url, provider.model)
85+
resp = httpx.post(url, headers=headers, data=data, files=files, timeout=60.0)
86+
resp.raise_for_status()
8587

86-
result = resp.json()
87-
segments = result.get("segments", [])
88+
result = resp.json()
89+
segments = result.get("segments", [])
8890

89-
# Filter: keep if ANY segment has no_speech_prob < threshold.
90-
if segments:
91-
has_speech = any(seg.get("no_speech_prob", 0.0) < _NO_SPEECH_THRESHOLD for seg in segments)
92-
if not has_speech:
93-
log.debug("All segments above no_speech_prob threshold; filtering out")
94-
raise ScreamerError(AppError.NO_SPEECH)
91+
# Filter: keep if ANY segment has no_speech_prob < threshold.
92+
if segments:
93+
has_speech = any(seg.get("no_speech_prob", 0.0) < _NO_SPEECH_THRESHOLD for seg in segments)
94+
if not has_speech:
95+
log.debug("All segments above no_speech_prob threshold; filtering out")
96+
raise ScreamerError(AppError.NO_SPEECH)
9597

96-
text = (result.get("text") or "").strip()
97-
if not text:
98-
raise ScreamerError(AppError.NO_SPEECH)
98+
text = (result.get("text") or "").strip()
99+
if not text:
100+
raise ScreamerError(AppError.NO_SPEECH)
99101

100-
log.debug("STT result: %s", text[:80])
101-
return text
102+
log.debug("STT result: %s", text[:80])
103+
return text
102104

103105

104106
# ---------------------------------------------------------------------------

src/utils.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
from __future__ import annotations
44

5+
import logging
56
import os
7+
from contextlib import contextmanager
68
from dataclasses import dataclass, field
79
from enum import Enum
10+
from time import perf_counter
811

912
from PySide6.QtCore import QObject, Signal
1013

@@ -51,3 +54,16 @@ class PipelineResult:
5154

5255
text: str
5356
warnings: list[AppError] = field(default_factory=list)
57+
58+
59+
@contextmanager
60+
def log_duration(logger: logging.Logger, label: str, level: int = logging.INFO):
61+
"""Log how long a block takes, even if it raises."""
62+
start = perf_counter()
63+
try:
64+
yield
65+
except Exception:
66+
logger.log(level, "%s failed after %.3fs", label, perf_counter() - start)
67+
raise
68+
else:
69+
logger.log(level, "%s finished in %.3fs", label, perf_counter() - start)

0 commit comments

Comments
 (0)