Skip to content

Commit 9eeeffd

Browse files
committed
fix: fix text injection after successfull groq whisper call
1 parent 56ff23a commit 9eeeffd

3 files changed

Lines changed: 109 additions & 27 deletions

File tree

src/audio.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ def default_input_device_id() -> int | None:
6565
return int(device_id)
6666

6767

68+
def _clean_device_name(name: str) -> str:
69+
return name.removesuffix(" (Default input)").strip()
70+
71+
6872
class AudioRecorder:
6973
def __init__(self, device_id: int | None = None, sample_rate: int = SAMPLE_RATE) -> None:
7074
self._device_id = device_id
@@ -181,25 +185,38 @@ def stop(self) -> bytes:
181185

182186

183187
def resolve_device(preferred_id: int | None, preferred_name: str) -> int | None:
184-
"""ID → name search → None (use default)."""
188+
"""Resolve saved device ID/name, falling back to the current default input."""
185189
_require_sd()
190+
clean_name = _clean_device_name(preferred_name)
191+
186192
if preferred_id is not None:
187193
try:
188194
dev = sd.query_devices(preferred_id)
189-
if dev["max_input_channels"] > 0:
195+
dev_name = str(dev["name"])
196+
if dev["max_input_channels"] > 0 and (
197+
not clean_name or clean_name.lower() in dev_name.lower()
198+
):
190199
return preferred_id
200+
if dev["max_input_channels"] > 0:
201+
log.warning(
202+
"Preferred device ID %d is now '%s', expected '%s'; trying name search",
203+
preferred_id,
204+
dev_name,
205+
clean_name,
206+
)
191207
except (sd.PortAudioError, ValueError):
192208
log.warning("Preferred device ID %d not found; trying name search", preferred_id)
193209

194-
if preferred_name:
210+
if clean_name:
195211
devices = sd.query_devices()
196212
for i, dev in enumerate(devices):
197-
if dev["max_input_channels"] > 0 and preferred_name.lower() in dev["name"].lower():
198-
log.info("Resolved device '%s' to ID %d", preferred_name, i)
213+
if dev["max_input_channels"] > 0 and clean_name.lower() in dev["name"].lower():
214+
log.info("Resolved device '%s' to ID %d", clean_name, i)
199215
return i
200216

201-
log.info("No preferred device; using default")
202-
return None
217+
default_id = default_input_device_id()
218+
log.info("Using current default input device: %s", default_id)
219+
return default_id
203220

204221

205222
# ---------------------------------------------------------------------------

src/injector.py

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ def type_text(text: str, post_key: str | None = None) -> None:
3333
import ctypes.wintypes
3434

3535
# --- SendInput struct definitions ---
36-
# Copied from pynput/_util/win32.py (MIT-licensed) for correct alignment.
37-
# See: https://github.com/moses-palmer/pynput
36+
# SendInput requires cbSize to be the exact size of the Win32 INPUT union.
37+
# Defining only KEYBDINPUT makes INPUT too small on 64-bit Windows.
3838

3939
INPUT_KEYBOARD = 1
4040
KEYEVENTF_KEYUP = 0x0002
@@ -46,39 +46,73 @@ class KEYBDINPUT(ctypes.Structure):
4646
("wScan", ctypes.wintypes.WORD),
4747
("dwFlags", ctypes.wintypes.DWORD),
4848
("time", ctypes.wintypes.DWORD),
49-
("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),
49+
("dwExtraInfo", ctypes.c_size_t),
50+
]
51+
52+
class MOUSEINPUT(ctypes.Structure):
53+
_fields_ = [
54+
("dx", ctypes.wintypes.LONG),
55+
("dy", ctypes.wintypes.LONG),
56+
("mouseData", ctypes.wintypes.DWORD),
57+
("dwFlags", ctypes.wintypes.DWORD),
58+
("time", ctypes.wintypes.DWORD),
59+
("dwExtraInfo", ctypes.c_size_t),
60+
]
61+
62+
class HARDWAREINPUT(ctypes.Structure):
63+
_fields_ = [
64+
("uMsg", ctypes.wintypes.DWORD),
65+
("wParamL", ctypes.wintypes.WORD),
66+
("wParamH", ctypes.wintypes.WORD),
5067
]
5168

5269
class _INPUT_UNION(ctypes.Union):
53-
_fields_ = [("ki", KEYBDINPUT)]
70+
_fields_ = [
71+
("mi", MOUSEINPUT),
72+
("ki", KEYBDINPUT),
73+
("hi", HARDWAREINPUT),
74+
]
5475

5576
class INPUT(ctypes.Structure):
77+
_anonymous_ = ("union",)
5678
_fields_ = [
5779
("type", ctypes.wintypes.DWORD),
5880
("union", _INPUT_UNION),
5981
]
6082

61-
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
83+
user32 = ctypes.WinDLL("user32", use_last_error=True) # type: ignore[attr-defined]
84+
user32.SendInput.argtypes = (ctypes.wintypes.UINT, ctypes.POINTER(INPUT), ctypes.c_int)
85+
user32.SendInput.restype = ctypes.wintypes.UINT
86+
87+
def _raise_sendinput_failed(detail: str) -> None:
88+
err = ctypes.get_last_error()
89+
if err:
90+
detail = f"{detail} (WinError {err})"
91+
raise ScreamerError(AppError.INJECTION_FAILED, detail)
6292

6393
def _send_unicode(char: str, key_up: bool = False) -> None:
6494
inp = INPUT()
6595
inp.type = INPUT_KEYBOARD
66-
inp.union.ki.wScan = ord(char)
67-
inp.union.ki.dwFlags = KEYEVENTF_UNICODE | (KEYEVENTF_KEYUP if key_up else 0)
68-
if user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT)) == 0:
69-
raise ScreamerError(AppError.INJECTION_FAILED, f"SendInput failed for U+{ord(char):04X}")
96+
inp.ki.wScan = ord(char)
97+
inp.ki.dwFlags = KEYEVENTF_UNICODE | (KEYEVENTF_KEYUP if key_up else 0)
98+
if user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT)) != 1:
99+
_raise_sendinput_failed(f"SendInput failed for U+{ord(char):04X}")
70100

71101
def _send_vk(vk: int, key_up: bool = False) -> None:
72102
inp = INPUT()
73103
inp.type = INPUT_KEYBOARD
74-
inp.union.ki.wVk = vk
75-
inp.union.ki.dwFlags = KEYEVENTF_KEYUP if key_up else 0
76-
if user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT)) == 0:
77-
raise ScreamerError(AppError.INJECTION_FAILED, f"SendInput failed for VK 0x{vk:02X}")
104+
inp.ki.wVk = vk
105+
inp.ki.dwFlags = KEYEVENTF_KEYUP if key_up else 0
106+
if user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT)) != 1:
107+
_raise_sendinput_failed(f"SendInput failed for VK 0x{vk:02X}")
108+
109+
def _utf16_units(value: str) -> list[str]:
110+
encoded = value.encode("utf-16-le", errors="surrogatepass")
111+
return [chr(int.from_bytes(encoded[i : i + 2], "little")) for i in range(0, len(encoded), 2)]
78112

79113
try:
80114
log.info("Typing %d characters", len(text))
81-
for ch in text:
115+
for ch in _utf16_units(text):
82116
_send_unicode(ch)
83117
_send_unicode(ch, key_up=True)
84118

src/settings_dialog.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ def _populate(self, cfg: AppConfig) -> None:
325325
self._llm_fb_headers.setText(cfg.llm_fallback_custom_headers)
326326

327327
# Audio
328+
self._select_device(cfg)
328329
self._rms_spin.setValue(cfg.rms_threshold)
329330
self._rms_label.setText(f"Threshold: {cfg.rms_threshold:.1f}")
330331

@@ -368,24 +369,50 @@ def _collect(self) -> None:
368369
cfg.audio_device_name = ""
369370
if cfg.audio_device_id is not None:
370371
text = self._device_combo.currentText()
371-
cfg.audio_device_name = text.split("] ", 1)[1] if "] " in text else text
372+
cfg.audio_device_name = _clean_device_name(
373+
text.split("] ", 1)[1] if "] " in text else text
374+
)
372375

373376
# ------------------------------------------------------------------
374377
# Audio tab helpers
375378
# ------------------------------------------------------------------
376379

377380
def _populate_devices(self) -> None:
378381
"""Fill the device combo from the pre-fetched device list."""
379-
self._device_combo.addItem("System Default (usually built-in laptop mic)", None)
382+
default_name = next(
383+
(
384+
_clean_device_name(dev_name)
385+
for _dev_id, dev_name in self._devices
386+
if dev_name.endswith(" (Default input)")
387+
),
388+
"usually built-in laptop mic",
389+
)
390+
self._device_combo.addItem(f"System Default ({default_name})", None)
380391
for dev_id, dev_name in self._devices:
381392
self._device_combo.addItem(f"[{dev_id}] {dev_name}", dev_id)
382393

383-
# Select the stored device.
384-
if self._working.audio_device_id is not None:
394+
self._select_device(self._working)
395+
396+
def _select_device(self, cfg: AppConfig) -> None:
397+
"""Select saved device by current ID, then by stable device name."""
398+
self._device_combo.setCurrentIndex(0)
399+
saved_name = _clean_device_name(cfg.audio_device_name).lower()
400+
401+
if cfg.audio_device_id is not None:
385402
for i in range(self._device_combo.count()):
386-
if self._device_combo.itemData(i) == self._working.audio_device_id:
403+
if self._device_combo.itemData(i) == cfg.audio_device_id:
404+
item_name = _clean_device_name(self._device_combo.itemText(i).split("] ", 1)[-1])
405+
if not saved_name or saved_name in item_name.lower():
406+
self._device_combo.setCurrentIndex(i)
407+
return
408+
409+
if saved_name:
410+
for i in range(self._device_combo.count()):
411+
item_data = self._device_combo.itemData(i)
412+
item_name = _clean_device_name(self._device_combo.itemText(i).split("] ", 1)[-1])
413+
if item_data is not None and saved_name in item_name.lower():
387414
self._device_combo.setCurrentIndex(i)
388-
break
415+
return
389416

390417
def _on_calibrate(self) -> None:
391418
"""Run RMS auto-calibration via the provided callback."""
@@ -496,6 +523,10 @@ def _combo_index(combo: QComboBox, data: str) -> int:
496523
return -1
497524

498525

526+
def _clean_device_name(name: str) -> str:
527+
return name.removesuffix(" (Default input)").strip()
528+
529+
499530
# ------------------------------------------------------------------
500531
# Standalone mode
501532
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)