Repository navigation
Releases: PLNech/TuParles
Release list
v0.5.1 — la version qui dit vrai
0.5.1 — 2026-09-09 · la version qui dit vrai
A patch for one wrong string, which had been wrong for two releases.
Fixed
- The tray reported 0.3.0 while running 0.5.0.
bugreport.app_version()reads
importlib.metadata.version("tuparles"), andpoetry installresolves path-dep
versions from poetry.lock — not from the packages'pyproject.toml. Bumping
the pyproject was therefore never enough: without apoetry lock, every install
re-stamped the old dist-info, and the label had been stale since v0.3.0. The lock
now tracks the real version, andv0.5.0's own tag shipped with the stale one —
hence this patch rather than a moved tag.
Doctrine
- A version lives in as many places as your tooling reads it from. Two
pyproject.tomls said 0.5.0, the lockfile said 0.4.0, and the installed
metadata — the only copy the UI actually reads — said 0.3.0. Bump the source,
re-lock, reinstall, then look at the surface that displays it.
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.
v0.4.0 — Trancher le silence, dérouler le ruban
Two headline changes this release: dictation stops paying for the silence you didn't mean to record, and the live preview finally spreads sideways instead of towering over your code.
Trancher le silence — trim the dead air
A mic left keyed after your last word used to make the CPU engines decode every silent second — a 51-second take could spend half its decode time on dead tail (the GPU's in-decode VAD already skipped it; the CPU rungs did not). TuParles now trims the dead lead/tail once, at capture handoff, so every engine (GPU, whisper.cpp, qwen) decodes the shorter buffer.
Three tiers, GPU-or-CPU by construction: silero-vad when the optional trim group is installed, a dependency-free RMS fallback otherwise, and a plain no-op if neither fits — it never raises. Conservative by design: lead/tail only (never interior pauses), keeps a beat of margin around speech, and hands back the untrimmed audio at the first doubt. On by default, with a Réglages toggle « Couper les silences en début/fin de prise ».
Validated on 62 consented real takes with a paired-bootstrap A/B (aggregate numbers only, no transcript content): the trim does not harm quality, and after the A/B the min-result floor was raised to 1.25 s so short takes don't get trimmed into sub-1-second clips that whisper decodes unreliably — when in doubt, keep the audio.
Dérouler le ruban — a preview that grows the right way
The "full" preview window now widens along the bottom edge before it ever adds a second line (up to 92% of the screen, capped at two lines by default). Older words drop into a dim, left-aligned history register above a bright, right-anchored live tail — recency shown by brightness and size, never a colour change. The start of your take stays visible far longer. New live knobs (no restart): bandeau width, bandeau lines, and a text-size control for HiDPI / accessibility.
Partials are now display-faithful: pipeline.preview() runs the text stages (punctuation, lexicon, spoken syntax including slashes, casing) so "slash impeccable" shows as "/impeccable" — what you see amber is byte-for-byte what lands. Command parsing and its safety interlocks stay off the preview path by construction.
Also in this release
- One audio-prep seam (
prepare_pcm) for every engine — qwen and the offline file path used to skip normalization; no more silent divergence between rungs. - Tuned faster-whisper VAD at every call site (500 ms min-silence, 200 ms pad) so a short dead tail no longer sails through the in-decode filter. Complementary to the pre-trim, not redundant.
- Persistent CPU-fallback tray signal — the glyph stays a dimmer blue the whole time you're on the CPU rung, not just a one-shot flash, so a suspend-wedged CUDA context can't hide behind a neutral idle icon.
- Status toasts no longer swallowed during back-to-back dictation — a dropped state notice is re-queued and re-fires when the bubble next goes idle.
Everything runs on your own silicon — that is the whole point. Speak, see, ship.
v0.3.0
Sprint 23 — 2026-06-28 · « slash » devient / (piloter au clavier-voix) · v0.3.0
You dictate INTO Claude Code, shells, URL bars, code comments. They all spell /
the way you say it: "slash". But "slash" decoded as the word, and a split
command name ("pre compact", "code review") never rejoined — so the one place
voice should shine, driving a REPL, was the one place it fumbled. Fixed as a
spoken-syntax family (#53), not a model tweak: deterministic, post-decode,
reversible — plus a tiny decode-time seed so Whisper hears "slash" at all.
Added
- Spoken slashes (
syntax_features/slashes.py) — every spoken "slash"
becomes "/", a path separator that glues to its neighbours: "slash help" →
/help, "endpoint slash habits" →endpoint/habits, "code slash slash
comment" →code//comment. A curated ontology of Claude Code commands
canonicalises known names ("slash pré compact" →/pre-compact, "slash code
review" →/code-review); accents are trimmed (/cafe, not/café) and what
follows a "/" is treated as ASCII. A "/" after sentence punctuation keeps its
space, so breaks survive ("Bonjour. /help"). Wired into
pipeline.postprocess()(daemon + eval share it); auto-listed in
tuparles cheatsheet. - Spoken hyphen (
punctuation.py) — "tiret" / "trait d'union" / "hyphen" →
-, so you can spell a command or identifier aloud ("slash pré tiret compact"
→/pre-compact). - Command-vocabulary seed (
seed_prompt.COMMAND_SEED) — a tiny, validated
bias so Whisper actually hears the command. The 2026-06-28 take replay
proved it rescues the worst mishear: "slash precompact" decoded as "c'est l'âge
prix compact" →/pre-compact. Rides the protected tail with the manual
glossary, gated bydictseed_bias. slash_commandssetting — a flat list extending the ontology with your own
command names, no source edit. The family as a whole toggles via
settings["syntax"]["slashes"].
Doctrine
- Forensics first: we seeded commands because the replay said to. The earlier
instinct was "rewrite, don't seed". But take 16 has no "slash" in the decoded
text to rewrite — it had to be fixed at decode time. So we measured
(docs/research/2026-06-28-spoken-slash-commands.md): a command-WORDS-only seed
rescues it with zero hallucination, while a URL-example seed invented
facebook.fr/google.com(the Sprint 13 over-seeding tax, reproduced). Seed
the commands, drop the URL examples. - "slash" means
/everywhere — a measured product call. It fires anywhere,
not just a line head, relaxing the house when-in-doubt-text asymmetry: in real
dictation (the take forensics) "slash" was the glyph, never prose. The one
thing we won't fuse is a sentence break. It stays a setting. - URLs are the harder sibling — deferred to a dictation mode. "https deux-
points slash slash …" mangles acoustically ("https2…", "slashnek"), so no
seed or rewrite this sprint fully fixes it. The right answer is a spoken
spell/URL quasimode (#62-kin), scoped separately rather than hacked in here.
TuParles Android — Experimental POC (v0.1)
Experimental local FR/EN code-switch dictation on Android — a proof of concept. Everything runs on-device; no INTERNET permission is declared.
What it is
A capture harness: speak a prompt, it runs the same pipeline as the desktop — native whisper.cpp (on-device) → embedded CPython postprocess() — and saves the audio + raw + cleaned text. One shared Python core with the desktop, no re-port.
Install
- Download
tuparles-experimental.apkbelow (~212 MB — the whisper model is bundled, no download needed). - Enable "install from unknown sources", install, grant the mic permission.
- Open it, wait for "Prêt", tap ● Enregistrer, speak, ■ Stop.
Features
- Toggles: Langue (auto / fr / en) · Postprocess (on / off).
- 📧 Export: send your takes to dev@nech.pl via your own email app (local intent — nothing leaves the phone unless you send it).
- Bundled base model (fast, ~1.5 s/clip). For flawless quality, push a larger model — see
android/README.md.
Honest notes
- Experimental. The bundled
basemodel keeps French as French but fumbles some loanwords;large-v3-turbois flawless but ~30 s/clip on a mid-range phone. - Tested on a Fairphone 6 (arm64). Other devices unverified.
- The code-switch quality verdict and the build story live in
android/README.md.
🎙️ Speak, see, ship — now on your own silicon, in your pocket.
v0.2.0
What's Changed
- Add Wayland + GNOME support (evdev hotkey, ydotool delivery) by @tagadanar in #1
New Contributors
- @tagadanar made their first contribution in #1
Full Changelog: v0.1.0...v0.2.0
v0.1.0
Full Changelog: https://github.com/PLNech/TuParles/commits/v0.1.0


