Repository navigation
v0.5.0 — le micro qu'on porte, la fenêtre qu'on lit, la carte qu'on prête
Local push-to-talk dictation for Fr-En code-switchers. Everything on your own silicon.
| Réglages › Langues | Réglages › Décodage |
|---|---|
![]() |
![]() |
Three fixes, each with a different cause than its symptom suggested.
- 🎤 Your Bluetooth mic is finally in the list. PortAudio on a PipeWire desktop enumerates only
pulseanddefault— a bluez source is not an ALSA card, so the rescan the dialog already did could never have surfaced one. Sources now come from the sound server, with human labels (Bose QC Ultra 2 HP) and live hotplug. - 🪟 Réglages scrolls instead of squashing. A flat layout past the window manager's cap compresses every row below its minimum, and word-wrapped labels print over each other. Now a sidebar of seven categories, each page in its own scroll area — and it carries its own stylesheet, because nothing in the package set one.
- ⚡ « Préférer le CPU » lends the GPU back. For when someone else wants the card. It never constructs the CUDA engine rather than building it and falling back: 0 MiB held when set before launch, against 2316 MiB before.
Sprint 39 — 2026-09-09 · 0.5.0 — le micro qu'on porte, la fenêtre qu'on lit, la carte qu'on prête
Two reports from one session on Bluetooth headphones: the mic picker did not
list the headset that pavucontrol showed plainly, and Réglages rendered as
overlapping text — "this feels 1996, not 2026". Neither cause was the one the
symptom suggested. Full write-up:
docs/research/2026-09-09-mic-enumeration-and-settings-ui.md.
Fixed
- The mic list now comes from the sound server, not PortAudio (new
pulse.py). PortAudio here has only the ALSA host API, and on a PipeWire
desktop that means it enumerates exactlypulseanddefault— before and
after the rescan the dialog was already doing. A Bluetooth mic is a PipeWire
node, never an ALSA card, so no amount of rescanning could ever surface it:
the picker was offering a choice between two aliases for "whatever the desktop
picked". Sources now come frompactl(JSON, with a text parser for pactl
< 15), monitors filtered out, which also buys human labels
(Bose QC Ultra 2 HPrather than
alsa_input.pci-0000_00_1f.3-platform-sof_sdw.HiFi__hw_sofsoundwire_4__source)
and hotplug with no rescan at all, since pactl is queried live. - Réglages squashed instead of scrolling. One flat
QVBoxLayout,
thirty-five widgets, no scroll area, a natural height past 1000 px. The window
manager caps the window, Qt compresses every row below its minimum, and
word-wrapped labels — which need a definite width to know their height — print
one clipped line over the next. Now a sidebar of seven categories, each page
its ownQScrollArea(widgetResizable=True).
Added
-
« Préférer le CPU » — leave the GPU to whoever else needs it (#137). Not a
fallback, a choice: when someone wants the card for training a model, a game
or a render, dictation should step aside rather than compete for VRAM. The
daemon was holding 2316 MiB while idle.The distinction that makes it work: with the preference on, the CUDA engine is
never constructed.GpuEngine()preloads the CUDA libs and allocates VRAM
in its constructor, so "build it, then fall back" would still take the memory
the user is trying to free. Measured:when the preference is set VRAM held by TuParles before launch 0 MiB — libcudart/libcublasnever even mappedtoggled mid-session 2266 → 186 MiB The 186 MiB is the CUDA primary context, which no library call can destroy
short of exiting the process; the ~2.1 GB of model weights are genuinely
returned. So the honest claim is "the weights come back immediately, the last
~190 MB at the next restart" — not "it frees the card".Lives in
ResilientEngine, deliberately, rather thanload_engine()returning
a bare CPU engine: a bare engine has nowhere to notice the setting changing
back, so the toggle would have been one-way until a restart. It also tracks
why it is on CPU —"preference"vs"failure"— because un-falling-back a
card that actually died would break every take after.tuparles transcribe
honours it too on--device auto, ahead of the ctranslate2 probe (so the
preferred path never imports it); an explicit--device cudastill wins,
because a flag you just typed beats a preference you once set. -
Réglages has a look of its own (new
theme.py). Nothing in the desktop
package set a stylesheet, and this box resolves no Qt6 platform theme at all
(it exportsQT_QPA_PLATFORMTHEME=qt5ct, the Qt5 bridge, which Qt6 ignores) —
so the dialogs were bare Fusion. They now bring their own paint, in the
bubble's palette, derived for light and dark from the runningQPalette.
Hand-rolled rather than a theme dependency: identical on every box, and scoped
per-window so it can never repaint the bubble or the ribbon, which paint
themselves. -
Settings re-homed by meaning. The old "Microphone" section had accreted
eight checkboxes about decode behaviour, tray animation and the clipboard.
Now: Micro, Langues, Bulle, Écriture, Décodage, Vie privée, Dev.
Changed
- A chosen PipeWire source is opened by pinning
PULSE_SOURCEand
PIPEWIRE_NODE(libpulse honours the first; pipewire-alsa, which serves
default, only the second) around theInputStreamconstructor — that is
where the ALSA plugin connects; bystart()the source is already chosen. The
pin is scoped and restored, because a leaked pin at a headset that just walked
out of range would break the fallback open too, turning one failure into two. - Stored mic names resolve pulse source → PortAudio name → system default, so
settings written before this keep working. No migration. - Resolving a mic at take start calls
pulse.source_names(), not
list_sources(): marking which mic the desktop would have chosen costs a
second pactl call, and the take-start path does not need to know. Measured
~19 ms → ~11 ms, of a ~54 msRecorder.start()(n=5, 35–58 ms — small
sample, wide spread; the stream open itself dominates either way).
Infra
tests/conftest.py, at last: an autouse fixture pointsXDG_CONFIG_HOME
andXDG_CACHE_HOMEat a tmp dir for every test. The suite had been reading
whatever box it ran on — survivable while no engine consulted a setting at
construction, and not survivable the momentprefer_cpureached
ResilientEngine.__init__: ticking a box in Réglages could have turned a green
suite red, the worst kind of flake because it looks like a code failure.ResilientEngine.engine_namenow names the rung that actually decoded
(whisper.cpp or qwen) instead of hardcodingQwenCpuEngine. That was a rare
white lie while CPU meant "the GPU died"; with CPU as a deliberate choice it
would have been the common case. One test asserted the lie and was corrected.tests/test_settings_layout.pyimposes the reported 738×578 and asserts no
visible widget on any page is laid out shorter than itsminimumSizeHint()
(offscreen has no window manager, so the test inflicts the cramp itself),
plus mic id-vs-label round-tripping and cross-page save coverage. Pulse
parsing, monitor filtering, the PortAudio fallback and the env scoping are
covered from synthetic fixtures — a real Bluetooth address is
hardware-identifying and stays out of the repo. 990 tests, ruff and mypy
clean.
Doctrine
- "Free the resource" and "don't take the resource" are different features.
A fallback path already existed and was useless here: falling back happens
after the constructor has taken the VRAM. The lever was never the decode, it
was construction — and the discriminating test is "the factory was never
called", not "it decoded on CPU", which a built-then-abandoned GPU also passes. - State the residue. ~190 MB of CUDA context survives a mid-session release
and no API can reclaim it. Measuring it turned a clean marketing sentence into
a true one, and the true one is still a good feature. - "No devices found" is a question about who is doing the enumerating. The
picker was not buggy; it was asking the wrong subsystem, and it had a rescan
bolted on that could never have helped. Before hardening a retry, check that
the thing you are retrying can ever return the answer. - A missing icon is not a visible failure. Qt stylesheet
url()resolves
files and Qt resources only: adata:URI loads nothing, with no error, no
warning and no broken-image box. Measured both ways rather than assumed
(file SVG: 20 tick pixels; data URI: 0). When a declarative layer can fail
silently, verify it by rendering and counting pixels, not by reading it.


