Skip to content

v0.5.0 — le micro qu'on porte, la fenêtre qu'on lit, la carte qu'on prête

Choose a tag to compare

@github-actions github-actions released this 09 Sep 12:00
· 12 commits to main since this release

Local push-to-talk dictation for Fr-En code-switchers. Everything on your own silicon.

Réglages › Langues Réglages › Décodage
Settings, Languages page: searchable checklist of 100 languages, selected first Settings, Decoding page: prefer-CPU toggle to leave the GPU free

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 pulse and default — 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.

The bubble: whole take, word-wrapped, green waveform on GPU


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 exactly pulse and default — 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 from pactl (JSON, with a text parser for pactl
    < 15), monitors filtered out, which also buys human labels
    (Bose QC Ultra 2 HP rather 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 own QScrollArea(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/libcublas never even mapped
    toggled 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 than load_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 cuda still 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 exports QT_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 running QPalette.
    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_SOURCE and
    PIPEWIRE_NODE (libpulse honours the first; pipewire-alsa, which serves
    default, only the second) around the InputStream constructor — that is
    where the ALSA plugin connects; by start() 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 ms Recorder.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 points XDG_CONFIG_HOME
    and XDG_CACHE_HOME at 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 moment prefer_cpu reached
    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_name now names the rung that actually decoded
    (whisper.cpp or qwen) instead of hardcoding QwenCpuEngine. 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.py imposes the reported 738×578 and asserts no
    visible widget on any page is laid out shorter than its minimumSizeHint()
    (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: a data: 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.