Skip to content

Releases: PLNech/TuParles

v0.5.1 — la version qui dit vrai

Choose a tag to compare

@github-actions github-actions released this 09 Sep 12:26

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"), and poetry install resolves path-dep
    versions from poetry.lock — not from the packages' pyproject.toml. Bumping
    the pyproject was therefore never enough: without a poetry 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, and v0.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

Choose a tag to compare

@github-actions github-actions released this 09 Sep 12:00

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.

v0.4.0 — Trancher le silence, dérouler le ruban

Choose a tag to compare

@github-actions github-actions released this 11 Jul 14:50

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

Choose a tag to compare

@github-actions github-actions released this 28 Jun 15:09

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 by dictseed_bias.
  • slash_commands setting — 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)

Choose a tag to compare

@PLNech PLNech released this 27 Jun 20:34

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

  1. Download tuparles-experimental.apk below (~212 MB — the whisper model is bundled, no download needed).
  2. Enable "install from unknown sources", install, grant the mic permission.
  3. 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 base model keeps French as French but fumbles some loanwords; large-v3-turbo is 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

Choose a tag to compare

@github-actions github-actions released this 25 Jun 14:17

What's Changed

  • Add Wayland + GNOME support (evdev hotkey, ydotool delivery) by @tagadanar in #1

New Contributors

Full Changelog: v0.1.0...v0.2.0

v0.1.0

Choose a tag to compare

@github-actions github-actions released this 11 Jun 17:39