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
24 changes: 17 additions & 7 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,10 @@ def parse_custom_headers(custom_headers: str) -> dict[str, str]: ...
def validate_config(cfg: AppConfig) -> list[ConfigValidationIssue]: ...
"""Return all startup/settings validation issues for the current config."""

def insecure_base_urls(cfg: AppConfig) -> list[tuple[str, str]]: ...
"""(label, url) for http:// provider URLs on a non-loopback host. Settings warns
once when such a URL is newly entered; it never blocks saving."""

def stt_provider_is_configured(provider: ProviderConfig) -> bool: ...
"""STT requires URL + model; key is optional. LLM still requires key + URL + model."""

Expand Down Expand Up @@ -377,16 +381,19 @@ class AudioRecorder:
@property
def rms_threshold(self) -> float: ...
def calibrate(self, duration: float = 2.0) -> float: ...
"""Return max(noise_floor * 2.0, DEFAULT_RMS_THRESHOLD); measurement failure falls back to 5.0."""
"""Return max(noise_floor * 2.0, DEFAULT_RMS_THRESHOLD); measurement failure raises
MIC_UNAVAILABLE and leaves the current threshold unchanged."""
def start(self) -> None: ...
"""Open and start the microphone; on failure close the stream and raise MIC_UNAVAILABLE."""
def snapshot(self) -> CaptureSnapshot: ...
"""Immutable evidence under the recorder lock; no enumeration or audio copy.
Before callbacks: level 0, has_callback_data False. Captured zeros: True, level 0."""
def stop(self) -> bytes: ...
"""Return mono int16 WAV; short/quiet capture returns b"". A valid-length
attempt without samples, unexpected stream finish, or stop/close failure
raises MIC_DISCONNECTED and discards frames. Late callbacks cannot append."""
def stop(self, tail: float = 0.0) -> bytes: ...
"""Capture `tail` more seconds (hotkey stops pass RELEASE_TAIL = 0.25), then
return mono int16 WAV; short/quiet capture returns b"". Samples the backend
drains during stream.stop() are kept. A valid-length attempt without samples,
unexpected stream finish, or stop/close failure raises MIC_DISCONNECTED and
discards frames. Callbacks after stop() returns cannot append."""

def resolve_device(
preferred_id: int | None,
Expand Down Expand Up @@ -601,7 +608,8 @@ ruff format --check src/ tests/ .github/scripts/
- The tray Language radio submenu switches the persisted active STT code immediately.
Auto omits the STT language field and rewrite hint; a selected code is sent to both
primary and fallback STT and added to the optional rewrite prompt. Provider support
for a language code is not guaranteed. Settings manages the ordered favorites;
for a language code is not guaranteed. STT receives only the primary subtag
(`pt-br` is sent as `pt`), since Whisper-compatible endpoints expect ISO 639-1. Settings manages the ordered favorites;
removing an active additional favorite selects Auto. A failed tray save restores
the previous visible and live selection.
- Tray icon: grey (idle) → red (recording) → yellow (processing) → grey.
Expand Down Expand Up @@ -666,7 +674,9 @@ ruff format --check src/ tests/ .github/scripts/
- Latest usable text remains in RAM even with history disabled. Persistent history is
opt-in (default off) and stores validated version-1 JSON as DPAPI-protected ciphertext
at `APP_DIR/history.enc`; writes use ciphertext-only temporary files, fsync and atomic
replacement. Retention is 1..100 on commit. Read/decrypt/schema/write failures fail
replacement. Records missing a defaulted field load with its default and unknown
fields are ignored, so adding a field does not invalidate older files.
Retention is 1..100 on commit. Read/decrypt/schema/write failures fail
closed while RAM recovery remains available. Turning history off stops ordinary
reads/writes but leaves prior saved entries until explicit Clear. Delete is not a
secure-erasure guarantee for backups or other copies.
Expand Down
37 changes: 27 additions & 10 deletions src/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
CHANNELS = 1
DTYPE = "int16"
MIN_DURATION = 0.3
# Speech often ends a moment after the hotkey is released; keep capturing briefly.
RELEASE_TAIL = 0.25


@dataclass
Expand Down Expand Up @@ -116,6 +118,7 @@ def __init__(self, device_id: int | None = None, sample_rate: int = SAMPLE_RATE)
self._start_time: float = 0.0
self._rms_threshold: float = DEFAULT_RMS_THRESHOLD
self._accepting_frames = False
self._stop_requested = False
self._level_rms = 0.0
self._has_callback_data = False
self._capture_error: str | None = None
Expand Down Expand Up @@ -147,7 +150,11 @@ def snapshot(self) -> CaptureSnapshot:
)

def calibrate(self, duration: float = 2.0) -> float:
"""Record ambient noise and return a usable silence-gate threshold."""
"""Record ambient noise and return a usable silence-gate threshold.

Raise ``ScreamerError(AppError.MIC_UNAVAILABLE)`` if recording fails; the
current threshold is left unchanged.
"""
_require_sd()
try:
with log_duration(log, f"Calibration for {duration:.1f}s"):
Expand All @@ -169,9 +176,8 @@ def calibrate(self, duration: float = 2.0) -> float:
)
return threshold
except Exception as e:
log.warning("Calibration failed: %s; using fallback %.1f", e, DEFAULT_RMS_THRESHOLD)
self._rms_threshold = DEFAULT_RMS_THRESHOLD
return DEFAULT_RMS_THRESHOLD
log.warning("Calibration failed: %s", e)
raise ScreamerError(AppError.MIC_UNAVAILABLE, str(e)) from e

def _callback(self, indata: np.ndarray, frames: int, time_info, status) -> None: # type: ignore[no-untyped-def]
level_rms = float(np.sqrt(np.mean(indata.astype(np.float64) ** 2)))
Expand All @@ -185,7 +191,7 @@ def _callback(self, indata: np.ndarray, frames: int, time_info, status) -> None:

def _on_stream_finished(self) -> None:
with self._lock:
if self._accepting_frames:
if self._accepting_frames and not self._stop_requested:
self._capture_error = "Input stream stopped unexpectedly."
self._accepting_frames = False

Expand All @@ -199,6 +205,7 @@ def start(self) -> None:
self._capture_error = None
self._device = None
self._input_status = ""
self._stop_requested = False
self._accepting_frames = True
self._start_time = time.monotonic()
try:
Expand Down Expand Up @@ -233,9 +240,12 @@ def start(self) -> None:
self._stream = stream
log.info("Recording started (device=%s)", self._device_id)

def stop(self) -> bytes:
def stop(self, tail: float = 0.0) -> bytes:
"""Stop recording and return 16kHz mono int16 WAV bytes.

``tail`` keeps capturing for that many seconds first. Samples the backend
drains while stopping are kept, so the end of speech is not cut off.

Raise ``ScreamerError(AppError.MIC_DISCONNECTED)`` on stream failure
or a valid-length attempt without callback data.
Return empty bytes if the recording is too short or silent.
Expand All @@ -245,13 +255,16 @@ def stop(self) -> bytes:

stream = self._stream
self._stream = None
if tail > 0:
time.sleep(tail)
with self._lock:
self._accepting_frames = False
capture_error = self._capture_error
self._stop_requested = True
try:
try:
stream.stop(ignore_errors=False)
finally:
with self._lock:
self._accepting_frames = False
stream.close(ignore_errors=False)
except Exception as e:
with self._lock:
Expand All @@ -261,6 +274,7 @@ def stop(self) -> bytes:
with self._lock:
frames = self._frames
self._frames = []
capture_error = self._capture_error
if capture_error is not None:
raise ScreamerError(AppError.MIC_DISCONNECTED, capture_error)

Expand Down Expand Up @@ -349,8 +363,11 @@ def resolve_device(
recorder = AudioRecorder()

print("Calibrating (2s ambient noise)...")
threshold = recorder.calibrate(2.0)
print(f"RMS threshold: {threshold:.1f}")
try:
threshold = recorder.calibrate(2.0)
print(f"RMS threshold: {threshold:.1f}")
except ScreamerError as e:
print(f" Calibration failed: {e}; using {recorder.rms_threshold:.1f}")

print()
print("Recording 3 seconds... speak now!")
Expand Down
32 changes: 31 additions & 1 deletion src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import ipaddress
import json
import logging
import ntpath
Expand Down Expand Up @@ -328,7 +329,7 @@ def normalize_language_code(value: str) -> str:
raise ValueError("Language code must be text")
code = value.strip().lower()
if len(code) > _MAX_LANGUAGE_CODE_LENGTH or not _LANGUAGE_CODE.fullmatch(code):
raise ValueError("Use a language code such as cs, en, or pt-br (up to 32 characters)")
raise ValueError("Use a language code such as cs, en, or de (up to 32 characters)")
return code


Expand Down Expand Up @@ -1201,6 +1202,35 @@ def validate_config(cfg: AppConfig) -> list[ConfigValidationIssue]:
return issues


def insecure_base_urls(cfg: AppConfig) -> list[tuple[str, str]]:
"""Return (label, url) for plain-http provider URLs that leave this machine.

Such URLs send API keys, custom headers and transcripts unencrypted. This is
a warning, not a validation issue: LAN-hosted servers may use http on purpose.
"""
result: list[tuple[str, str]] = []
for url, label in (
(cfg.stt_base_url, "Primary STT"),
(cfg.stt_fallback_base_url, "Fallback STT"),
(cfg.llm_base_url, "Primary LLM"),
(cfg.llm_fallback_base_url, "Fallback LLM"),
):
try:
parsed = urlsplit(url)
host = parsed.hostname
except ValueError:
continue # Invalid URLs are reported by validate_config.
if parsed.scheme != "http" or not host or host == "localhost":
continue
try:
if ipaddress.ip_address(host).is_loopback:
continue
except ValueError:
pass # A hostname other than localhost.
result.append((label, url))
return result


def _env_path() -> str:
"""Locate .env: next to the executable when frozen, else at cwd (dev runs)."""
if getattr(sys, "frozen", False):
Expand Down
16 changes: 11 additions & 5 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@
QWidgetAction,
)

from src.audio import AudioRecorder, default_input_device_id, list_devices, resolve_device
from src.audio import (
RELEASE_TAIL,
AudioRecorder,
default_input_device_id,
list_devices,
resolve_device,
)
from src.config import (
DEFAULT_LLM_SYSTEM_PROMPT,
HOTKEY_OPTIONS,
Expand Down Expand Up @@ -539,15 +545,15 @@ def _poll_recording_level(self) -> None:
label, min(capture.level_rms / 32767.0, 1.0), capture.has_callback_data
)

def _finalize_recording(self) -> None:
def _finalize_recording(self, tail: float = 0.0) -> None:
"""Stop recording and start the processing worker."""
self._recording_timer.stop()
self._level_timer.stop()
self._recording = False
self._apply_state(TrayState.PROCESSING)

try:
audio_wav = self._recorder.stop()
audio_wav = self._recorder.stop(tail=tail)
except ScreamerError as e:
self._session_config = None
self._session = None
Expand Down Expand Up @@ -599,7 +605,7 @@ def _on_hotkey_pressed(self) -> None:

if self._recording:
# Toggle mode: second press → finalize and process.
self._finalize_recording()
self._finalize_recording(tail=RELEASE_TAIL)
else:
self._start_recording()

Expand All @@ -608,7 +614,7 @@ def _on_hotkey_released(self) -> None:
if self._is_hotkey_capture_active() and not self._recording:
return
if self._recording:
self._finalize_recording()
self._finalize_recording(tail=RELEASE_TAIL)

def _is_hotkey_capture_active(self) -> bool:
return self._settings_dlg is not None and self._settings_dlg.is_hotkey_capture_active()
Expand Down
31 changes: 23 additions & 8 deletions src/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import json
import os
import tempfile
from dataclasses import asdict, dataclass
from dataclasses import MISSING, asdict, dataclass, fields
from datetime import datetime, timezone
from pathlib import Path

Expand Down Expand Up @@ -77,10 +77,28 @@ def _timestamp(value: object) -> None:
raise ValueError("Expected UTC timestamp")


def _decode_record(value: object) -> DictationRecord:
if not isinstance(value, dict) or set(value) != set(DictationRecord.__dataclass_fields__):
def _known_fields(cls: type, value: object) -> dict:
"""Require fields without defaults and fill missing defaulted ones.

Records written before a defaulted field was added must still load instead of
invalidating the whole history file. Unknown fields (e.g. audio) stay rejected.
"""
if not isinstance(value, dict) or not set(value) <= {f.name for f in fields(cls)}:
raise ValueError("Invalid record fields")
data = dict(value)
data = {}
for f in fields(cls):
if f.name in value:
data[f.name] = value[f.name]
elif f.default is MISSING:
raise ValueError("Invalid record fields")
else:
# Defaults in their serialized form (tuples are stored as JSON lists).
data[f.name] = list(f.default) if isinstance(f.default, tuple) else f.default
return data


def _decode_record(value: object) -> DictationRecord:
data = _known_fields(DictationRecord, value)
_string(data["id"], nonempty=True)
_timestamp(data["created_at"])
_string(data["raw_text"], nonempty=True)
Expand All @@ -104,10 +122,7 @@ def _decode_record(value: object) -> DictationRecord:
data["warnings"] = tuple(AppError[name] for name in warnings)
attempt = data["last_delivery"]
if attempt is not None:
if not isinstance(attempt, dict) or set(attempt) != set(
DeliveryAttempt.__dataclass_fields__
):
raise ValueError("Invalid attempt fields")
attempt = _known_fields(DeliveryAttempt, attempt)
_timestamp(attempt["attempted_at"])
if attempt["mode"] not in {"type", "copy", "copy_and_type", "manual"}:
raise ValueError("Invalid output mode")
Expand Down
6 changes: 4 additions & 2 deletions src/rewrite.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,10 @@ def _call_llm(


def _groq_completion_cap(user_text: str) -> int:
estimated_input_tokens = max(1, len(user_text) // 4)
cap = int(estimated_input_tokens * 1.5) + 32
# Assume ~2 chars/token: accented languages such as Czech tokenize far
# denser than English, and a too-tight cap discards the whole rewrite.
estimated_input_tokens = max(1, len(user_text) // 2)
cap = estimated_input_tokens * 2 + 64
return max(128, min(4096, cap))


Expand Down
24 changes: 23 additions & 1 deletion src/settings_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
normalize_vocabulary_entries,
reset_config,
save_config,
insecure_base_urls,
validate_config,
)
from src.audio import resolve_device
Expand Down Expand Up @@ -168,6 +169,8 @@ def __init__(

# Edit a deep copy so the original is untouched until accept.
self._working = copy.deepcopy(config)
# Warn about unencrypted provider URLs once, and only when newly entered.
self._acknowledged_insecure_urls = {url for _label, url in insecure_base_urls(config)}
self._profile_loading = False
self._rewrite_error: str | None = None

Expand Down Expand Up @@ -407,7 +410,7 @@ def _build_stt_tab(self) -> None:
form.addRow("Favorite languages:", self._stt_favorites)

self._stt_favorite_input = QLineEdit()
self._stt_favorite_input.setPlaceholderText("Language code, e.g. de or pt-br")
self._stt_favorite_input.setPlaceholderText("ISO 639-1 language code, e.g. de or pt")
form.addRow("Favorite code:", self._stt_favorite_input)

btn_add = QPushButton("Add")
Expand Down Expand Up @@ -1519,6 +1522,7 @@ def _show_validation_issue(self) -> bool:
None,
)
if issue is None:
self._warn_insecure_urls()
return True

QMessageBox.warning(self, "Missing Configuration", issue.message)
Expand All @@ -1527,6 +1531,24 @@ def _show_validation_issue(self) -> bool:
self._llm_group.setVisible(True)
return False

def _warn_insecure_urls(self) -> None:
new = [
(label, url)
for label, url in insecure_base_urls(self._working)
if url not in self._acknowledged_insecure_urls
]
if not new:
return
self._acknowledged_insecure_urls.update(url for _label, url in new)
lines = "\n".join(f"{label}: {url}" for label, url in new)
QMessageBox.warning(
self,
"Unencrypted Connection",
"These providers use http:// on a non-local host, so the API key, custom "
f"headers and transcripts are sent unencrypted:\n\n{lines}\n\n"
"Use https:// unless the server is on a network you trust.",
)

def _save_or_warn(self) -> bool:
from src.utils import ScreamerError

Expand Down
Loading
Loading