From 523709cc8db010ca9b62f5390db5f35ab5725c61 Mon Sep 17 00:00:00 2001 From: ruscher Date: Sat, 24 Oct 2026 16:42:27 -0300 Subject: [PATCH 01/10] fix: a `pip install --user` module can no longer break the app at start hermes-agent puts a top-level utils.py in ~/.local/lib/python3.14/ site-packages. services/, ui/ and utils/ had no __init__.py: namespace packages lose to any plain module on sys.path, so every start died with "No module named 'utils.i18n'; 'utils' is not a package" (shortcut too). - services/, ui/, utils/ are regular packages now. - Launchers and the tray helper run python3 -s: the app uses only system packages, nothing in ~/.local may shadow them. - tests/test_module_shadowing.py reproduces it with a decoy user site. Warnings: rows get their icon through set_row_icon (ActionRow.set_icon_name is deprecated), GLibUnix.signal_add replaces GLib.unix_signal_add, and pytest.ini filters only PyGObject's own deprecations: 0 warnings. build: pkgrel 4 --- pkgbuild/PKGBUILD | 2 +- pytest.ini | 6 ++ tests/test_entry_points_history.py | 1 + tests/test_module_shadowing.py | 82 +++++++++++++++++++ usr/bin/biglinux-tts | 8 +- usr/bin/biglinux-tts-speak | 2 +- .../biglinux/tts-biglinux/application.py | 11 ++- .../tts-biglinux/services/__init__.py | 1 + .../tts-biglinux/services/tray_service.py | 1 + .../biglinux/tts-biglinux/ui/__init__.py | 1 + .../biglinux/tts-biglinux/ui/components.py | 9 ++ .../biglinux/tts-biglinux/ui/main_view.py | 13 +-- .../biglinux/tts-biglinux/utils/__init__.py | 1 + 13 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 pytest.ini create mode 100644 tests/test_module_shadowing.py create mode 100644 usr/share/biglinux/tts-biglinux/services/__init__.py create mode 100644 usr/share/biglinux/tts-biglinux/ui/__init__.py create mode 100644 usr/share/biglinux/tts-biglinux/utils/__init__.py diff --git a/pkgbuild/PKGBUILD b/pkgbuild/PKGBUILD index a174b21..a63437e 100644 --- a/pkgbuild/PKGBUILD +++ b/pkgbuild/PKGBUILD @@ -7,7 +7,7 @@ pkgname=tts-biglinux # vercmp); the epoch makes 4.1.0 an upgrade over them. Never remove it. epoch=1 pkgver=4.1.0 -pkgrel=3 +pkgrel=4 arch=('x86_64') license=('GPL-3.0-or-later') url="https://github.com/biglinux/tts-biglinux" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..bab9738 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +# Noise from inside PyGObject itself (Python 3.14 asyncio and GLib API +# deprecations raised by gi/, not by this app). Our own warnings still show. +filterwarnings = + ignore::DeprecationWarning:gi.events + ignore:GLib.unix_signal_add_full is deprecated:DeprecationWarning:gi.overrides diff --git a/tests/test_entry_points_history.py b/tests/test_entry_points_history.py index 5615126..024a8b6 100644 --- a/tests/test_entry_points_history.py +++ b/tests/test_entry_points_history.py @@ -12,6 +12,7 @@ gi = pytest.importorskip("gi") gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gdk, GLib, Gtk # noqa: E402 diff --git a/tests/test_module_shadowing.py b/tests/test_module_shadowing.py new file mode 100644 index 0000000..5cfe1ec --- /dev/null +++ b/tests/test_module_shadowing.py @@ -0,0 +1,82 @@ +"""A module installed by `pip install --user` must never replace the app's own. + +hermes-agent drops a top-level utils.py into ~/.local/lib/pythonX.Y/site-packages. +While utils/ had no __init__.py it was a namespace package, which loses to any +plain module on sys.path, and the app died at start with +"No module named 'utils.i18n'; 'utils' is not a package". +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import sysconfig +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +APP_DIR = ROOT / "usr" / "share" / "biglinux" / "tts-biglinux" +DECOYS = ("utils", "services", "ui", "config", "resources", "application", "window") + + +def _user_site(base: Path) -> Path: + rel = sysconfig.get_path("purelib", f"{os.name}_user", {"userbase": str(base)}) + return Path(rel) + + +@pytest.fixture +def poisoned_user_site(tmp_path): + """A user site-packages full of modules named like the app's packages.""" + site = _user_site(tmp_path) + site.mkdir(parents=True) + for name in DECOYS: + (site / f"{name}.py").write_text("raise ImportError('decoy from user site')\n") + env = dict(os.environ, PYTHONUSERBASE=str(tmp_path), PYTHONDONTWRITEBYTECODE="1") + env.pop("PYTHONNOUSERSITE", None) + probe = subprocess.run( + [sys.executable, "-c", "import site, sys; print(site.ENABLE_USER_SITE)"], + env=env, capture_output=True, text=True, + ) + if probe.stdout.strip() != "True": + pytest.skip("user site-packages disabled in this environment") + return env + + +@pytest.mark.parametrize("package", ["services", "ui", "utils", "resources"]) +def test_every_app_package_is_a_regular_package(package): + assert (APP_DIR / package / "__init__.py").is_file() + + +def test_app_starts_even_without_dash_s(poisoned_user_site): + """The package layer alone keeps the app's modules first.""" + result = subprocess.run( + [sys.executable, "-c", "import utils.i18n, services.settings_service, config"], + cwd=APP_DIR, env=poisoned_user_site, capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + + +def test_main_runs_with_poisoned_user_site(poisoned_user_site): + result = subprocess.run( + [sys.executable, "-s", "main.py", "--version"], + cwd=APP_DIR, env=poisoned_user_site, capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + assert "biglinux-tts" in result.stdout + + +@pytest.mark.parametrize("launcher", ["biglinux-tts", "biglinux-tts-speak"]) +def test_launchers_ignore_user_site(launcher): + text = (ROOT / "usr" / "bin" / launcher).read_text() + runs = [line for line in text.splitlines() if "/usr/bin/python3" in line and "exec" in line] + assert runs, "launcher no longer runs python3 directly" + assert all("python3 -s " in line for line in runs) + + +def test_tray_helper_ignores_user_site(): + from services import tray_service + + source = Path(tray_service.__file__).read_text() + assert '"/usr/bin/python3",\n "-s",' in source diff --git a/usr/bin/biglinux-tts b/usr/bin/biglinux-tts index 55dfe7c..2550053 100755 --- a/usr/bin/biglinux-tts +++ b/usr/bin/biglinux-tts @@ -1,14 +1,18 @@ #!/bin/bash # BigLinux TTS — GUI Launcher +# -s: ignore ~/.local site-packages. A `pip install --user` that drops a +# top-level utils.py there (hermes-agent does) used to shadow the app's own +# modules; the app needs only system packages. + # Detect dev environment: if launched from git repo, use repo's main.py SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" MAIN_PY="${SCRIPT_DIR}/../share/biglinux/tts-biglinux/main.py" if [[ -f "$MAIN_PY" && -d "${SCRIPT_DIR}/../../.git" ]]; then cd "${SCRIPT_DIR}/../share/biglinux/tts-biglinux/" || exit 1 - exec -a biglinux-tts /usr/bin/python3 main.py "$@" + exec -a biglinux-tts /usr/bin/python3 -s main.py "$@" fi cd /usr/share/biglinux/tts-biglinux/ || exit 1 -exec -a biglinux-tts /usr/bin/python3 main.py "$@" +exec -a biglinux-tts /usr/bin/python3 -s main.py "$@" diff --git a/usr/bin/biglinux-tts-speak b/usr/bin/biglinux-tts-speak index 8c1dee1..07520d2 100755 --- a/usr/bin/biglinux-tts-speak +++ b/usr/bin/biglinux-tts-speak @@ -7,7 +7,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" MAIN_PY="${SCRIPT_DIR}/../share/biglinux/tts-biglinux/main.py" if [[ -f "$MAIN_PY" && -d "${SCRIPT_DIR}/../../.git" ]]; then - exec /usr/bin/python3 "${MAIN_PY}" --speak "$@" + exec /usr/bin/python3 -s "${MAIN_PY}" --speak "$@" fi exec /usr/bin/biglinux-tts --speak "$@" diff --git a/usr/share/biglinux/tts-biglinux/application.py b/usr/share/biglinux/tts-biglinux/application.py index a9ff7d8..6f11cec 100644 --- a/usr/share/biglinux/tts-biglinux/application.py +++ b/usr/share/biglinux/tts-biglinux/application.py @@ -41,6 +41,15 @@ logger = logging.getLogger(__name__) +# GLib 2.80 moved unix_signal_add to GLibUnix; the GLib name is deprecated. +try: + gi.require_version("GLibUnix", "2.0") + from gi.repository import GLibUnix + + _unix_signal_add = GLibUnix.signal_add +except (ImportError, ValueError): + _unix_signal_add = GLib.unix_signal_add + class TTSApplication(Adw.Application): """ @@ -127,7 +136,7 @@ def _on_startup(self, app: Adw.Application) -> None: """Application startup — load CSS and create actions.""" # Logout / kill: quit cleanly so no player keeps speaking orphaned. for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): - GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signum, self._on_signal) + _unix_signal_add(GLib.PRIORITY_DEFAULT, signum, self._on_signal) logger.debug("Application startup") load_css() self._create_actions() diff --git a/usr/share/biglinux/tts-biglinux/services/__init__.py b/usr/share/biglinux/tts-biglinux/services/__init__.py new file mode 100644 index 0000000..a010835 --- /dev/null +++ b/usr/share/biglinux/tts-biglinux/services/__init__.py @@ -0,0 +1 @@ +"""Background services: speech engines, settings, history, desktop integration.""" diff --git a/usr/share/biglinux/tts-biglinux/services/tray_service.py b/usr/share/biglinux/tts-biglinux/services/tray_service.py index 8407a94..f6b44c3 100644 --- a/usr/share/biglinux/tts-biglinux/services/tray_service.py +++ b/usr/share/biglinux/tts-biglinux/services/tray_service.py @@ -368,6 +368,7 @@ def register(self) -> None: """Start the Qt6 tray helper subprocess.""" cmd = [ "/usr/bin/python3", + "-s", # no ~/.local site-packages: a pip --user PySide6 must not win "-c", _HELPER_SCRIPT, self._title, diff --git a/usr/share/biglinux/tts-biglinux/ui/__init__.py b/usr/share/biglinux/tts-biglinux/ui/__init__.py new file mode 100644 index 0000000..1a78c5b --- /dev/null +++ b/usr/share/biglinux/tts-biglinux/ui/__init__.py @@ -0,0 +1 @@ +"""GTK4/libadwaita views and dialogs.""" diff --git a/usr/share/biglinux/tts-biglinux/ui/components.py b/usr/share/biglinux/tts-biglinux/ui/components.py index bc00226..c4ec775 100644 --- a/usr/share/biglinux/tts-biglinux/ui/components.py +++ b/usr/share/biglinux/tts-biglinux/ui/components.py @@ -29,6 +29,15 @@ def create_preferences_group( return group +def set_row_icon(row: Adw.ActionRow, icon_name: str) -> Gtk.Image: + """Leading icon for a row (Adw.ActionRow.set_icon_name is deprecated).""" + icon = Gtk.Image.new_from_icon_name(icon_name) + icon.set_valign(Gtk.Align.CENTER) + icon.set_accessible_role(Gtk.AccessibleRole.PRESENTATION) + row.add_prefix(icon) + return icon + + def create_action_row_with_switch( title: str, subtitle: str | None = None, diff --git a/usr/share/biglinux/tts-biglinux/ui/main_view.py b/usr/share/biglinux/tts-biglinux/ui/main_view.py index 028c919..13db1b0 100644 --- a/usr/share/biglinux/tts-biglinux/ui/main_view.py +++ b/usr/share/biglinux/tts-biglinux/ui/main_view.py @@ -56,6 +56,7 @@ create_icon_button, create_preferences_group, create_state_pill, + set_row_icon, set_state_pill, ) from utils.async_utils import run_in_thread @@ -211,7 +212,7 @@ def _build_advanced_button(self) -> Adw.PreferencesGroup: row = Adw.ActionRow() row.set_title(_("Advanced options")) row.set_subtitle(_("Shortcut, performance, behavior")) - row.set_icon_name("emblem-system-symbolic") + set_row_icon(row, "emblem-system-symbolic") row.set_activatable(True) row.connect("activated", self._open_advanced_dialog) arrow = Gtk.Image.new_from_icon_name("go-next-symbolic") @@ -650,7 +651,7 @@ def _build_advanced_section(self) -> Adw.PreferencesGroup: "Select text anywhere and press the shortcut to read aloud. Press again to stop." ) ) - shortcut_row.set_icon_name("input-keyboard-symbolic") + set_row_icon(shortcut_row, "input-keyboard-symbolic") self._shortcut_row = shortcut_row # Current keys + whether the desktop confirmed them. @@ -681,7 +682,7 @@ def _build_advanced_section(self) -> Adw.PreferencesGroup: on_toggled=self._on_launcher_toggle, accessible_name=_("Show system tray icon"), ) - launcher_row.set_icon_name("tts-biglinux-symbolic") + set_row_icon(launcher_row, "tts-biglinux-symbolic") group.add(launcher_row) # ── History save ── @@ -697,7 +698,7 @@ def _build_advanced_section(self) -> Adw.PreferencesGroup: on_toggled=self._on_history_toggle, accessible_name=_("Save speech history"), ) - history_row.set_icon_name("document-save-symbolic") + set_row_icon(history_row, "document-save-symbolic") group.add(history_row) self._history_audio_row, self._history_audio_switch = create_action_row_with_switch( @@ -731,7 +732,7 @@ def _build_advanced_section(self) -> Adw.PreferencesGroup: playback_row = Adw.ComboRow() playback_row.set_title(_("Audio playback mode")) playback_row.set_subtitle(_("Behavior when playing multiple audios or speech")) - playback_row.set_icon_name("media-playback-start-symbolic") + set_row_icon(playback_row, "media-playback-start-symbolic") playback_modes = Gtk.StringList.new( [_("Interrupt previous"), _("Queue"), _("Simultaneous")] ) @@ -750,7 +751,7 @@ def _build_advanced_section(self) -> Adw.PreferencesGroup: on_toggled=self._on_media_player_toggle, accessible_name=_("Show system media player"), ) - media_row.set_icon_name("multimedia-player-symbolic") + set_row_icon(media_row, "multimedia-player-symbolic") group.add(media_row) return group diff --git a/usr/share/biglinux/tts-biglinux/utils/__init__.py b/usr/share/biglinux/tts-biglinux/utils/__init__.py new file mode 100644 index 0000000..9b3e9b0 --- /dev/null +++ b/usr/share/biglinux/tts-biglinux/utils/__init__.py @@ -0,0 +1 @@ +"""Small helpers: translations and thread/main-loop glue.""" From be7a844fd8743f6cf1d34cb96466d9c0a7f1f703 Mon Sep 17 00:00:00 2001 From: ruscher Date: Tue, 3 Nov 2026 02:19:12 -0300 Subject: [PATCH 02/10] fix: Alt+V works again right after login, plus fixes from a full review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shortcut (KDE): kglobalaccel loads every launcher in ~/.local/share/kglobalaccel/ at each login. Old copies of ours there (bigtts.desktop, tts-speak.desktop, default Alt+V) came back as separate components claiming the key, so a cold Alt+V failed until the app had run once (its cleanUp only fixed memory). They are now deleted (only when their Exec runs biglinux-tts), and an existing kglobalaccel copy of biglinux-tts-speak.desktop follows the configured key. Startup and desktop integration: - Restore defaults resets the shared settings object in place: History switches, Kokoro blend, playback mode and media player now really reset. - A tray helper with no tray host (or that crashed) is reported; the app drops the tray instead of hiding the window into nothing. - Xfce: no longer deletes the user's own bindings to biglinux-tts, sets the new binding before removing the old, never overwrites another command. - Unknown desktops no longer claim a shortcut nobody listens to. - X11: the xsel fallback reads the CLIPBOARD (it read PRIMARY twice). - Queue mode: an item that processes to empty no longer stalls the queue. - 10 s inactivity timeout: an Alt+V during shutdown is not lost. - A corrupt/unreadable settings.json (or Infinity in it) no longer crashes; it is kept as settings.json.corrupt and History stays off. - No more libadwaita "gtk-application-prefer-dark-theme" warning at start. Speech: - Simultaneous mode: a paused reading is resumed before a new one starts (it stayed SIGSTOP'd forever and every stop() blocked 0.5 s). - Pause also freezes players that start later (next chunk), and works while the voice is still loading. - espeak-ng CLI fallback can be stopped; stop() reaps killed processes. - Piper voices are listed with only the native engine; the Piper CLI fallback applies the volume. - Kokoro: mixed punctuation runs (Fim.! / "Pare!". / etc….) are collapsed so koko never gets a punctuation-only piece; pitch/volume sliders are insensitive (koko has no such options). History: - Cards no longer appear twice when the engine list changes. - Undo works after a reload; no Gtk-WARNING removing a non-child. - Queue mode does not replay a track and advances on Stop. - Players remove their bus watch (leak); "Open file location" and "Save audio as" no longer block the window; legacy migration is race-free. - Accessible names for play/pause, stop and the seek bar. New strings translated in every catalog. --- locale/bg.po | 8 + locale/ca.po | 8 + locale/cs.po | 8 + locale/da.po | 8 + locale/de.po | 8 + locale/el.po | 8 + locale/en.po | 10 + locale/es.po | 8 + locale/et.po | 8 + locale/fi.po | 8 + locale/fr.po | 8 + locale/he.po | 8 + locale/hr.po | 8 + locale/hu.po | 8 + locale/is.po | 8 + locale/it.po | 8 + locale/ja.po | 8 + locale/ko.po | 8 + locale/nl.po | 8 + locale/no.po | 8 + locale/pl.po | 8 + locale/pt-BR.po | 8 + locale/pt.po | 6 + locale/ro.po | 8 + locale/ru.po | 8 + locale/sk.po | 8 + locale/sv.po | 8 + locale/tr.po | 8 + locale/tts-biglinux.pot | 10 + locale/uk.po | 8 + locale/zh.po | 8 + tests/test_history_fixes.py | 452 ++++++++++++++++++ tests/test_kde_launchers.py | 79 +++ tests/test_koko_text.py | 23 + tests/test_speech_fixes.py | 308 ++++++++++++ tests/test_startup_fixes.py | 165 +++++++ .../biglinux/tts-biglinux/application.py | 32 +- usr/share/biglinux/tts-biglinux/config.py | 18 +- usr/share/biglinux/tts-biglinux/main.py | 9 + .../services/clipboard_service.py | 2 +- .../services/desktop_integration_service.py | 108 ++++- .../tts-biglinux/services/history_db.py | 62 ++- .../services/kokoro_voice_service.py | 28 +- .../tts-biglinux/services/settings_service.py | 12 +- .../tts-biglinux/services/shortcut_service.py | 12 +- .../tts-biglinux/services/tray_service.py | 21 +- .../tts-biglinux/services/tts_service.py | 134 ++++-- .../tts-biglinux/services/voice_manager.py | 11 +- .../biglinux/tts-biglinux/ui/audio_player.py | 70 ++- .../biglinux/tts-biglinux/ui/history_view.py | 138 ++++-- .../biglinux/tts-biglinux/ui/main_view.py | 39 +- 51 files changed, 1804 insertions(+), 169 deletions(-) create mode 100644 tests/test_history_fixes.py create mode 100644 tests/test_kde_launchers.py create mode 100644 tests/test_speech_fixes.py create mode 100644 tests/test_startup_fixes.py diff --git a/locale/bg.po b/locale/bg.po index 8a221c8..1d83499 100644 --- a/locale/bg.po +++ b/locale/bg.po @@ -1302,3 +1302,11 @@ msgstr "Пакетите, отбелязани с AUR, не са във ваши #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "{packages} не можа да бъде компилиран от AUR. Вижте подробностите." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Позиция на възпроизвеждане" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Гласовете Kokoro не поддържат тази настройка." diff --git a/locale/ca.po b/locale/ca.po index 9f1fddc..717b302 100644 --- a/locale/ca.po +++ b/locale/ca.po @@ -1686,3 +1686,11 @@ msgstr "Els paquets marcats amb AUR no són als vostres repositoris: provenen de #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "No s'ha pogut compilar {packages} des de l'AUR. Vegeu els detalls." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Posició de la reproducció" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Les veus Kokoro no admeten aquest ajust." diff --git a/locale/cs.po b/locale/cs.po index eb88de5..22647c1 100644 --- a/locale/cs.po +++ b/locale/cs.po @@ -1302,3 +1302,11 @@ msgstr "Balíčky označené AUR nejsou ve vašich repozitářích: pocházejí #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "{packages} se nepodařilo sestavit z AUR. Podívejte se na podrobnosti." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Pozice přehrávání" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Hlasy Kokoro toto nastavení nepodporují." diff --git a/locale/da.po b/locale/da.po index 88f56fb..63409cb 100644 --- a/locale/da.po +++ b/locale/da.po @@ -1302,3 +1302,11 @@ msgstr "Pakker markeret med AUR findes ikke i dine arkiver: de kommer fra Arch U #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "{packages} kunne ikke bygges fra AUR. Se detaljerne." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Afspilningsposition" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Kokoro-stemmer understøtter ikke denne indstilling." diff --git a/locale/de.po b/locale/de.po index 2307b86..310e79c 100644 --- a/locale/de.po +++ b/locale/de.po @@ -1310,3 +1310,11 @@ msgstr "Mit AUR markierte Pakete sind nicht in Ihren Paketquellen: Sie stammen a #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "{packages} konnte nicht aus dem AUR gebaut werden. Siehe Details." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Wiedergabeposition" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Kokoro-Stimmen unterstützen diese Einstellung nicht." diff --git a/locale/el.po b/locale/el.po index 80cade2..c9044f8 100644 --- a/locale/el.po +++ b/locale/el.po @@ -1304,3 +1304,11 @@ msgstr "Τα πακέτα με την ένδειξη AUR δεν βρίσκοντ #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "Δεν ήταν δυνατή η μεταγλώττιση του {packages} από το AUR. Δείτε τις λεπτομέρειες." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Θέση αναπαραγωγής" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Οι φωνές Kokoro δεν υποστηρίζουν αυτή τη ρύθμιση." diff --git a/locale/en.po b/locale/en.po index c61d389..e487322 100644 --- a/locale/en.po +++ b/locale/en.po @@ -2131,3 +2131,13 @@ msgstr "This will return all adjustments to their original defaults." # File: tts-biglinux/usr/share/biglinux/tts-biglinux/window.py, line: 465 msgid "Restore" msgstr "Restore" + +# +# File: tts-biglinux/usr/share/biglinux/tts-biglinux/ui/audio_player.py, line: 134 +msgid "Playback position" +msgstr "Playback position" + +# +# File: tts-biglinux/usr/share/biglinux/tts-biglinux/ui/main_view.py, line: 1296 +msgid "Kokoro voices do not support this setting." +msgstr "Kokoro voices do not support this setting." diff --git a/locale/es.po b/locale/es.po index 1fda8fc..edd72ce 100644 --- a/locale/es.po +++ b/locale/es.po @@ -1310,3 +1310,11 @@ msgstr "Los paquetes marcados con AUR no están en sus repositorios: provienen d #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "No se pudo compilar {packages} desde el AUR. Consulte los detalles." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Posición de reproducción" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Las voces Kokoro no admiten este ajuste." diff --git a/locale/et.po b/locale/et.po index 26214cd..ca17893 100644 --- a/locale/et.po +++ b/locale/et.po @@ -1302,3 +1302,11 @@ msgstr "AUR-iga märgitud pakette pole teie hoidlates: need pärinevad Arch User #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "Paketti {packages} ei õnnestunud AUR-ist ehitada. Vaadake üksikasju." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Esitamise asukoht" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Kokoro hääled seda seadet ei toeta." diff --git a/locale/fi.po b/locale/fi.po index 5454e77..1161eac 100644 --- a/locale/fi.po +++ b/locale/fi.po @@ -1302,3 +1302,11 @@ msgstr "AUR-merkityt paketit eivät ole ohjelmistolähteissäsi: ne tulevat Arch #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "Pakettia {packages} ei voitu kääntää AUR:sta. Katso lisätiedot." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Toistokohta" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Kokoro-äänet eivät tue tätä asetusta." diff --git a/locale/fr.po b/locale/fr.po index 7d074fc..1408dbc 100644 --- a/locale/fr.po +++ b/locale/fr.po @@ -1310,3 +1310,11 @@ msgstr "Les paquets marqués AUR ne sont pas dans vos dépôts : ils proviennent #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "{packages} n'a pas pu être compilé depuis l'AUR. Consultez les détails." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Position de lecture" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Les voix Kokoro ne prennent pas en charge ce réglage." diff --git a/locale/he.po b/locale/he.po index 0b19bcd..845717f 100644 --- a/locale/he.po +++ b/locale/he.po @@ -1302,3 +1302,11 @@ msgstr "חבילות המסומנות ב-AUR אינן במאגרים שלך: ה #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "לא ניתן היה לבנות את {packages} מ-AUR. ראו את הפרטים." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "מיקום ההשמעה" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "קולות Kokoro אינם תומכים בהגדרה זו." diff --git a/locale/hr.po b/locale/hr.po index 351c46a..0dde770 100644 --- a/locale/hr.po +++ b/locale/hr.po @@ -1304,3 +1304,11 @@ msgstr "Paketi označeni s AUR nisu u vašim repozitorijima: dolaze iz Arch User #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "{packages} nije moguće izgraditi iz AUR-a. Pogledajte pojedinosti." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Položaj reprodukcije" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Kokoro glasovi ne podržavaju ovu postavku." diff --git a/locale/hu.po b/locale/hu.po index ebf6671..721db66 100644 --- a/locale/hu.po +++ b/locale/hu.po @@ -1302,3 +1302,11 @@ msgstr "Az AUR jelölésű csomagok nincsenek a tárolóidban: az Arch User Repo #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "A(z) {packages} nem fordítható le az AUR-ból. Lásd a részleteket." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Lejátszási pozíció" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "A Kokoro hangok nem támogatják ezt a beállítást." diff --git a/locale/is.po b/locale/is.po index 8eb1200..91714d8 100644 --- a/locale/is.po +++ b/locale/is.po @@ -1302,3 +1302,11 @@ msgstr "Pakkar merktir AUR eru ekki í hugbúnaðarsöfnunum þínum: þeir koma #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "Ekki tókst að byggja {packages} úr AUR. Sjá nánar." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Staða spilunar" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Kokoro-raddir styðja ekki þessa stillingu." diff --git a/locale/it.po b/locale/it.po index 04f6f1d..3f76513 100644 --- a/locale/it.po +++ b/locale/it.po @@ -1310,3 +1310,11 @@ msgstr "I pacchetti contrassegnati con AUR non sono nei tuoi repository: proveng #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "Impossibile compilare {packages} dall'AUR. Vedi i dettagli." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Posizione di riproduzione" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Le voci Kokoro non supportano questa impostazione." diff --git a/locale/ja.po b/locale/ja.po index 4b81427..4cde188 100644 --- a/locale/ja.po +++ b/locale/ja.po @@ -1302,3 +1302,11 @@ msgstr "AUR と表示されたパッケージはリポジトリにありませ #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "{packages} を AUR からビルドできませんでした。詳細を確認してください。" + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "再生位置" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Kokoro の音声はこの設定に対応していません。" diff --git a/locale/ko.po b/locale/ko.po index 17e7538..f553014 100644 --- a/locale/ko.po +++ b/locale/ko.po @@ -1302,3 +1302,11 @@ msgstr "AUR로 표시된 패키지는 저장소에 없습니다. Arch User Repos #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "AUR에서 {packages}을(를) 빌드할 수 없습니다. 세부 정보를 확인하세요." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "재생 위치" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Kokoro 음성은 이 설정을 지원하지 않습니다." diff --git a/locale/nl.po b/locale/nl.po index 43077f4..ae8828b 100644 --- a/locale/nl.po +++ b/locale/nl.po @@ -1304,3 +1304,11 @@ msgstr "Pakketten gemarkeerd met AUR staan niet in je pakketbronnen: ze komen ui #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "{packages} kon niet vanuit de AUR worden gebouwd. Zie de details." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Afspeelpositie" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Kokoro-stemmen ondersteunen deze instelling niet." diff --git a/locale/no.po b/locale/no.po index da7239c..61b76db 100644 --- a/locale/no.po +++ b/locale/no.po @@ -1302,3 +1302,11 @@ msgstr "Pakker merket AUR finnes ikke i pakkebrønnene dine: de kommer fra Arch #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "{packages} kunne ikke bygges fra AUR. Se detaljene." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Avspillingsposisjon" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Kokoro-stemmer støtter ikke denne innstillingen." diff --git a/locale/pl.po b/locale/pl.po index ed35e9a..e07aaba 100644 --- a/locale/pl.po +++ b/locale/pl.po @@ -1302,3 +1302,11 @@ msgstr "Pakiety oznaczone jako AUR nie znajdują się w Twoich repozytoriach: po #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "Nie udało się zbudować {packages} z AUR. Zobacz szczegóły." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Pozycja odtwarzania" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Głosy Kokoro nie obsługują tego ustawienia." diff --git a/locale/pt-BR.po b/locale/pt-BR.po index eb07e70..053c9dc 100644 --- a/locale/pt-BR.po +++ b/locale/pt-BR.po @@ -1678,3 +1678,11 @@ msgstr "Os pacotes marcados com AUR não estão nos seus repositórios: vêm do #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "Não foi possível compilar {packages} a partir do AUR. Veja os detalhes." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Posição da reprodução" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "As vozes Kokoro não aceitam este ajuste." diff --git a/locale/pt.po b/locale/pt.po index 371cc33..f47fe3c 100644 --- a/locale/pt.po +++ b/locale/pt.po @@ -1302,3 +1302,9 @@ msgstr "Os pacotes marcados com AUR não estão nos seus repositórios: vêm do #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "Não foi possível compilar {packages} a partir do AUR. Consulte os detalhes." +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Posição da reprodução" +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "As vozes Kokoro não suportam esta definição." diff --git a/locale/ro.po b/locale/ro.po index 4e0edf9..60ec6d6 100644 --- a/locale/ro.po +++ b/locale/ro.po @@ -1302,3 +1302,11 @@ msgstr "Pachetele marcate cu AUR nu se află în depozitele tale: provin din Arc #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "{packages} nu a putut fi compilat din AUR. Vezi detaliile." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Poziția redării" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Vocile Kokoro nu acceptă această setare." diff --git a/locale/ru.po b/locale/ru.po index 8fb9303..af9aed1 100644 --- a/locale/ru.po +++ b/locale/ru.po @@ -1310,3 +1310,11 @@ msgstr "Пакеты с пометкой AUR отсутствуют в ваши #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "Не удалось собрать {packages} из AUR. См. подробности." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Позиция воспроизведения" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Голоса Kokoro не поддерживают этот параметр." diff --git a/locale/sk.po b/locale/sk.po index e586253..806fcc3 100644 --- a/locale/sk.po +++ b/locale/sk.po @@ -1302,3 +1302,11 @@ msgstr "Balíky označené AUR nie sú vo vašich repozitároch: pochádzajú z #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "{packages} sa nepodarilo zostaviť z AUR. Pozrite si podrobnosti." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Pozícia prehrávania" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Hlasy Kokoro toto nastavenie nepodporujú." diff --git a/locale/sv.po b/locale/sv.po index f13115f..16a6a84 100644 --- a/locale/sv.po +++ b/locale/sv.po @@ -1302,3 +1302,11 @@ msgstr "Paket märkta med AUR finns inte i dina förråd: de kommer från Arch U #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "{packages} kunde inte byggas från AUR. Se detaljerna." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Uppspelningsposition" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Kokoro-röster stöder inte den här inställningen." diff --git a/locale/tr.po b/locale/tr.po index d3a2d41..1e7ea01 100644 --- a/locale/tr.po +++ b/locale/tr.po @@ -1302,3 +1302,11 @@ msgstr "AUR olarak işaretlenen paketler depolarınızda yok: Arch User Reposito #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "{packages} AUR'dan derlenemedi. Ayrıntılara bakın." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Oynatma konumu" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Kokoro sesleri bu ayarı desteklemiyor." diff --git a/locale/tts-biglinux.pot b/locale/tts-biglinux.pot index 557ff14..b025e37 100644 --- a/locale/tts-biglinux.pot +++ b/locale/tts-biglinux.pot @@ -2131,3 +2131,13 @@ msgstr "" # File: tts-biglinux/usr/share/biglinux/tts-biglinux/window.py, line: 465 msgid "Restore" msgstr "" + +# +# File: tts-biglinux/usr/share/biglinux/tts-biglinux/ui/audio_player.py, line: 134 +msgid "Playback position" +msgstr "" + +# +# File: tts-biglinux/usr/share/biglinux/tts-biglinux/ui/main_view.py, line: 1296 +msgid "Kokoro voices do not support this setting." +msgstr "" diff --git a/locale/uk.po b/locale/uk.po index 105cb7d..b75260d 100644 --- a/locale/uk.po +++ b/locale/uk.po @@ -1302,3 +1302,11 @@ msgstr "Пакунки з позначкою AUR відсутні у ваших #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "Не вдалося зібрати {packages} з AUR. Перегляньте подробиці." + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "Позиція відтворення" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Голоси Kokoro не підтримують цей параметр." diff --git a/locale/zh.po b/locale/zh.po index 1ca8c0c..ff87b3e 100644 --- a/locale/zh.po +++ b/locale/zh.po @@ -1302,3 +1302,11 @@ msgstr "标记为 AUR 的软件包不在您的软件源中:它们来自 Arch U #: usr/share/biglinux/tts-biglinux/services/package_installer.py:265 msgid "{packages} could not be built from the AUR. See the details." msgstr "无法从 AUR 构建 {packages}。请查看详细信息。" + +#: usr/share/biglinux/tts-biglinux/ui/audio_player.py:134 +msgid "Playback position" +msgstr "播放位置" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 +msgid "Kokoro voices do not support this setting." +msgstr "Kokoro 语音不支持此设置。" diff --git a/tests/test_history_fixes.py b/tests/test_history_fixes.py new file mode 100644 index 0000000..bc9710b --- /dev/null +++ b/tests/test_history_fixes.py @@ -0,0 +1,452 @@ +"""Regressions of the History page review: double cards, Undo after a reload, +queue playback, leaked bus watches, a frozen "Open file location", the +JSON migration race and the player's accessible names. + +GTK tests are skipped without a display — run headless with +GDK_BACKEND=broadway (see test_history_view.py). +""" +import importlib +import json +import threading +import time + +import pytest + +gi = pytest.importorskip("gi") +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +gi.require_version("Gst", "1.0") +from gi.repository import Adw, Gdk, Gio, GLib, Gst, Gtk # noqa: E402 + +HAVE_DISPLAY = Gtk.init_check() and Gdk.Display.get_default() is not None +needs_gtk = pytest.mark.skipif(not HAVE_DISPLAY, reason="no display for GTK") +if HAVE_DISPLAY: + Adw.init() + +from fake_engines import text_wav # noqa: E402 + +config = importlib.import_module("config") +hs = importlib.import_module("services.history_service") +hdb = importlib.import_module("services.history_db") +hv = importlib.import_module("ui.history_view") if HAVE_DISPLAY else None +ap = importlib.import_module("ui.audio_player") if HAVE_DISPLAY else None + + +def _pump(condition=lambda: False, timeout=3.0): + ctx = GLib.MainContext.default() + end = time.monotonic() + timeout + while time.monotonic() < end: + while ctx.iteration(False): + pass + if condition(): + return True + time.sleep(0.01) + return condition() + + +def _add(text, backend="piper", audio=True, tmp=None): + path = text_wav(tmp / f"{abs(hash(text))}.wav", "a") if audio else None + hs.save_history_entry(text=text, audio_path=path, backend=backend, voice_id=f"{backend}:voz") + + +def _settled(view): + """Wait until no page is loading, then a little more (late reloads).""" + _pump(lambda: not view._loading and view._stack.get_visible_child_name() != "loading") + _pump(timeout=0.3) + _pump(lambda: not view._loading) + return view + + +@pytest.fixture +def page(tmp_path): + settings = config.AppSettings() + view = hv.HistoryView(settings) + window = Gtk.Window() + window.set_child(view) + window.present() + yield view + window.destroy() + + +def _ids(view): + return [c.entry["id"] for c in view._cards] + + +def _texts(view): + return [c.entry["text"] for c in view._cards] + + +# ── 1. A new engine in the filter must not load every card twice ─────── + +@needs_gtk +def test_new_engine_keeps_the_filter_and_loads_cards_once(page, tmp_path): + view = page + _add("leitura kokoro", "kokoro", tmp=tmp_path) + _add("leitura piper", "piper", tmp=tmp_path) + view.reload() + _settled(view) + assert view._backends == ["kokoro", "piper"] + view._engine.set_selected(2) # piper + _settled(view) + assert _texts(view) == ["leitura piper"] + + # "espeak" sorts first: piper moves from position 2 to 3 in the model. + _add("leitura espeak", "espeak", tmp=tmp_path) + view.reload() + _settled(view) + assert view._backends == ["espeak", "kokoro", "piper"] + assert view._engine.get_selected() == 3 + assert view._filters()["backend"] == "piper" + assert _texts(view) == ["leitura piper"] + assert len(set(_ids(view))) == len(_ids(view)) + assert view._offset == 1 + + +@needs_gtk +def test_engine_gone_resets_the_filter_once_without_duplicates(page, tmp_path): + view = page + _add("leitura kokoro", "kokoro", tmp=tmp_path) + _add("leitura piper", "piper", tmp=tmp_path) + view.reload() + _settled(view) + view._engine.set_selected(1) # kokoro + _settled(view) + assert _texts(view) == ["leitura kokoro"] + with hdb._connect() as conn: + conn.execute("DELETE FROM entries WHERE backend = 'kokoro'") + view.reload() + _settled(view) + assert view._engine.get_selected() == 0 + assert _texts(view) == ["leitura piper"] + assert view._offset == 1 + + +# ── 2. Undo / delete after the list was reloaded ─────────────────────── + +@needs_gtk +def test_undo_after_a_reload_brings_the_entry_back(page, tmp_path): + view = page + _add("fica", tmp=tmp_path) + _add("quase apagada", tmp=tmp_path) + view.reload() + _settled(view) + card = next(c for c in view._cards if c.entry["text"] == "quase apagada") + entry_id = card.entry["id"] + view.delete_with_undo(card) + view.reload() # e.g. a reading was saved meanwhile + _settled(view) + new = view._pending_delete[entry_id] + assert new is not card and new in view._cards + assert not new.get_parent().get_visible() # still hidden + view._undo_toast.emit("button-clicked") + view._undo_toast.dismiss() + _pump(timeout=0.3) + assert new.get_parent().get_visible() + assert entry_id not in view._pending_delete + assert hs.count_history_entries() == 2 + + +@needs_gtk +def test_delete_after_a_reload_removes_only_real_children(page, tmp_path): + view = page + _add("apagar depois", tmp=tmp_path) + _add("outra", tmp=tmp_path) + view.reload() + _settled(view) + card = next(c for c in view._cards if c.entry["text"] == "apagar depois") + old_row = card.get_parent() + view.delete_with_undo(card) + view.reload() + _settled(view) + assert old_row.get_parent() is None # the reload dropped the old row + + removed = [] + real_remove = view._list.remove + + def checked_remove(row): + assert row.get_parent() is view._list, "removing a row that is not in the list" + removed.append(row) + real_remove(row) + + view._list.remove = checked_remove + view._undo_toast.dismiss() + assert _pump(lambda: hs.count_history_entries() == 1) + assert len(removed) == 1 + _settled(view) # deleting notifies the listeners: the page reloads + assert _texts(view) == ["outra"] + + # The original card (already out of the list) is handled quietly too. + view._remove_card(card) + assert len(removed) == 1 + + +@needs_gtk +def test_deleting_the_only_card_after_a_reload_shows_the_empty_page(page, tmp_path): + view = page + _add("única", tmp=tmp_path) + view.reload() + _settled(view) + view.delete_with_undo(view._cards[0]) + view.reload() + _settled(view) + assert view._stack.get_visible_child_name() == "empty" # only a hidden card + view._undo_toast.emit("button-clicked") + view._undo_toast.dismiss() + _pump(timeout=0.3) + assert view._stack.get_visible_child_name() == "content" + assert view._cards[0].get_parent().get_visible() + + +# ── 3. Queue mode ────────────────────────────────────────────────────── + +class _FakePipeline: + def __init__(self): + self.states = [] + + def set_state(self, state): + self.states.append(state) + + def query_position(self, _fmt): + return False, 0 + + def query_duration(self, _fmt): + return False, 0 + + +@pytest.fixture +def players(): + cls = ap.AudioPlayerWidget + made = [] + + def make(): + p = cls("/nonexistent.wav") + p._pipeline = _FakePipeline() + made.append(p) + return p + + cls.set_playback_mode("queue") + yield make + for p in made: + p.cleanup() + cls.set_playback_mode("interrupt") + cls._queue.clear() + cls._active_player = None + + +@needs_gtk +def test_queue_advances_when_the_current_one_is_stopped(players): + cls = ap.AudioPlayerWidget + a, b, c = players(), players(), players() + a._on_play_pause(None) + b._on_play_pause(None) + c._on_play_pause(None) + assert a._playing and not b._playing and not c._playing + assert cls._queue == [b, c] + a._on_stop() + assert b._playing and cls._active_player is b + assert cls._queue == [c] + b._on_eos_idle() # end of b + assert c._playing and cls._queue == [] + c._on_eos_idle() + assert not any(p._playing for p in (a, b, c)) + assert cls._active_player is None + + +@needs_gtk +def test_queued_player_started_by_hand_does_not_play_again(players): + cls = ap.AudioPlayerWidget + a, b = players(), players() + a._on_play_pause(None) + b._on_play_pause(None) + assert cls._queue == [b] + a._on_play_pause(None) # pause a: nothing is playing now + b._on_play_pause(None) # b starts by hand + assert b._playing and cls._queue == [] + b._on_eos_idle() + assert not b._playing # not replayed from its old place in the queue + + +@needs_gtk +def test_stopping_in_interrupt_mode_does_not_start_the_queue(players): + cls = ap.AudioPlayerWidget + a, b = players(), players() + a._on_play_pause(None) + b._on_play_pause(None) + cls.set_playback_mode("interrupt") + assert cls._queue == [] + a._on_stop() + assert not b._playing + + +# ── 4. The bus watch is released ─────────────────────────────────────── + +@needs_gtk +def test_cleanup_removes_the_bus_watch(tmp_path, monkeypatch): + wav = text_wav(tmp_path / "a.wav", "a") + player = ap.AudioPlayerWidget(str(wav)) + assert player._ensure_pipeline() + bus = player._bus + handlers = list(player._bus_handlers) + assert handlers and all(bus.handler_is_connected(h) for h in handlers) + removed = [] + real = Gst.Bus.remove_signal_watch + monkeypatch.setattr(Gst.Bus, "remove_signal_watch", lambda b: (removed.append(b), real(b))[1]) + player.cleanup() + assert removed == [bus] + assert not any(bus.handler_is_connected(h) for h in handlers) + assert player._bus is None and player._pipeline is None + player.cleanup() # twice is harmless + assert removed == [bus] + + +# ── 5. Opening the folder and saving never block the window ─────────── + +class _FakeConn: + def __init__(self, fail): + self.fail = fail + self.calls = [] + + def call(self, name, path, iface, method, params, reply, flags, timeout, cancel, callback): + self.calls.append((name, method, params.unpack(), timeout)) + callback(self, "result") + + def call_finish(self, _result): + if self.fail: + raise GLib.Error("org.freedesktop.DBus.Error.ServiceUnknown") + + +@needs_gtk +@pytest.mark.parametrize("fail", [False, True]) +def test_open_file_location_is_asynchronous(monkeypatch, tmp_path, fail): + target = tmp_path / "leitura.wav" + target.write_bytes(b"") + pending, opened = [], [] + conn = _FakeConn(fail) + monkeypatch.setattr(Gio, "bus_get", lambda bus, cancel, cb: pending.append(cb)) + monkeypatch.setattr(Gio, "bus_get_finish", lambda _r: conn) + monkeypatch.setattr(hv, "_open_folder", opened.append) + hv._show_in_file_manager(target) + assert len(pending) == 1 and conn.calls == [] # returned at once, no waiting + pending[0](None, "result") + (name, method, params, timeout) = conn.calls[0] + assert (name, method) == ("org.freedesktop.FileManager1", "ShowItems") + assert params == ([target.as_uri()], "") + assert 0 < timeout <= 15000 + assert opened == ([target] if fail else []) + + +@needs_gtk +def test_open_file_location_without_a_bus_opens_the_folder(monkeypatch, tmp_path): + opened = [] + + def no_bus(_r): + raise GLib.Error("no session bus") + + monkeypatch.setattr(Gio, "bus_get", lambda bus, cancel, cb: cb(None, "result")) + monkeypatch.setattr(Gio, "bus_get_finish", no_bus) + monkeypatch.setattr(hv, "_open_folder", opened.append) + hv._show_in_file_manager(tmp_path) + assert opened == [tmp_path] + + +@needs_gtk +def test_save_audio_copies_off_the_main_thread(page, tmp_path, monkeypatch): + view = page + _add("salvar", tmp=tmp_path) + view.reload() + _settled(view) + card = view._cards[0] + dest = tmp_path / "out.wav" + threads, toasts = [], [] + monkeypatch.setattr(Gtk.FileDialog, "save", lambda d, parent, cancel, cb: cb(d, None)) + monkeypatch.setattr(Gtk.FileDialog, "save_finish", lambda d, r: Gio.File.new_for_path(str(dest))) + real_export = hs.export_audio + + def export(entry, destination): + threads.append(threading.current_thread()) + return real_export(entry, destination) + + monkeypatch.setattr(hs, "export_audio", export) + view.toast = toasts.append + card._on_export() + assert _pump(lambda: toasts) + assert threads and threads[0] is not threading.main_thread() + assert toasts == [hv._("Audio saved")] + assert dest.stat().st_size > 0 + + +# ── 6. Two migrations at once do not duplicate rows ──────────────────── + +def test_concurrent_json_migrations_insert_each_entry_once(tmp_path, monkeypatch): + folder = hs.get_history_dir() + folder.mkdir(parents=True, exist_ok=True) + entries = [ + {"timestamp": f"2026-09-15_10-00-{i:02d}-000000", "backend": "piper", + "voice_id": "v", "text_preview": f"t{i}", "has_audio": False} + for i in range(6) # no ids: duplicates would get fresh uuids + ] + jf = folder / "history.json" + jf.write_text(json.dumps(entries), encoding="utf-8") + hdb.count_entries() # create the database first + + real_epoch = hdb.ts_to_epoch + + def slow_epoch(ts): # widen the window between "check" and "insert" + time.sleep(0.03) + return real_epoch(ts) + + monkeypatch.setattr(hdb, "ts_to_epoch", slow_epoch) + real_read = type(jf).read_text + barrier = threading.Barrier(2) + + def read_together(self, *a, **kw): + text = real_read(self, *a, **kw) + if self.name == "history.json": + barrier.wait(timeout=5) # both runs have the data before either inserts + return text + + monkeypatch.setattr(type(jf), "read_text", read_together) + results, errors = [], [] + + def run(): + try: + results.append(hdb.migrate_from_json(jf)) + except Exception as e: # IntegrityError / duplicates are the bug + errors.append(e) + + threads = [threading.Thread(target=run) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=30) + assert errors == [] + assert sorted(results) == [0, 6] + assert hdb.count_entries() == 6 + + +# ── 7. Accessible names of the player ───────────────────────────────── + +@needs_gtk +def test_player_controls_have_accessible_names(monkeypatch): + labels = {} + real = Gtk.Widget.update_property + + def record(widget, props, values): + for prop, value in zip(props, values): + if prop == Gtk.AccessibleProperty.LABEL: + labels[widget] = value + return real(widget, props, values) + + monkeypatch.setattr(Gtk.Widget, "update_property", record, raising=False) + player = ap.AudioPlayerWidget("/nonexistent.wav") + _ = ap._ + assert labels[player._play_btn] == _("Play") + assert labels[player._stop_btn] == _("Stop") + assert labels.get(player._seek_scale) + player._pipeline = _FakePipeline() + player._on_play_pause(None) + assert labels[player._play_btn] == _("Pause") == player._play_btn.get_tooltip_text() + player._on_stop() + assert labels[player._play_btn] == _("Play") + player.cleanup() + ap.AudioPlayerWidget._active_player = None diff --git a/tests/test_kde_launchers.py b/tests/test_kde_launchers.py new file mode 100644 index 0000000..7eba14a --- /dev/null +++ b/tests/test_kde_launchers.py @@ -0,0 +1,79 @@ +"""Old speak-shortcut launchers in ~/.local/share/kglobalaccel/ must go. + +kglobalacceld loads every .desktop file there at login; old copies of ours +(bigtts.desktop, tts-speak.desktop) each claimed Alt+V with their default +X-KDE-Shortcuts, so the shortcut failed until the app had started once. +""" +from pathlib import Path + +import pytest + +from services.desktop_integration_service import DesktopIntegrationService as DIS + +OLD_LAUNCHER = """[Desktop Entry] +Type=Application +Exec=biglinux-tts-speak +Name=Speech or stop selected text +X-KDE-Shortcuts=Alt+V +""" + + +@pytest.fixture +def home(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + (tmp_path / ".local" / "share" / "kglobalaccel").mkdir(parents=True) + return tmp_path + + +def _kga(home: Path) -> Path: + return home / ".local" / "share" / "kglobalaccel" + + +def test_old_launchers_of_ours_are_removed(home): + for name in ("bigtts.desktop", "tts-speak.desktop"): + (_kga(home) / name).write_text(OLD_LAUNCHER) + (_kga(home) / "tts-speak.desktop").write_text( + OLD_LAUNCHER.replace("Exec=biglinux-tts-speak", "Exec=/home/x/.local/bin/biglinux-tts-speak") + ) + + removed = DIS.remove_stale_kde_launchers() + + assert sorted(p.name for p in removed) == ["bigtts.desktop", "tts-speak.desktop"] + assert list(_kga(home).iterdir()) == [] + + +def test_foreign_launcher_with_same_name_is_kept(home): + other = _kga(home) / "tts-speak.desktop" + other.write_text("[Desktop Entry]\nType=Application\nExec=someone-elses-tts\n") + + assert DIS.remove_stale_kde_launchers() == [] + assert other.exists() + + +def test_current_launcher_is_never_removed(home): + ours = _kga(home) / "biglinux-tts-speak.desktop" + ours.write_text(OLD_LAUNCHER) + + DIS.remove_stale_kde_launchers() + + assert ours.exists() + + +def test_missing_dir_is_fine(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + assert DIS.remove_stale_kde_launchers() == [] + + +def test_kglobalaccel_copy_follows_the_configured_key(home): + copy = _kga(home) / "biglinux-tts-speak.desktop" + copy.write_text(OLD_LAUNCHER) + + written = DIS.ensure_desktop_file("Ctrl+Alt+S") + + assert "X-KDE-Shortcuts=Ctrl+Alt+S\n" in copy.read_text() + assert copy.read_text() == written.read_text() + + +def test_no_kglobalaccel_copy_is_created(home): + DIS.ensure_desktop_file("Alt+V") + assert not (_kga(home) / "biglinux-tts-speak.desktop").exists() diff --git a/tests/test_koko_text.py b/tests/test_koko_text.py index 5aa4d8e..a2db29e 100644 --- a/tests/test_koko_text.py +++ b/tests/test_koko_text.py @@ -7,6 +7,7 @@ ("index out of bounds: the len is 511 but the index is 550"). """ import importlib +import re import pytest @@ -31,6 +32,28 @@ def test_no_punctuation_runs_reach_koko(raw): assert _words(out) == _words(raw) # nothing is lost +@pytest.mark.parametrize( + ("raw", "expected"), + [("Fim.!", "Fim!"), ("Ok.? Sim.", "Ok?\nSim."), ("Isso!. Não.", "Isso!\nNão."), + ('Ele disse "Pare!". Parei.', 'Ele disse "Pare"!\nParei.'), ("Etc…. Fim.", "Etc…\nFim."), + ("(Ok!). Sim.", "(Ok)!\nSim."), ("Ele disse “fim.”. Ok.", "Ele disse “fim”.\nOk.")], +) +def test_mixed_sign_runs_become_one_sign(raw, expected): + # Runs that mix "." with !?… — or are split by closing quotes/brackets — + # left a punctuation-only piece that crashed koko. + out = prep(raw) + assert out == expected + for line in out.splitlines(): + pieces = re.split(r"[.!?…]", line) + assert pieces[-1] == "" # the sentence ends at its sign + assert all(any(ch.isalnum() for ch in p) for p in pieces[:-1]) + assert re.findall(r"\w+", out) == re.findall(r"\w+", raw) # nothing is lost + + +def test_decimals_and_single_signs_are_kept(): + assert prep("Custa 3.5 mil. Versão 1.2!") == "Custa 3.5 mil.\nVersão 1.2!" + + def test_punctuation_only_text_says_nothing(): assert prep("...") == "" assert prep(" ?! … ") == "" diff --git a/tests/test_speech_fixes.py b/tests/test_speech_fixes.py new file mode 100644 index 0000000..231bb2c --- /dev/null +++ b/tests/test_speech_fixes.py @@ -0,0 +1,308 @@ +"""Speech pipeline fixes: pause/overlap, Stop reaching every process, Piper +discovery and volume, Kokoro's unsupported sliders. + +Real processes stand in for the engines and players (no sound); the fake +programs live in tests/tmp/ (/tmp may be noexec). +""" +import importlib +import subprocess +import sys +import threading +import time +import types +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +import pytest + +from fake_engines import install, make_bin_dir, text_wav, use_fakes + +gi = pytest.importorskip("gi") +from gi.repository import GLib # noqa: E402 (first import on the main thread, not in a worker) + +ts = importlib.import_module("services.tts_service") +vm = importlib.import_module("services.voice_manager") +config = importlib.import_module("config") +TTSState = config.TTSState + + +@pytest.fixture +def fakes(tmp_path, monkeypatch): + use_fakes(monkeypatch, tmp_path) + return tmp_path + + +def _pump(): + ctx = GLib.MainContext.default() + while ctx.iteration(False): + pass + + +def _until(condition, timeout=5.0): + end = time.monotonic() + timeout + while time.monotonic() < end: + _pump() + if condition(): + return True + time.sleep(0.02) + return False + + +def _run_watch(svc, timeout=10.0): + end = time.monotonic() + timeout + while time.monotonic() < end: + _pump() + if svc._check_process() is False: + _pump() + return + time.sleep(0.02) + raise AssertionError("request did not finish") + + +def _proc_state(proc: subprocess.Popen) -> str: + """Kernel state letter of a live process ("T" = stopped).""" + try: + stat = Path(f"/proc/{proc.pid}/stat").read_text() + except OSError: + return "gone" + return stat.rsplit(")", 1)[1].split()[0] + + +def _reading(svc, text="x", backend="espeak-ng"): + reading = ts._Reading(id=text, text=text, processed=text, backend=backend, voice_id="", gen=svc._generation) + svc._reading = reading + return reading + + +# ── 1. simultaneous speak while paused ─────────────────────────────── + + +def test_simultaneous_speak_while_paused_continues_the_paused_reading(fakes, monkeypatch): + monkeypatch.setenv("APLAY_SECONDS", "5") + svc = ts.TTSService() + assert svc.speak("Primeira.", backend="rhvoice", voice_id="x", volume=50) + assert _until(lambda: svc.state is TTSState.SPEAKING) + first = svc._reading + assert svc.pause() + player = first.procs[-1] + assert _until(lambda: _proc_state(player) == "T", 2) + + assert svc.speak("Segunda.", backend="rhvoice", voice_id="x", volume=50, stop_previous=False) + assert svc._overlapped == [first] + assert not svc.is_paused + assert _proc_state(player) not in ("T", "gone") # continued, not frozen for ever + + procs = svc._audio_procs() + started = time.monotonic() + svc.stop() + assert time.monotonic() - started < 1.0 # no 0.5 s wait per frozen process + assert all(p.returncode is not None for p in procs) # all reaped + + +# ── 5. pause reaches players started later ─────────────────────────── + + +def test_pause_while_nothing_is_live_freezes_the_next_player(fakes, monkeypatch): + monkeypatch.setenv("APLAY_SECONDS", "0.3") + svc = ts.TTSService() + reading = _reading(svc, "Texto") + svc._set_state(TTSState.LOADING) + gate = threading.Event() + + def synth(chunk): # the voice is still "loading" until the gate opens + gate.wait(5) + return text_wav(Path(ts._temp_wav()), chunk) + + svc._run_synthesis(reading, synth, lambda path: ["aplay", "-q", path], "fail") + assert svc._audio_procs() == [] + assert svc.pause() # nothing live yet: the reading is paused anyway + assert svc.is_paused + gate.set() + assert _until(lambda: reading.procs and _proc_state(reading.procs[-1]) == "T", 3) + player = reading.procs[-1] + time.sleep(0.5) # longer than the chunk: it must not play while paused + assert player.poll() is None + assert reading.heard(time.monotonic(), svc._paused_since) == 0.0 # paused before any sound + + assert svc.resume() + time.sleep(0.1) + assert reading.heard(time.monotonic(), None) < 0.3 # the pause is not counted as heard + assert player.wait(timeout=3) == 0 + reading.threads[0].join(timeout=3) + assert not reading.threads[0].is_alive() + + +def test_resume_before_the_player_starts_lets_it_play(fakes, monkeypatch): + monkeypatch.setenv("APLAY_SECONDS", "0.05") + svc = ts.TTSService() + reading = _reading(svc, "Texto") + svc._set_state(TTSState.LOADING) + gate = threading.Event() + + def synth(chunk): + gate.wait(5) + return text_wav(Path(ts._temp_wav()), chunk) + + svc._run_synthesis(reading, synth, lambda path: ["aplay", "-q", path], "fail") + assert svc.pause() + assert svc.resume() + gate.set() + reading.threads[0].join(timeout=5) + assert reading.procs and reading.procs[-1].returncode == 0 + + +def test_pause_state_is_cleared_when_the_request_ends(fakes): + svc = ts.TTSService() + _reading(svc, "x") + svc._set_state(TTSState.SPEAKING) + assert svc.pause() + svc._reading = None + assert svc._check_process() is False # nothing left: back to IDLE + assert not svc._paused + _reading(svc, "y") + svc._set_state(TTSState.SPEAKING) + assert svc.pause() # a stale pause never blocks the next one + + +# ── 6. Stop reaches the espeak-ng command ──────────────────────────── + +ESPEAK_HANG = f"""#!{sys.executable} +import sys, time +sys.stdin.buffer.read() +time.sleep(30) +""" + + +def test_stop_kills_the_espeak_ng_command(fakes, monkeypatch): + install(make_bin_dir_on_path(monkeypatch), "espeak-ng", ESPEAK_HANG) + monkeypatch.setattr(ts, "_get_tts_engine", lambda: None) + svc = ts.TTSService() + assert svc.speak("Texto qualquer.", backend="espeak-ng", voice_id="espeak-pt-br", volume=50) + reading = svc._reading + assert _until(lambda: reading.live_procs(), 3) + espeak = reading.live_procs()[0] + started = time.monotonic() + svc.stop() + assert espeak.wait(timeout=2) is not None + reading.threads[0].join(timeout=3) + assert not reading.threads[0].is_alive() + assert time.monotonic() - started < 3 + + +def make_bin_dir_on_path(monkeypatch) -> Path: + import os + + bindir = make_bin_dir() + monkeypatch.setenv("PATH", f"{bindir}:{os.environ['PATH']}") + return bindir + + +# ── 7. a process that ignores SIGTERM is reaped ────────────────────── + + +def test_stop_reaps_a_process_that_ignores_sigterm(fakes): + svc = ts.TTSService() + reading = _reading(svc, "x") + stubborn = subprocess.Popen( + [sys.executable, "-c", + "import signal, sys, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); " + "print('ready', flush=True); time.sleep(30)"], + stdout=subprocess.PIPE, + ) + assert stubborn.stdout.readline().strip() == b"ready" + reading.procs.append(stubborn) + svc.stop() + assert stubborn.returncode is not None # killed and waited: no zombie + stubborn.stdout.close() + + +# ── 3. Piper CLI fallback applies the volume ───────────────────────── + +PIPER = f"""#!{sys.executable} +import sys, wave +args = sys.argv[1:] +out = args[args.index("--output_file") + 1] +sys.stdin.buffer.read() +w = wave.open(out, "wb"); w.setnchannels(1); w.setsampwidth(2); w.setframerate(22050) +w.writeframes(b"\\x01\\x00" * 2205); w.close() +""" + +PLAY = f"""#!{sys.executable} +import os, sys +open(os.environ["PLAYARGS"], "a").write(" ".join(sys.argv[1:]) + "\\n") +""" + + +def test_piper_cli_fallback_applies_the_volume_when_native_fails(fakes, monkeypatch): + bindir = make_bin_dir_on_path(monkeypatch) + piper = install(bindir, "piper-tts", PIPER) + install(bindir, "play", PLAY) + log = fakes / "play.args" + monkeypatch.setenv("PLAYARGS", str(log)) + model = fakes / "voice.onnx" + model.write_bytes(b"x") + + class Broken: # the native module loaded, but fails for this model + def synthesize_piper(self, *args): + raise RuntimeError("unsupported model") + + monkeypatch.setattr(ts, "_get_tts_engine", lambda: Broken()) + monkeypatch.setattr(vm, "_find_piper_binary", lambda: str(piper)) + svc = ts.TTSService() + assert svc.speak("Olá.", backend="piper", voice_id=f"piper:{model}", volume=25) + _run_watch(svc) + assert svc.state is TTSState.IDLE + assert log.exists() and "vol 0.50" in log.read_text() + + +# ── 2. Piper voices are listed with the native engine only ─────────── + + +def _piper_voice(home: Path) -> Path: + d = home / ".local" / "share" / "piper-voices" / "pt" / "pt_BR" / "faber" / "medium" + d.mkdir(parents=True) + onnx = d / "pt_BR-faber-medium.onnx" + onnx.write_bytes(b"x") + Path(str(onnx) + ".json").write_text("{}") + return onnx + + +def test_piper_voices_found_with_the_native_engine_and_no_binary(tmp_path, monkeypatch): + onnx = _piper_voice(tmp_path) + monkeypatch.setattr(vm.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(vm, "_find_piper_binary", lambda: None) + monkeypatch.setitem(sys.modules, "tts_engine", types.ModuleType("tts_engine")) + ids = [v.voice_id for v in vm._discover_piper_voices()] + assert f"piper:{onnx}" in ids + + +def test_no_piper_voices_without_any_piper_engine(tmp_path, monkeypatch): + _piper_voice(tmp_path) + monkeypatch.setattr(vm.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(vm, "_find_piper_binary", lambda: None) + monkeypatch.setitem(sys.modules, "tts_engine", None) # import fails + assert vm._discover_piper_voices() == [] + + +# ── 3. Kokoro: pitch and volume sliders are insensitive ────────────── + + +@pytest.mark.parametrize(("backend", "volume", "pitch_ok", "volume_ok"), [ + ("kokoro", 75, False, False), + ("kokoro", 0, False, True), # muted: the slider stays usable to unmute + ("piper", 75, True, True), + ("rhvoice", 75, True, True), +]) +def test_kokoro_disables_the_sliders_it_ignores(backend, volume, pitch_ok, volume_ok): + mv = importlib.import_module("ui.main_view") + view = SimpleNamespace( + _pitch_scale=mock.Mock(), _pitch_row=mock.Mock(), + _volume_scale=mock.Mock(), _volume_row=mock.Mock(), + _settings=SimpleNamespace(speech=SimpleNamespace(volume=volume)), + ) + mv.MainView._update_kokoro_unsupported_sliders(view, backend) + view._pitch_scale.set_sensitive.assert_called_with(pitch_ok) + view._volume_scale.set_sensitive.assert_called_with(volume_ok) + tip = view._pitch_row.set_tooltip_text.call_args.args[0] + assert (tip is None) == (backend != "kokoro") diff --git a/tests/test_startup_fixes.py b/tests/test_startup_fixes.py new file mode 100644 index 0000000..cb73148 --- /dev/null +++ b/tests/test_startup_fixes.py @@ -0,0 +1,165 @@ +"""Startup and desktop-integration regressions (review of 2026-11-02).""" +import subprocess +from types import SimpleNamespace + +import config +from services import clipboard_service, settings_service +from services.desktop_integration_service import DesktopIntegrationService as DIS + +# ── settings ───────────────────────────────────────────────────────── + + +def test_infinity_in_settings_does_not_crash(): + s = config._deserialize_settings({"speech": {"rate": float("inf"), "volume": 1e999}}) + assert s.speech.rate == config.RATE_DEFAULT + + +def test_corrupt_settings_are_kept_and_history_stays_off(): + config.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + config.SETTINGS_FILE.write_text("{not json") + + s = config.load_settings() + + assert s.history.enabled is False + assert config.SETTINGS_FILE.with_suffix(".json.corrupt").read_text() == "{not json" + + +def test_unreadable_settings_do_not_crash(): + config.SETTINGS_FILE.mkdir(parents=True) # a directory: read_text raises OSError + assert isinstance(config.load_settings(), config.AppSettings) + + +def test_restore_defaults_keeps_the_shared_object(): + svc = settings_service.SettingsService() + shared = svc.get() # what TTSService and the History hold + shared.history.enabled = False + shared.speech.rate = 40 + + returned = svc.reset_to_defaults() + + assert returned is shared + assert shared.history.enabled is True + assert shared.speech.rate == config.AppSettings().speech.rate + + +# ── shortcut on other desktops ────────────────────────────────────── + + +def test_unknown_desktop_does_not_claim_a_shortcut(monkeypatch): + monkeypatch.setenv("XDG_CURRENT_DESKTOP", "Hyprland") + monkeypatch.setenv("DESKTOP_SESSION", "hyprland") + called = [] + monkeypatch.setattr(DIS, "register_gnome_shortcut", classmethod(lambda *a: called.append(a) or True)) + + assert DIS.register_shortcut_for_current_de("v") is False + assert called == [] + + +class FakeXfconf: + def __init__(self, bindings): + self.bindings = dict(bindings) + self.calls = [] + + def run(self, args, **kwargs): + self.calls.append(args) + if "-l" in args: + out = "".join(f"{k} {v}\n" for k, v in self.bindings.items()) + return SimpleNamespace(returncode=0, stdout=out) + prop = args[args.index("-p") + 1] + if "-r" in args: + self.bindings.pop(prop, None) + else: + self.bindings[prop] = args[args.index("-s") + 1] + return SimpleNamespace(returncode=0, stdout="") + + +def _xfce(monkeypatch, bindings): + fake = FakeXfconf(bindings) + monkeypatch.setattr(subprocess, "run", fake.run) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/" + name) + return fake + + +def test_xfce_keeps_users_own_binding_to_the_app(monkeypatch): + fake = _xfce(monkeypatch, { + "/commands/custom/t": "/usr/bin/biglinux-tts", + "/commands/custom/s": "/usr/bin/biglinux-tts-speak", + }) + + assert DIS.register_xfce_shortcut("v", "/usr/bin/biglinux-tts-speak") + + assert fake.bindings == { + "/commands/custom/t": "/usr/bin/biglinux-tts", + "/commands/custom/v": "/usr/bin/biglinux-tts-speak", + } + + +def test_xfce_sets_new_binding_before_removing_old(monkeypatch): + fake = _xfce(monkeypatch, {"/commands/custom/s": "/usr/bin/biglinux-tts-speak"}) + + DIS.register_xfce_shortcut("v", "/usr/bin/biglinux-tts-speak") + + writes = [c for c in fake.calls if "-l" not in c] + assert "-n" in writes[0] and "-r" in writes[1] + + +def test_xfce_does_not_overwrite_another_command(monkeypatch): + fake = _xfce(monkeypatch, {"/commands/custom/v": "xfce4-terminal"}) + + assert DIS.register_xfce_shortcut("v", "/usr/bin/biglinux-tts-speak") is False + assert fake.bindings["/commands/custom/v"] == "xfce4-terminal" + + +def test_xfce_already_right_writes_nothing(monkeypatch): + fake = _xfce(monkeypatch, {"/commands/custom/v": "/usr/bin/biglinux-tts-speak"}) + + assert DIS.register_xfce_shortcut("v", "/usr/bin/biglinux-tts-speak") + assert all("-l" in c for c in fake.calls) + + +# ── selection capture on X11 ──────────────────────────────────────── + + +def test_x11_falls_back_to_the_clipboard(monkeypatch): + seen = [] + + def capture(args, max_chars): + seen.append(args) + return clipboard_service.ClipboardResult("", False, "empty") + + monkeypatch.setattr(clipboard_service, "_run_capture", capture) + monkeypatch.setattr(clipboard_service.shutil, "which", lambda n: n == "xsel") + + clipboard_service._get_text_x11(100) + + assert ["xsel", "--clipboard", "-o"] in seen + + +# ── tray helper that gives up ─────────────────────────────────────── + + +def test_tray_helper_error_is_read_and_reported_on_exit(caplog): + import os + + from gi.repository import GLib + + from services.tray_service import TrayIcon + + tray = TrayIcon(title="t", tooltip="t") + exits = [] + tray.on_exit = lambda: exits.append(1) or False + r, w = os.pipe() + os.write(w, b'{"event": "error", "message": "No system tray"}\n') + os.close(w) # the helper exited: IN and HUP arrive together + channel = GLib.IOChannel.unix_new(r) + channel.set_encoding(None) + with caplog.at_level("ERROR"): + keep = tray._on_child_output(channel, GLib.IOCondition.IN | GLib.IOCondition.HUP) + os.close(r) + ctx = GLib.MainContext.default() + while ctx.iteration(False): + pass + + assert keep is False + assert "No system tray" in caplog.text + assert exits == [1] diff --git a/usr/share/biglinux/tts-biglinux/application.py b/usr/share/biglinux/tts-biglinux/application.py index 6f11cec..c618c7d 100644 --- a/usr/share/biglinux/tts-biglinux/application.py +++ b/usr/share/biglinux/tts-biglinux/application.py @@ -63,6 +63,9 @@ def __init__(self) -> None: application_id=APP_ID, flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE, ) + # Linger a little after the last reading: an Alt+V pressed meanwhile + # reuses this instance instead of racing a quitting one. + self.set_inactivity_timeout(10000) # Register --speak option for GApplication command line handling self.add_main_option( @@ -271,6 +274,7 @@ def _setup_tray_icon(self) -> None: ) self._tray.on_activate = self._on_tray_speak self._tray.on_player = self._on_tray_player + self._tray.on_exit = self._on_tray_lost self._tray.set_menu(self._build_tray_menu()) self._tray.register() # Keep app alive when all windows are closed @@ -293,6 +297,15 @@ def _on_tray_revert(self) -> bool: self._refresh_tray_playback() return False + def _speak_next_queued(self) -> bool: + """Start the next queued reading; skip ones that turn out empty.""" + while self._speech_queue: + if self.tts_service.speak(**self._speech_queue.pop(0)): + return False + # Nothing started, so no IDLE will come to release the hold. + self._release_speak_hold() + return False + def _release_speak_hold(self) -> None: if self._speak_hold: self._speak_hold = False @@ -383,8 +396,7 @@ def _on_tts_state_changed(self, state: "TTSState") -> None: self._speech_queue.clear() # Process speech queue (queue mode) if self._speech_queue: - params = self._speech_queue.pop(0) - GLib.idle_add(lambda p=params: self.tts_service.speak(**p) and False) + GLib.idle_add(self._speak_next_queued) return self._release_speak_hold() if not self._notif_id: @@ -698,6 +710,22 @@ def enable_tray(self) -> None: return self._setup_tray_icon() + def _on_tray_lost(self) -> bool: + """The tray helper ended on its own (no tray host, crash). + + Without an icon a window hidden "to the tray" could never be reached + again: drop the tray, so closing the window quits as usual. + """ + if self._tray is None: + return False + self._tray.unregister() + self._tray = None + self.release() + if self._window is not None and not self._window.get_visible(): + self._window.destroy() + self._window = None + return False + def disable_tray(self) -> None: """Disable the system tray icon (called from settings toggle).""" if self._tray is None: diff --git a/usr/share/biglinux/tts-biglinux/config.py b/usr/share/biglinux/tts-biglinux/config.py index 696cf5f..530a636 100644 --- a/usr/share/biglinux/tts-biglinux/config.py +++ b/usr/share/biglinux/tts-biglinux/config.py @@ -7,6 +7,7 @@ import json import logging +import math import os from dataclasses import asdict, dataclass, field from enum import Enum @@ -187,8 +188,16 @@ def load_settings() -> AppSettings: settings = _deserialize_settings(data) logger.debug("Settings loaded from %s", SETTINGS_FILE) return settings - except (json.JSONDecodeError, KeyError, TypeError, ValueError, AttributeError): - logger.warning("Corrupt settings file, using defaults") + except (OSError, OverflowError, json.JSONDecodeError, KeyError, TypeError, ValueError, AttributeError) as e: + # Keep the broken file for the user (the next save replaces it), + # and an existing user's History is never switched on by itself. + logger.warning("Corrupt settings file (%s), using defaults", e) + try: + os.replace(SETTINGS_FILE, SETTINGS_FILE.with_suffix(".json.corrupt")) + except OSError: + pass + settings.history.enabled = False + return settings # Try migrating legacy settings if LEGACY_CONFIG_DIR.exists(): @@ -212,15 +221,16 @@ def _safe_int(d: dict, key: str, default: int) -> int: """Coerce d[key] to int, falling back to default on any bad value.""" try: return int(d.get(key, default)) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): # OverflowError: Infinity return default def _safe_float(d: dict, key: str, default: float) -> float: try: - return float(d.get(key, default)) + value = float(d.get(key, default)) except (ValueError, TypeError): return default + return value if math.isfinite(value) else default def _safe_bool(d: dict, key: str, default: bool) -> bool: diff --git a/usr/share/biglinux/tts-biglinux/main.py b/usr/share/biglinux/tts-biglinux/main.py index 3fc2c46..64655f6 100644 --- a/usr/share/biglinux/tts-biglinux/main.py +++ b/usr/share/biglinux/tts-biglinux/main.py @@ -52,6 +52,15 @@ def main() -> None: gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") + from gi.repository import Gtk + + # Plasma writes gtk-application-prefer-dark-theme=true to GTK's + # settings.ini; libadwaita warns about it at every start. The dark style + # still follows the desktop (AdwStyleManager reads the portal). + if Gtk.init_check(): + gtk_settings = Gtk.Settings.get_default() + if gtk_settings is not None and gtk_settings.props.gtk_application_prefer_dark_theme: + gtk_settings.props.gtk_application_prefer_dark_theme = False from application import TTSApplication diff --git a/usr/share/biglinux/tts-biglinux/services/clipboard_service.py b/usr/share/biglinux/tts-biglinux/services/clipboard_service.py index aa534e6..20c8af2 100644 --- a/usr/share/biglinux/tts-biglinux/services/clipboard_service.py +++ b/usr/share/biglinux/tts-biglinux/services/clipboard_service.py @@ -72,7 +72,7 @@ def _get_text_x11(max_chars: int) -> ClipboardResult: """Capture text on X11 via xsel or xclip.""" # Try xsel first if shutil.which("xsel"): - for args in [["xsel", "--primary", "-o"], ["xsel", "-o"]]: + for args in [["xsel", "--primary", "-o"], ["xsel", "--clipboard", "-o"]]: result = _run_capture(args, max_chars) if result.success and result.text: return result diff --git a/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py b/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py index 57cb60d..81770b5 100644 --- a/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py +++ b/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py @@ -251,9 +251,16 @@ def purge_stale_kde_components(keep: str = "biglinux_tts_speak_desktop") -> None the press to our launcher, so the shortcut silently fails when the app is not already running. ``Component.cleanUp`` drops each zombie so the live ``biglinux-tts-speak.desktop`` component owns the key alone. + + kglobalacceld also loads every launcher in ~/.local/share/kglobalaccel/ + at each login (System Settings copies one there when a shortcut is added + by hand), so old copies of ours are deleted too — otherwise the zombies + come back with their default Alt+V before the app ever starts. """ import shutil + DesktopIntegrationService.remove_stale_kde_launchers() + # D-Bus object-path names (dots/dashes → underscores) of components # historically used for the speak shortcut. Never touch ``keep``. stale = [ @@ -284,6 +291,40 @@ def purge_stale_kde_components(keep: str = "biglinux_tts_speak_desktop") -> None pass logger.debug("Purged stale KDE shortcut components (kept %s)", keep) + # Launchers older versions (or System Settings) left in the kglobalaccel + # data dir; each one is a separate component claiming the speak shortcut. + _STALE_KDE_LAUNCHERS = ("bigtts.desktop", "tts-speak.desktop", "br.com.biglinux.tts.desktop") + + @staticmethod + def _kde_launcher_dir() -> Path: + return Path.home() / ".local" / "share" / "kglobalaccel" + + @classmethod + def remove_stale_kde_launchers(cls) -> list[Path]: + """Delete our old launchers from ~/.local/share/kglobalaccel/. + + Only files whose Exec runs this app are removed, so a user's own + launcher that happens to share a name is left alone. + """ + removed = [] + for name in cls._STALE_KDE_LAUNCHERS: + path = cls._kde_launcher_dir() / name + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + execs = [line for line in text.splitlines() if line.startswith("Exec=")] + if not any("biglinux-tts" in line for line in execs): + continue + try: + path.unlink() + except OSError as e: + logger.warning("Could not remove stale shortcut launcher %s: %s", path, e) + continue + removed.append(path) + logger.info("Removed stale shortcut launcher %s", path) + return removed + @staticmethod def ensure_desktop_file(kde_key: str) -> Path: """Ensure the shortcut desktop file exists locally with current keybinding.""" @@ -308,6 +349,14 @@ def ensure_desktop_file(kde_key: str) -> Path: """ desktop_dst.parent.mkdir(parents=True, exist_ok=True) desktop_dst.write_text(content, encoding="utf-8") + # kglobalacceld looks in its own data dir before applications/, so a + # copy there (made by System Settings) would keep an old Exec/default. + kde_copy = DesktopIntegrationService._kde_launcher_dir() / desktop_dst.name + if kde_copy.is_file(): + try: + kde_copy.write_text(content, encoding="utf-8") + except OSError as e: + logger.warning("Could not update %s: %s", kde_copy, e) return desktop_dst @classmethod @@ -526,26 +575,35 @@ def register_xfce_shortcut(cls, accel: str, exec_path: str) -> bool: channel = "xfce4-keyboard-shortcuts" prop_path = f"/commands/custom/{xfce_accel}" - # First, remove any previous biglinux-tts binding + def is_ours(command: str) -> bool: + # The speak launcher only — not a user's own binding that opens + # the app window (/usr/bin/biglinux-tts). + return command.strip().split("/")[-1] == "biglinux-tts-speak" + try: result = subprocess.run( ["xfconf-query", "-c", channel, "-l", "-v"], capture_output=True, text=True, timeout=5, ) - if result.returncode == 0: - for line in result.stdout.splitlines(): - if "biglinux-tts" in line: - parts = line.split(None, 1) - if parts: - old_prop = parts[0] - subprocess.run( - ["xfconf-query", "-c", channel, "-p", old_prop, "-r"], - timeout=3, check=False, - ) - except (OSError, subprocess.TimeoutExpired): - pass + except (OSError, subprocess.TimeoutExpired) as e: + logger.error("Failed to read XFCE shortcuts: %s", e) + return False + bindings = {} + if result.returncode == 0: + for line in result.stdout.splitlines(): + parts = line.split(None, 1) + if len(parts) == 2 and parts[0].startswith("/commands/custom/"): + bindings[parts[0]] = parts[1].strip() + + current = bindings.get(prop_path) + if current == exec_path: + return True # already right: no churn at every start + if current is not None and not is_ours(current): + logger.warning("XFCE shortcut %s is already used by %s", xfce_accel, current) + return False - # Register the new binding + # Set the new binding first, then drop our old ones: an app that quits + # in between never leaves the user without a shortcut. try: subprocess.run( [ @@ -555,11 +613,20 @@ def register_xfce_shortcut(cls, accel: str, exec_path: str) -> bool: ], timeout=5, check=True, ) - logger.info("XFCE shortcut registered: %s → %s", xfce_accel, exec_path) - return True except (OSError, subprocess.TimeoutExpired, subprocess.CalledProcessError) as e: logger.error("Failed to register XFCE shortcut: %s", e) return False + for prop, command in bindings.items(): + if prop != prop_path and is_ours(command): + try: + subprocess.run( + ["xfconf-query", "-c", channel, "-p", prop, "-r"], + timeout=3, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + pass + logger.info("XFCE shortcut registered: %s → %s", xfce_accel, exec_path) + return True @classmethod def register_cinnamon_shortcut(cls, accel: str, exec_path: str) -> bool: @@ -676,7 +743,8 @@ def register_shortcut_for_current_de(cls, accel: str) -> bool: return cls.register_xfce_shortcut(accel, exec_path) elif de == "cinnamon": return cls.register_cinnamon_shortcut(accel, exec_path) - else: - # Unknown DE — try GNOME gsettings as fallback (many DEs are GNOME-based) - logger.info("Unknown DE '%s', trying GNOME gsettings as fallback", de) - return cls.register_gnome_shortcut(accel, exec_path) + # Unknown desktop: writing GNOME's gsettings could "succeed" with no + # daemon listening, and the app would promise a shortcut that never + # fires. The user adds it in the desktop's own keyboard settings. + logger.info("Unknown desktop '%s': shortcut must be added by hand", de) + return False diff --git a/usr/share/biglinux/tts-biglinux/services/history_db.py b/usr/share/biglinux/tts-biglinux/services/history_db.py index ea4f432..7b29025 100644 --- a/usr/share/biglinux/tts-biglinux/services/history_db.py +++ b/usr/share/biglinux/tts-biglinux/services/history_db.py @@ -362,34 +362,44 @@ def migrate_from_json(json_path: Path) -> int: except (json.JSONDecodeError, OSError): return 0 - known = known_keys() migrated = 0 with _connect() as conn: - for e in data: - if not isinstance(e, dict): - continue - ts = str(e.get("timestamp", "")) - backend = str(e.get("backend", "")) - if not ts or (ts, backend) in known: - continue - preview = str(e.get("text_preview", "")) - conn.execute( - "INSERT INTO entries " - "(id, ts, backend, voice_id, text, text_preview, has_audio, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - ( - str(e.get("id") or uuid.uuid4().hex), - ts, - backend, - str(e.get("voice_id", "")), - str(e.get("text", "")) or preview, - preview, - 1 if e.get("has_audio") else 0, - ts_to_epoch(ts), - ), - ) - known.add((ts, backend)) - migrated += 1 + # Two starts may migrate at the same moment: the check for rows + # already indexed and the inserts must be one write transaction, + # or both see "not indexed yet" and both insert. + conn.isolation_level = None + conn.execute("BEGIN IMMEDIATE") + try: + known = {(r[0], r[1] or "") for r in conn.execute("SELECT ts, backend FROM entries")} + for e in data: + if not isinstance(e, dict): + continue + ts = str(e.get("timestamp", "")) + backend = str(e.get("backend", "")) + if not ts or (ts, backend) in known: + continue + preview = str(e.get("text_preview", "")) + conn.execute( + "INSERT INTO entries " + "(id, ts, backend, voice_id, text, text_preview, has_audio, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + str(e.get("id") or uuid.uuid4().hex), + ts, + backend, + str(e.get("voice_id", "")), + str(e.get("text", "")) or preview, + preview, + 1 if e.get("has_audio") else 0, + ts_to_epoch(ts), + ), + ) + known.add((ts, backend)) + migrated += 1 + conn.execute("COMMIT") + except BaseException: + conn.execute("ROLLBACK") + raise try: json_path.rename(json_path.with_suffix(".json.migrated")) except OSError: diff --git a/usr/share/biglinux/tts-biglinux/services/kokoro_voice_service.py b/usr/share/biglinux/tts-biglinux/services/kokoro_voice_service.py index c8515a4..e52fc11 100644 --- a/usr/share/biglinux/tts-biglinux/services/kokoro_voice_service.py +++ b/usr/share/biglinux/tts-biglinux/services/kokoro_voice_service.py @@ -304,10 +304,24 @@ def build_koko_command( _MAX_VOICE_DOWNLOAD = 16 * 1024 * 1024 _RE_DOTS = re.compile(r"\.{2,}") -_RE_PUNCT_RUN = re.compile(r"[!?…]{2,}") +# Two or more sentence signs in a row ("?!", "Fim.!", "etc….") — also when +# closing quotes or brackets sit between them ('"Pare!".', "(Ok!)."). +_CLOSERS = "\"'”’»)]}" +_RE_PUNCT_RUN = re.compile(r"[.!?…](?:[\"'”’»)\]}]*[.!?…])+") _RE_SENTENCE_END = re.compile(r"(?<=[.!?…;:])\s+") +def _one_sign(match: re.Match) -> str: + """A run of sentence signs → its closers, then one sign (koko ends the + sentence there, with nothing left after it).""" + run = match.group() + closers = "".join(ch for ch in run if ch in _CLOSERS) + for sign in "?!…": + if sign in run: + return closers + sign + return closers + "." + + def _split_long(sentence: str, limit: int) -> list[str]: """Split one sentence into pieces of at most ``limit`` characters. @@ -351,18 +365,18 @@ def prepare_koko_text(text: str, limit: int = KOKO_MAX_SEGMENT_CHARS) -> str: """Text ``koko pipe`` can read to the end: one sentence per line. - ASCII ellipses ("..." / "....") become "…", and runs of sentence - punctuation ("?!", "!!!", "?…") become one sign: koko cuts a sentence at - each sign and exits with ``SendError`` on the punctuation-only piece - left in between ("Wait... this", "Really?! No"). + punctuation ("?!", "!!!", "?…", "Fim.!", "etc….") become one sign, + also when closing quotes or brackets split the run ('"Pare!".' → + '"Pare"!'): koko cuts a sentence at each sign and exits with + ``SendError`` on the punctuation-only piece left in between + ("Wait... this", "Really?! No"). - Sentences longer than ``limit`` are split into shorter ones (see KOKO_MAX_SEGMENT_CHARS); each piece ends with punctuation so koko treats it as its own segment. - Segments without any letter or digit (only punctuation) are dropped. """ text = _RE_DOTS.sub("…", text) - text = _RE_PUNCT_RUN.sub( - lambda m: "?" if "?" in m.group() else ("!" if "!" in m.group() else "…"), text - ) + text = _RE_PUNCT_RUN.sub(_one_sign, text) lines: list[str] = [] for paragraph in text.splitlines(): for sentence in _RE_SENTENCE_END.split(paragraph.strip()): diff --git a/usr/share/biglinux/tts-biglinux/services/settings_service.py b/usr/share/biglinux/tts-biglinux/services/settings_service.py index 46c0cfb..9cf65c9 100644 --- a/usr/share/biglinux/tts-biglinux/services/settings_service.py +++ b/usr/share/biglinux/tts-biglinux/services/settings_service.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging +from dataclasses import fields from config import AppSettings, load_settings, save_settings from utils.async_utils import Debouncer @@ -44,8 +45,15 @@ def save_now(self) -> None: self._do_save() def reset_to_defaults(self) -> AppSettings: - """Reset all settings to defaults.""" - self._settings = AppSettings() + """Reset all settings to defaults. + + In place: the TTS service, the History and the app hold this same + object, and must see the defaults too (e.g. History switched off). + """ + defaults = AppSettings() + current = self.get() + for f in fields(AppSettings): + setattr(current, f.name, getattr(defaults, f.name)) self.save_now() logger.info("Settings reset to defaults") return self._settings diff --git a/usr/share/biglinux/tts-biglinux/services/shortcut_service.py b/usr/share/biglinux/tts-biglinux/services/shortcut_service.py index 8b43d6d..a289e13 100644 --- a/usr/share/biglinux/tts-biglinux/services/shortcut_service.py +++ b/usr/share/biglinux/tts-biglinux/services/shortcut_service.py @@ -113,11 +113,11 @@ def validate(accel: str) -> str: application (typing "v" would read text), so one of them is required — except for function keys, which apps rarely use for text input. """ - from gi.repository import Gdk - - key, mods = _parse(accel) + key, mods = _parse(accel) # first: it pins the Gdk version if not key: return _("This key combination is not valid.") + from gi.repository import Gdk + name = Gdk.keyval_name(key) or "" is_function_key = name.startswith("F") and name[1:].isdigit() strong = Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.ALT_MASK | Gdk.ModifierType.SUPER_MASK @@ -128,11 +128,11 @@ def validate(accel: str) -> str: def to_qt_key(accel: str) -> int: """GTK accelerator → Qt key code (as KGlobalAccel stores keys); 0 if unknown.""" - from gi.repository import Gdk - - key, mods = _parse(accel) + key, mods = _parse(accel) # first: it pins the Gdk version if not key: return 0 + from gi.repository import Gdk + name = Gdk.keyval_name(key) or "" if name in _QT_NAMED_KEYS: code = _QT_NAMED_KEYS[name] diff --git a/usr/share/biglinux/tts-biglinux/services/tray_service.py b/usr/share/biglinux/tts-biglinux/services/tray_service.py index f6b44c3..7c03796 100644 --- a/usr/share/biglinux/tts-biglinux/services/tray_service.py +++ b/usr/share/biglinux/tts-biglinux/services/tray_service.py @@ -86,6 +86,17 @@ def send(data: dict) -> None: app.setDesktopFileName("br.com.biglinux.tts") app.setQuitOnLastWindowClosed(False) + # No tray host (GNOME without AppIndicator, or the panel not up yet at + # login): wait a little for one, then give up so the app knows. + import time + deadline = time.monotonic() + 15 + while not QSystemTrayIcon.isSystemTrayAvailable(): + if time.monotonic() > deadline: + send({"event": "error", "message": "No system tray on this desktop. Tray icon is disabled."}) + sys.exit(2) + app.processEvents() + time.sleep(0.5) + state = {"speaking": False, "paused": False} def is_dark_theme() -> bool: @@ -358,6 +369,8 @@ def __init__( self.on_activate: Callable[[], None] | None = None # on_player(action) where action is "play" or "stop" self.on_player: Callable[[str], None] | None = None + # on_exit() runs when the helper ends on its own (crash, no tray host) + self.on_exit: Callable[[], None] | None = None def set_menu(self, items: list[MenuItem]) -> None: """Set the context menu items.""" @@ -485,11 +498,17 @@ def _on_child_output( self, channel: GLib.IOChannel, condition: GLib.IOCondition ) -> bool: """Handle output from the helper subprocess.""" + # Read first: the helper's last line (its error) comes with the HUP. + self._read_child_lines(channel) if condition & GLib.IOCondition.HUP: logger.warning("Tray helper subprocess ended") self._io_watch_id = 0 + if self.on_exit: + GLib.idle_add(self.on_exit) return False + return True + def _read_child_lines(self, channel: GLib.IOChannel) -> None: try: while True: status, line, _length, _term = channel.read_line() @@ -524,5 +543,3 @@ def _on_child_output( logger.debug("Tray helper: %s", msg.get('message')) except Exception as e: logger.warning("Error reading tray helper: %s", e) - - return True diff --git a/usr/share/biglinux/tts-biglinux/services/tts_service.py b/usr/share/biglinux/tts-biglinux/services/tts_service.py index 0373c57..0df73de 100644 --- a/usr/share/biglinux/tts-biglinux/services/tts_service.py +++ b/usr/share/biglinux/tts-biglinux/services/tts_service.py @@ -261,6 +261,10 @@ def __init__(self, settings=None) -> None: # playback freezes and resumes in place. self._paused: bool = False self._paused_since: float | None = None # monotonic + # Serializes pause()/resume() with workers that start a player while + # paused (_begin_playback): a new player is never left running, nor + # frozen after a resume. + self._pause_lock = threading.Lock() # Why the last request failed: a translated sentence that says how to # fix it, plus optional technical detail (engine stderr). Kept until # the next speak()/stop() so the UI can show it. @@ -331,37 +335,41 @@ def _audio_procs(self) -> list[subprocess.Popen]: return [p for r in self._readings() for p in r.live_procs()] def pause(self) -> bool: - """Freeze playback in place (SIGSTOP). Returns True if it paused.""" - if self._paused or not self.is_speaking: - return False - procs = self._audio_procs() - if not procs: - return False - for p in procs: - try: - p.send_signal(signal.SIGSTOP) - except (ProcessLookupError, OSError): - pass - self._paused = True - self._paused_since = time.monotonic() + """Freeze playback in place (SIGSTOP). Returns True if it paused. + + Works even when no process is live at this instant (the voice is + still loading, or the next chunk is being synthesized): players + started while paused are frozen as soon as they start. + """ + with self._pause_lock: + if self._paused or not self.is_speaking or not self._readings(): + return False + self._paused = True + self._paused_since = time.monotonic() + for p in self._audio_procs(): + try: + p.send_signal(signal.SIGSTOP) + except (ProcessLookupError, OSError): + pass logger.debug("Speech paused") return True def resume(self) -> bool: """Resume playback from where it was paused (SIGCONT).""" - if not self._paused: - return False - for p in self._audio_procs(): - try: - p.send_signal(signal.SIGCONT) - except (ProcessLookupError, OSError): - pass - if self._paused_since is not None: - elapsed = time.monotonic() - self._paused_since - for reading in self._readings(): - reading.paused_for += elapsed - self._paused = False - self._paused_since = None + with self._pause_lock: + if not self._paused: + return False + self._paused = False + for p in self._audio_procs(): + try: + p.send_signal(signal.SIGCONT) + except (ProcessLookupError, OSError): + pass + if self._paused_since is not None: + elapsed = time.monotonic() - self._paused_since + for reading in self._readings(): + reading.paused_for += elapsed + self._paused_since = None logger.debug("Speech resumed") return True @@ -428,6 +436,9 @@ def speak( # No sleep here: speak() may run on the GTK main thread. self.stop() else: + # A paused reading goes on beside the new one: continue it first, + # or its SIGSTOP'd processes would be frozen for ever. + self.resume() self._overlap_current() if voice: @@ -475,7 +486,6 @@ def speak( success = False if success: - self._paused = False self._stopped_by_user = False self._last_spoken_text = processed self._last_text = text @@ -514,14 +524,15 @@ def stop(self) -> None: self._process = None procs = [p for r in readings for p in r.live_procs()] # A SIGSTOP'd process ignores SIGTERM until continued. - if self._paused: - for p in procs: - try: - p.send_signal(signal.SIGCONT) - except (ProcessLookupError, OSError): - pass - self._paused = False - self._paused_since = None + with self._pause_lock: + if self._paused: + for p in procs: + try: + p.send_signal(signal.SIGCONT) + except (ProcessLookupError, OSError): + pass + self._paused = False + self._paused_since = None # stop() may run on the GTK main thread: the reap is bounded and short # (aplay, RHVoice-test, piper and koko die at once). Workers see the new # generation and exit at their next check; they are not joined here. @@ -531,6 +542,10 @@ def stop(self) -> None: proc.wait(timeout=0.5) except subprocess.TimeoutExpired: proc.kill() + try: + proc.wait(timeout=0.2) # reap it: no zombie left behind + except subprocess.TimeoutExpired: + pass except (ProcessLookupError, OSError): pass for reading in readings: @@ -857,14 +872,27 @@ def synth(chunk: str) -> str | None: # and no 128 KiB argument limit. cmd = ["espeak-ng", "-v", voice, "-s", str(wpm), "-p", str(esp_pitch), "-a", str(esp_vol), "-w", path, "--stdin"] + proc = None try: - proc = subprocess.run(cmd, input=chunk.encode("utf-8"), capture_output=True, timeout=120) + proc = subprocess.Popen( + cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, + ) + reading.procs.append(proc) # Stop must reach it + if not self._is_current(reading.gen): + proc.kill() # stopped while it was starting + # No timeout: a paused reading freezes it too (SIGSTOP), and + # Stop now reaches it. + _out, err = proc.communicate(chunk.encode("utf-8")) if proc.returncode == 0 and os.path.getsize(path) > 100: return path - self._stderr_tail.append(proc.stderr.decode(errors="replace").strip()) - except (OSError, subprocess.TimeoutExpired) as e: + if proc.returncode >= 0: # not killed by Stop + self._stderr_tail.append(err.decode(errors="replace").strip()) + except OSError as e: logger.error("espeak-ng failed: %s", e) self._stderr_tail.append(str(e)) + if proc is not None and proc.poll() is None: + proc.kill() + proc.wait() _unlink(path) return None @@ -894,6 +922,9 @@ def _speak_piper( length_scale, noise_scale, noise_w = piper_length_scale(rate), piper_noise_scale(pitch), 0.8 engine = _get_tts_engine() piper_bin: str | None = None + # Files written by the piper command: unlike the native engine, it + # does not apply the volume, so they are played through sox. + from_cli: set[str] = set() def synth(chunk: str) -> str | None: nonlocal piper_bin @@ -922,6 +953,7 @@ def synth(chunk: str) -> str | None: reading.procs.append(proc) _out, err = proc.communicate(chunk.encode("utf-8")) if proc.returncode == 0 and os.path.getsize(path) >= 100: + from_cli.add(path) return path if proc.returncode > 0: self._stderr_tail.append(err.decode(errors="replace").strip()[-300:]) @@ -931,8 +963,10 @@ def synth(chunk: str) -> str | None: return None def play_cmd(path: str) -> list[str]: - if not engine and vol_factor != 1.0 and shutil.which("play"): - return ["play", "-q", path, "vol", f"{vol_factor:.2f}"] + if path in from_cli: + from_cli.discard(path) + if vol_factor != 1.0 and shutil.which("play"): + return ["play", "-q", path, "vol", f"{vol_factor:.2f}"] return ["aplay", "-q", path] self._run_synthesis(reading, synth, play_cmd, _("Piper could not read this text. Try another voice.")) @@ -1038,13 +1072,28 @@ def _begin_playback(self, reading: _Reading, proc: subprocess.Popen) -> bool: if not self._is_current(reading.gen): _terminate(proc) return False + # Paused meanwhile (e.g. while this chunk was synthesized): the new + # player waits frozen like the others. Under the lock, so a resume() + # either comes after this (and continues the registered player) or + # before it (and the player just plays). + with self._pause_lock: + if self._paused: + try: + proc.send_signal(signal.SIGSTOP) + except (ProcessLookupError, OSError): + pass self._on_audio(reading) return True def _on_audio(self, reading: _Reading) -> None: """Sound of ``reading`` is being produced.""" if not reading.audio_started: - reading.audio_started_at = time.monotonic() + now = time.monotonic() + reading.audio_started_at = now + paused_since = self._paused_since if self._paused else None + if paused_since is not None: + # Paused before any sound: only the pause from now on counts. + reading.paused_for -= now - paused_since reading.audio_started = True if reading is self._reading and self._is_current(reading.gen): self._mark_audio_started() @@ -1143,6 +1192,9 @@ def _check_process(self) -> bool: def _finish_request(self) -> None: """Playback ended: back to IDLE unless a failure is being shown.""" self._watch_id = 0 + with self._pause_lock: # nothing is left to pause or resume + self._paused = False + self._paused_since = None if self._state != TTSState.ERROR: self._set_state(TTSState.IDLE) diff --git a/usr/share/biglinux/tts-biglinux/services/voice_manager.py b/usr/share/biglinux/tts-biglinux/services/voice_manager.py index 976ddac..57cf9c8 100644 --- a/usr/share/biglinux/tts-biglinux/services/voice_manager.py +++ b/usr/share/biglinux/tts-biglinux/services/voice_manager.py @@ -559,14 +559,15 @@ def _discover_piper_voices() -> list[VoiceInfo]: BigLinux packages install voices to: /usr/share/piper-voices/{lang}/{lang_REGION}/{speaker}/{quality}/{lang_REGION}-{speaker}-{quality}.onnx - The binary is /usr/bin/piper-tts (package: piper-tts-bin). + They are spoken by the native engine (tts_engine module) or, as a + fallback, by the binary /usr/bin/piper-tts (package: piper-tts-bin). """ voices: list[VoiceInfo] = [] - # Check if piper-tts binary exists - piper_bin = _find_piper_binary() - if not piper_bin: - logger.debug("Piper TTS binary not found") + # The native engine (tts_engine module) speaks Piper voices without the + # piper binary: either one is enough. + if not _engine_installed(TTSBackend.PIPER.value): + logger.debug("Neither the native engine nor the piper binary was found") return voices search_dirs = [ diff --git a/usr/share/biglinux/tts-biglinux/ui/audio_player.py b/usr/share/biglinux/tts-biglinux/ui/audio_player.py index aa6ff18..e5e36d0 100644 --- a/usr/share/biglinux/tts-biglinux/ui/audio_player.py +++ b/usr/share/biglinux/tts-biglinux/ui/audio_player.py @@ -42,6 +42,17 @@ class AudioPlayerWidget(Gtk.Box): @classmethod def set_playback_mode(cls, mode: str) -> None: cls._playback_mode = mode + if mode != "queue": + cls._queue.clear() # nothing may start by itself in other modes + + @classmethod + def _play_next_queued(cls) -> None: + """Queue mode: start the next waiting player (skips released ones).""" + while cls._queue: + nxt = cls._queue.pop(0) + if nxt._pipeline is not None: + nxt._on_play_pause(None) + return def __init__(self, audio_path: str) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) @@ -57,6 +68,8 @@ def __init__(self, audio_path: str) -> None: # eagerly for every history entry created thousands of playbin pipelines # up front and froze the History view with large histories. self._pipeline: Gst.Element | None = None + self._bus: Gst.Bus | None = None + self._bus_handlers: list[int] = [] self._build_ui() @@ -70,9 +83,14 @@ def _ensure_pipeline(self) -> bool: pipeline.set_property("uri", Path(self._audio_path).as_uri()) bus = pipeline.get_bus() bus.add_signal_watch() - bus.connect("message::eos", self._on_eos) - bus.connect("message::error", self._on_error) - bus.connect("message::state-changed", self._on_state_changed) + # The watch keeps the bus (and, through these handlers, this widget) + # alive until cleanup() removes it. + self._bus = bus + self._bus_handlers = [ + bus.connect("message::eos", self._on_eos), + bus.connect("message::error", self._on_error), + bus.connect("message::state-changed", self._on_state_changed), + ] self._pipeline = pipeline return True @@ -86,7 +104,7 @@ def _build_ui(self) -> None: self._play_btn = Gtk.Button(icon_name="media-playback-start-symbolic") self._play_btn.add_css_class("flat") self._play_btn.add_css_class("circular") - self._play_btn.set_tooltip_text(_("Play")) + self._set_play_label(_("Play")) self._play_btn.connect("clicked", self._on_play_pause) seek_box.append(self._play_btn) @@ -95,6 +113,7 @@ def _build_ui(self) -> None: self._stop_btn.add_css_class("flat") self._stop_btn.add_css_class("circular") self._stop_btn.set_tooltip_text(_("Stop")) + self._stop_btn.update_property([Gtk.AccessibleProperty.LABEL], [_("Stop")]) self._stop_btn.set_sensitive(False) self._stop_btn.connect("clicked", self._on_stop) seek_box.append(self._stop_btn) @@ -112,6 +131,7 @@ def _build_ui(self) -> None: self._seek_scale.set_draw_value(False) self._seek_scale.set_hexpand(True) self._seek_scale.add_css_class("player-seek") + self._seek_scale.update_property([Gtk.AccessibleProperty.LABEL], [_("Playback position")]) self._seek_scale.connect( "change-value", self._on_seek_change ) @@ -125,6 +145,11 @@ def _build_ui(self) -> None: self.append(seek_box) + def _set_play_label(self, text: str) -> None: + """Tooltip and accessible name of the play/pause button.""" + self._play_btn.set_tooltip_text(text) + self._play_btn.update_property([Gtk.AccessibleProperty.LABEL], [text]) + def set_duration_hint(self, seconds: float) -> None: """Show the known duration before the first play (from the history).""" if seconds > 0 and self._duration_ns <= 0: @@ -141,7 +166,7 @@ def _on_play_pause(self, _btn: Gtk.Button) -> None: self._pipeline.set_state(Gst.State.PAUSED) self._playing = False self._play_btn.set_icon_name("media-playback-start-symbolic") - self._play_btn.set_tooltip_text(_("Play")) + self._set_play_label(_("Play")) return mode = AudioPlayerWidget._playback_mode @@ -159,12 +184,16 @@ def _on_play_pause(self, _btn: Gtk.Button) -> None: AudioPlayerWidget._queue.append(self) return AudioPlayerWidget._active_player = self + # Started (by a click or by the queue): it must not play again + # when the queue reaches its old place. + if self in AudioPlayerWidget._queue: + AudioPlayerWidget._queue.remove(self) # simultaneous: just play, no stop self._pipeline.set_state(Gst.State.PLAYING) self._playing = True self._play_btn.set_icon_name("media-playback-pause-symbolic") - self._play_btn.set_tooltip_text(_("Pause")) + self._set_play_label(_("Pause")) self._stop_btn.set_sensitive(True) self._start_poll() @@ -178,12 +207,17 @@ def _force_stop(self) -> None: self._pipeline.set_state(Gst.State.NULL) self._playing = False self._play_btn.set_icon_name("media-playback-start-symbolic") - self._play_btn.set_tooltip_text(_("Play")) + self._set_play_label(_("Play")) self._stop_btn.set_sensitive(False) self._seek_scale.set_value(0) self._elapsed_label.set_label("00:00") + if self in AudioPlayerWidget._queue: + AudioPlayerWidget._queue.remove(self) if AudioPlayerWidget._active_player is self: AudioPlayerWidget._active_player = None + # Queue mode: the end, an error or Stop all hand over to the next. + if AudioPlayerWidget._playback_mode == "queue": + AudioPlayerWidget._play_next_queued() def _on_seek_change( self, @@ -207,18 +241,18 @@ def _on_seek_change( def _on_eos(self, _bus: Gst.Bus, _msg: Gst.Message) -> None: GLib.idle_add(self._on_eos_idle) - def _on_eos_idle(self) -> None: - self._force_stop() - # Queue mode: play next queued item - if AudioPlayerWidget._queue: - nxt = AudioPlayerWidget._queue.pop(0) - if nxt._pipeline: - nxt._on_play_pause(None) + def _on_eos_idle(self) -> bool: + self._force_stop() # queue mode: also starts the next one + return False def _on_error(self, _bus: Gst.Bus, msg: Gst.Message) -> None: err, debug = msg.parse_error() logger.error("GStreamer error: %s (%s)", err.message, debug) - GLib.idle_add(self._force_stop) + GLib.idle_add(self._on_error_idle) + + def _on_error_idle(self) -> bool: + self._force_stop() + return False def _on_state_changed( self, _bus: Gst.Bus, msg: Gst.Message @@ -271,6 +305,12 @@ def _poll_position(self) -> bool: def cleanup(self) -> None: """Release GStreamer resources.""" self._stop_poll() + if self._bus is not None: + for handler in self._bus_handlers: + self._bus.disconnect(handler) + self._bus.remove_signal_watch() + self._bus = None + self._bus_handlers = [] if self._pipeline: self._pipeline.set_state(Gst.State.NULL) self._pipeline = None diff --git a/usr/share/biglinux/tts-biglinux/ui/history_view.py b/usr/share/biglinux/tts-biglinux/ui/history_view.py index a58ae0e..e1a5e04 100644 --- a/usr/share/biglinux/tts-biglinux/ui/history_view.py +++ b/usr/share/biglinux/tts-biglinux/ui/history_view.py @@ -36,6 +36,9 @@ PAGE_SIZE = 50 _UNDO_SECONDS = 5 +# FileManager1 may have to be started (D-Bus activation): wait this long +# before opening the folder instead (the default wait is 25 s). +_FILE_MANAGER_TIMEOUT_MS = 10000 def _engine_label(backend: str) -> str: return engine_name(backend) if backend else _("Unknown") @@ -65,25 +68,44 @@ def _spinner() -> Gtk.Widget: return Adw.Spinner() if hasattr(Adw, "Spinner") else Gtk.Spinner(spinning=True) +def _open_folder(target: Path) -> None: + folder = target.parent if target.is_file() else target + try: + subprocess.Popen(["xdg-open", str(folder)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError: + pass + + def _show_in_file_manager(file_path: Path | None) -> None: - """Show the file in the file manager (FileManager1 D-Bus, or xdg-open).""" + """Show the file in the file manager (FileManager1 D-Bus, or xdg-open). + + Asynchronous: the window must not freeze while the file manager starts + (or while D-Bus waits for one that never answers). + """ target = file_path or history_service.get_history_dir() - try: - proxy = Gio.DBusProxy.new_for_bus_sync( - Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, + + def shown(conn: Gio.DBusConnection, result: Gio.AsyncResult) -> None: + try: + conn.call_finish(result) + except GLib.Error as e: + logger.debug("FileManager1.ShowItems failed: %s", e.message) + _open_folder(target) + + def got_bus(_source, result: Gio.AsyncResult) -> None: + try: + conn = Gio.bus_get_finish(result) + except GLib.Error as e: + logger.debug("No session bus: %s", e.message) + _open_folder(target) + return + conn.call( "org.freedesktop.FileManager1", "/org/freedesktop/FileManager1", - "org.freedesktop.FileManager1", None, + "org.freedesktop.FileManager1", "ShowItems", + GLib.Variant("(ass)", ([target.as_uri()], "")), None, + Gio.DBusCallFlags.NONE, _FILE_MANAGER_TIMEOUT_MS, None, shown, ) - proxy.call_sync( - "ShowItems", GLib.Variant("(ass)", ([target.as_uri()], "")), - Gio.DBusCallFlags.NONE, -1, None, - ) - except Exception: - folder = target.parent if target.is_file() else target - try: - subprocess.Popen(["xdg-open", str(folder)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except FileNotFoundError: - pass + + Gio.bus_get(Gio.BusType.SESSION, None, got_bus) # ── One entry ───────────────────────────────────────────────────────── @@ -262,8 +284,13 @@ def done(d: Gtk.FileDialog, result) -> None: file = d.save_finish(result) except GLib.Error: return # cancelled - ok = history_service.export_audio(self.entry, file.get_path()) - self._view.toast(_("Audio saved") if ok else _("Could not save the audio")) + entry, destination, view = self.entry, file.get_path(), self._view + + def work() -> None: # a long reading is a big file: not on the main thread + ok = history_service.export_audio(entry, destination) + GLib.idle_add(view.toast, _("Audio saved") if ok else _("Could not save the audio")) + + threading.Thread(target=work, daemon=True).start() dialog.save(self.get_root(), None, done) @@ -355,7 +382,7 @@ def _build_ui(self) -> None: self._engine = Gtk.DropDown.new(self._engine_model, None) self._engine.set_tooltip_text(_("Filter by engine")) self._engine.update_property([Gtk.AccessibleProperty.LABEL], [_("Filter by engine")]) - self._engine.connect("notify::selected", lambda *_a: self.reload()) + self._engine_handler = self._engine.connect("notify::selected", lambda *_a: self.reload()) bar.append(self._engine) tools = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2) @@ -548,15 +575,24 @@ def _on_page(self, serial: int, rows: list[dict], total: int | None, backends, e self._more_spinner.set_visible(False) if backends is not None: self._set_backends(backends) + if serial != self._query_serial: + return False # the engine filter changed: a new first page is loading if total is not None: self._total = total for entry in rows: - if entry["id"] not in self._pending_delete: + pending = self._pending_delete.get(entry["id"]) + if pending is None: self._add_card(entry) + elif pending not in self._cards: + # Deleted, Undo still possible, and the list was reloaded + # meanwhile: keep it here, hidden, so Undo can bring it back. + card = self._add_card(entry) + card.get_parent().set_visible(False) + self._pending_delete[entry["id"]] = card self._offset += len(rows) if error: self._show_empty(error) - elif not self._cards: + elif not self._visible_cards(): self._show_empty() else: self._stack.set_visible_child_name("content") @@ -564,15 +600,31 @@ def _on_page(self, serial: int, rows: list[dict], total: int | None, backends, e return False def _set_backends(self, backends: list[str]) -> None: + """New engine list for the filter, keeping the chosen engine. + + Changing the model moves the selection: notify::selected must not + reload the page in the middle of _on_page (every card came twice). + Only when the chosen engine is gone does the filter change — then + the page is reloaded once, on purpose. + """ if backends == self._backends: return current = self._filters().get("backend") self._backends = backends - self._engine_model.splice(1, self._engine_model.get_n_items() - 1, [_engine_label(b) for b in backends]) - if current in backends: - self._engine.set_selected(backends.index(current) + 1) + self._engine.handler_block(self._engine_handler) + try: + self._engine_model.splice(1, self._engine_model.get_n_items() - 1, + [_engine_label(b) for b in backends]) + self._engine.set_selected(backends.index(current) + 1 if current in backends else 0) + finally: + self._engine.handler_unblock(self._engine_handler) + if current and current not in backends: + self.reload() - def _add_card(self, entry: dict) -> None: + def _visible_cards(self) -> list[HistoryCard]: + return [c for c in self._cards if self._pending_delete.get(c.entry["id"]) is not c] + + def _add_card(self, entry: dict) -> HistoryCard: card = HistoryCard(entry, self) self._cards.append(card) if self._grid_mode: @@ -582,6 +634,7 @@ def _add_card(self, entry: dict) -> None: row.set_activatable(False) row.set_child(card) self._list.append(row) + return card def _clear(self) -> None: for card in self._cards: @@ -708,18 +761,23 @@ def _remove_card(self, card: HistoryCard) -> None: card.cleanup() if card in self._cards: self._cards.remove(card) - parent = card.get_parent() - if isinstance(parent, Gtk.ListBoxRow): - self._list.remove(parent) - elif isinstance(parent, Gtk.FlowBoxChild): - self._grid.remove(parent) + # A reload already took the card out of the list: removing its old + # row again would be a Gtk-WARNING (not a child). + holder = card.get_parent() + if isinstance(holder, Gtk.ListBoxRow) and holder.get_parent() is self._list: + self._list.remove(holder) + elif isinstance(holder, Gtk.FlowBoxChild) and holder.get_parent() is self._grid: + self._grid.remove(holder) def delete_with_undo(self, card: HistoryCard) -> None: - """Hide the entry now; delete it when the Undo toast goes away.""" + """Hide the entry now; delete it when the Undo toast goes away. + + The list may be reloaded meanwhile (a new reading, a filter): the + entry's current card is looked up in _pending_delete each time. + """ entry_id = card.entry["id"] self._pending_delete[entry_id] = card - holder = card.get_parent() - holder.set_visible(False) + card.get_parent().set_visible(False) toast = Adw.Toast.new(_("Reading deleted")) toast.set_button_label(_("Undo")) toast.set_timeout(_UNDO_SECONDS) @@ -727,16 +785,22 @@ def delete_with_undo(self, card: HistoryCard) -> None: def undo(_t) -> None: undone["value"] = True - self._pending_delete.pop(entry_id, None) - holder.set_visible(True) + current = self._pending_delete.pop(entry_id, None) + if current is not None and current in self._cards and current.get_parent() is not None: + current.get_parent().set_visible(True) + if self._stack.get_visible_child_name() == "empty": + self._stack.set_visible_child_name("content") + else: + self.reload() # not on the page now: the entry still exists def dismissed(_t) -> None: if undone["value"]: return - self._pending_delete.pop(entry_id, None) - self._remove_card(card) + current = self._pending_delete.pop(entry_id, None) + if current is not None: + self._remove_card(current) threading.Thread(target=history_service.delete_entries, args=([entry_id],), daemon=True).start() - if not self._cards: + if not self._visible_cards(): self._show_empty() toast.connect("button-clicked", undo) diff --git a/usr/share/biglinux/tts-biglinux/ui/main_view.py b/usr/share/biglinux/tts-biglinux/ui/main_view.py index 13db1b0..baa7800 100644 --- a/usr/share/biglinux/tts-biglinux/ui/main_view.py +++ b/usr/share/biglinux/tts-biglinux/ui/main_view.py @@ -507,6 +507,7 @@ def _build_quick_settings(self) -> Adw.PreferencesGroup: title_size_group=title_sg, ) group.add(self._volume_row) + self._update_kokoro_unsupported_sliders(self._settings.speech.backend) # ── Kokoro-specific controls (visible only when Kokoro is active) ── is_kokoro = self._settings.speech.backend == TTSBackend.KOKORO.value @@ -729,7 +730,7 @@ def _build_advanced_section(self) -> Adw.PreferencesGroup: self._sync_history_rows() # ── Playback mode for history player ── - playback_row = Adw.ComboRow() + playback_row = self._playback_row = Adw.ComboRow() playback_row.set_title(_("Audio playback mode")) playback_row.set_subtitle(_("Behavior when playing multiple audios or speech")) set_row_icon(playback_row, "media-playback-start-symbolic") @@ -1282,6 +1283,25 @@ def _update_pitch_row_for_backend(self, backend: str) -> None: if sub_lbl is not None: sub_lbl.set_label(subtitle) sub_lbl.set_visible(bool(subtitle)) + self._update_kokoro_unsupported_sliders(backend) + + def _update_kokoro_unsupported_sliders(self, backend: str) -> None: + """Kokoro (koko pipe) plays the audio itself and has no pitch or volume + option: those sliders would do nothing, so they are made insensitive. + + Volume 0 (mute) is still honored by Kokoro: a muted volume slider stays + usable, so the person can never be locked in silence. + """ + is_kokoro = backend == TTSBackend.KOKORO.value + tip = _("Kokoro voices do not support this setting.") + pitch_scale = getattr(self, "_pitch_scale", None) + if pitch_scale is not None: + pitch_scale.set_sensitive(not is_kokoro) + self._pitch_row.set_tooltip_text(tip if is_kokoro else None) + volume_scale = getattr(self, "_volume_scale", None) + if volume_scale is not None: + volume_scale.set_sensitive(not is_kokoro or self._settings.speech.volume <= 0) + self._volume_row.set_tooltip_text(tip if is_kokoro else None) def _on_pitch_changed(self, value: float) -> None: """Handle pitch change.""" @@ -1909,6 +1929,21 @@ def restore_defaults(self) -> None: if hasattr(app, "disable_tray"): app.disable_tray() + # Also guarded above: the history player mode and the media player. + from ui.audio_player import AudioPlayerWidget + + mode = self._settings.history.playback_mode + AudioPlayerWidget.set_playback_mode(mode) + modes = ["interrupt", "queue", "simultaneous"] + with self._guard_ui(): + self._playback_row.set_selected(modes.index(mode) if mode in modes else 0) + self._media_switch_widget.set_active(self._settings.show_media_player) + if self._settings.show_media_player: + if hasattr(app, "enable_media_player"): + app.enable_media_player() + elif hasattr(app, "disable_media_player"): + app.disable_media_player() + self._on_toast(_("Settings restored to defaults"), 3) def _update_ui_from_settings(self) -> None: @@ -1967,6 +2002,8 @@ def _update_ui_from_settings(self) -> None: self._history_limit_row.set_selected(self._retention_values.index(limit)) self._sync_history_rows() + self._update_pitch_row_for_backend(current_backend) + # Kokoro controls — visible only when Kokoro is selected is_kokoro = current_backend == TTSBackend.KOKORO.value self._kokoro_emotion_combo.set_visible(is_kokoro) From 2ec8aaa0bc0569b2412981e4bb0bc16710fdce6b Mon Sep 17 00:00:00 2001 From: ruscher Date: Tue, 3 Nov 2026 02:19:12 -0300 Subject: [PATCH 03/10] build: pkgrel 5 --- pkgbuild/PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgbuild/PKGBUILD b/pkgbuild/PKGBUILD index a63437e..38579ee 100644 --- a/pkgbuild/PKGBUILD +++ b/pkgbuild/PKGBUILD @@ -7,7 +7,7 @@ pkgname=tts-biglinux # vercmp); the epoch makes 4.1.0 an upgrade over them. Never remove it. epoch=1 pkgver=4.1.0 -pkgrel=4 +pkgrel=5 arch=('x86_64') license=('GPL-3.0-or-later') url="https://github.com/biglinux/tts-biglinux" From 09ced2f59e6f35c39ac107e66247481c5633a7ea Mon Sep 17 00:00:00 2001 From: ruscher Date: Tue, 3 Nov 2026 02:34:11 -0300 Subject: [PATCH 04/10] fix: address review of #18 - Kokoro: the volume slider stays usable (mute works); its tooltip says only mute applies. A sentence ending in '"Pare!"' or '("Pare!".)' no longer gets a period after the quote ('".' was a punctuation-only piece for koko). - Pitch/volume controls are refreshed after an automatic engine switch (no voices, cancelled installation); the auto-switch log names both. - An earlier settings.json.corrupt is never overwritten; a huge float in settings falls back for that key only. - Stale KDE launchers are matched on the Exec program, not on any word. - Xfce: nothing is written when the binding list cannot be read; old bindings of ours are dropped also when the key is already right. - Tray helper waiting for a tray host obeys "quit" and EOF at once. --- locale/bg.po | 4 + locale/ca.po | 4 + locale/cs.po | 4 + locale/da.po | 4 + locale/de.po | 4 + locale/el.po | 4 + locale/en.po | 4 + locale/es.po | 4 + locale/et.po | 4 + locale/fi.po | 4 + locale/fr.po | 4 + locale/he.po | 4 + locale/hr.po | 4 + locale/hu.po | 4 + locale/is.po | 4 + locale/it.po | 4 + locale/ja.po | 4 + locale/ko.po | 4 + locale/nl.po | 4 + locale/no.po | 4 + locale/pl.po | 4 + locale/pt-BR.po | 4 + locale/pt.po | 4 + locale/ro.po | 4 + locale/ru.po | 4 + locale/sk.po | 4 + locale/sv.po | 4 + locale/tr.po | 4 + locale/tts-biglinux.pot | 5 ++ locale/uk.po | 4 + locale/zh.po | 4 + tests/test_kde_launchers.py | 8 ++ tests/test_koko_text.py | 8 ++ tests/test_speech_fixes.py | 22 +++--- tests/test_startup_fixes.py | 73 ++++++++++++++++++- usr/share/biglinux/tts-biglinux/config.py | 9 ++- .../services/desktop_integration_service.py | 60 +++++++++------ .../services/kokoro_voice_service.py | 9 ++- .../tts-biglinux/services/tray_service.py | 16 +++- .../biglinux/tts-biglinux/ui/main_view.py | 54 ++++++++------ 40 files changed, 315 insertions(+), 69 deletions(-) diff --git a/locale/bg.po b/locale/bg.po index 1d83499..c149fab 100644 --- a/locale/bg.po +++ b/locale/bg.po @@ -1310,3 +1310,7 @@ msgstr "Позиция на възпроизвеждане" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Гласовете Kokoro не поддържат тази настройка." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Гласовете Kokoro следват само „Без звук“. Използвайте системната сила на звука, за да промените колко силно говорят." diff --git a/locale/ca.po b/locale/ca.po index 717b302..d0c0721 100644 --- a/locale/ca.po +++ b/locale/ca.po @@ -1694,3 +1694,7 @@ msgstr "Posició de la reproducció" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Les veus Kokoro no admeten aquest ajust." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Les veus Kokoro només segueixen «Silencia». Feu servir el volum del sistema per canviar-ne la intensitat." diff --git a/locale/cs.po b/locale/cs.po index 22647c1..cf84554 100644 --- a/locale/cs.po +++ b/locale/cs.po @@ -1310,3 +1310,7 @@ msgstr "Pozice přehrávání" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Hlasy Kokoro toto nastavení nepodporují." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Hlasy Kokoro respektují jen „Ztlumit“. Hlasitost změníte v systémovém nastavení hlasitosti." diff --git a/locale/da.po b/locale/da.po index 63409cb..02c10e0 100644 --- a/locale/da.po +++ b/locale/da.po @@ -1310,3 +1310,7 @@ msgstr "Afspilningsposition" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Kokoro-stemmer understøtter ikke denne indstilling." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Kokoro-stemmer følger kun »Lydløs«. Brug systemets lydstyrke til at ændre, hvor højt de taler." diff --git a/locale/de.po b/locale/de.po index 310e79c..7b44ca1 100644 --- a/locale/de.po +++ b/locale/de.po @@ -1318,3 +1318,7 @@ msgstr "Wiedergabeposition" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Kokoro-Stimmen unterstützen diese Einstellung nicht." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Kokoro-Stimmen beachten nur „Stumm“. Ändern Sie die Lautstärke über die Systemlautstärke." diff --git a/locale/el.po b/locale/el.po index c9044f8..842cbbf 100644 --- a/locale/el.po +++ b/locale/el.po @@ -1312,3 +1312,7 @@ msgstr "Θέση αναπαραγωγής" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Οι φωνές Kokoro δεν υποστηρίζουν αυτή τη ρύθμιση." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Οι φωνές Kokoro ακολουθούν μόνο τη «Σίγαση». Χρησιμοποιήστε την ένταση του συστήματος για να αλλάξετε πόσο δυνατά μιλούν." diff --git a/locale/en.po b/locale/en.po index e487322..f2e6fc4 100644 --- a/locale/en.po +++ b/locale/en.po @@ -2141,3 +2141,7 @@ msgstr "Playback position" # File: tts-biglinux/usr/share/biglinux/tts-biglinux/ui/main_view.py, line: 1296 msgid "Kokoro voices do not support this setting." msgstr "Kokoro voices do not support this setting." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Kokoro voices only follow Mute. Use the system volume to change how loud they are." diff --git a/locale/es.po b/locale/es.po index edd72ce..fdf481a 100644 --- a/locale/es.po +++ b/locale/es.po @@ -1318,3 +1318,7 @@ msgstr "Posición de reproducción" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Las voces Kokoro no admiten este ajuste." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Las voces Kokoro solo respetan «Silencio». Use el volumen del sistema para cambiar su intensidad." diff --git a/locale/et.po b/locale/et.po index ca17893..2678316 100644 --- a/locale/et.po +++ b/locale/et.po @@ -1310,3 +1310,7 @@ msgstr "Esitamise asukoht" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Kokoro hääled seda seadet ei toeta." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Kokoro hääled järgivad ainult valikut „Vaigista“. Valjuse muutmiseks kasutage süsteemi helitugevust." diff --git a/locale/fi.po b/locale/fi.po index 1161eac..4e64e60 100644 --- a/locale/fi.po +++ b/locale/fi.po @@ -1310,3 +1310,7 @@ msgstr "Toistokohta" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Kokoro-äänet eivät tue tätä asetusta." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Kokoro-äänet noudattavat vain Mykistä-asetusta. Muuta äänenvoimakkuutta järjestelmän äänenvoimakkuudesta." diff --git a/locale/fr.po b/locale/fr.po index 1408dbc..4c33248 100644 --- a/locale/fr.po +++ b/locale/fr.po @@ -1318,3 +1318,7 @@ msgstr "Position de lecture" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Les voix Kokoro ne prennent pas en charge ce réglage." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Les voix Kokoro ne suivent que « Muet ». Utilisez le volume du système pour régler leur intensité." diff --git a/locale/he.po b/locale/he.po index 845717f..41d3f9b 100644 --- a/locale/he.po +++ b/locale/he.po @@ -1310,3 +1310,7 @@ msgstr "מיקום ההשמעה" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "קולות Kokoro אינם תומכים בהגדרה זו." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "קולות Kokoro מצייתים רק ל„השתק”. השתמשו בעוצמת השמע של המערכת כדי לשנות את עוצמתם." diff --git a/locale/hr.po b/locale/hr.po index 0dde770..8cb07d3 100644 --- a/locale/hr.po +++ b/locale/hr.po @@ -1312,3 +1312,7 @@ msgstr "Položaj reprodukcije" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Kokoro glasovi ne podržavaju ovu postavku." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Kokoro glasovi slijede samo „Bez zvuka”. Za promjenu glasnoće koristite glasnoću sustava." diff --git a/locale/hu.po b/locale/hu.po index 721db66..1caec86 100644 --- a/locale/hu.po +++ b/locale/hu.po @@ -1310,3 +1310,7 @@ msgstr "Lejátszási pozíció" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "A Kokoro hangok nem támogatják ezt a beállítást." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "A Kokoro hangok csak a „Némítás” beállítást követik. A hangerőt a rendszer hangerejével módosíthatja." diff --git a/locale/is.po b/locale/is.po index 91714d8..3923c3d 100644 --- a/locale/is.po +++ b/locale/is.po @@ -1310,3 +1310,7 @@ msgstr "Staða spilunar" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Kokoro-raddir styðja ekki þessa stillingu." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Kokoro-raddir fara aðeins eftir „Hljóðlaust“. Notaðu hljóðstyrk kerfisins til að breyta hversu hátt þær tala." diff --git a/locale/it.po b/locale/it.po index 3f76513..9c20990 100644 --- a/locale/it.po +++ b/locale/it.po @@ -1318,3 +1318,7 @@ msgstr "Posizione di riproduzione" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Le voci Kokoro non supportano questa impostazione." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Le voci Kokoro seguono solo «Muto». Usa il volume di sistema per cambiarne l'intensità." diff --git a/locale/ja.po b/locale/ja.po index 4cde188..e246a0e 100644 --- a/locale/ja.po +++ b/locale/ja.po @@ -1310,3 +1310,7 @@ msgstr "再生位置" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Kokoro の音声はこの設定に対応していません。" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Kokoro の音声が従うのは「ミュート」だけです。音量はシステムの音量で変更してください。" diff --git a/locale/ko.po b/locale/ko.po index f553014..2c0502d 100644 --- a/locale/ko.po +++ b/locale/ko.po @@ -1310,3 +1310,7 @@ msgstr "재생 위치" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Kokoro 음성은 이 설정을 지원하지 않습니다." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Kokoro 음성은 '음소거'만 따릅니다. 소리 크기는 시스템 볼륨으로 바꾸세요." diff --git a/locale/nl.po b/locale/nl.po index ae8828b..9487d2c 100644 --- a/locale/nl.po +++ b/locale/nl.po @@ -1312,3 +1312,7 @@ msgstr "Afspeelpositie" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Kokoro-stemmen ondersteunen deze instelling niet." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Kokoro-stemmen volgen alleen ‘Dempen’. Gebruik het systeemvolume om te bepalen hoe luid ze spreken." diff --git a/locale/no.po b/locale/no.po index 61b76db..617352a 100644 --- a/locale/no.po +++ b/locale/no.po @@ -1310,3 +1310,7 @@ msgstr "Avspillingsposisjon" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Kokoro-stemmer støtter ikke denne innstillingen." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Kokoro-stemmer følger bare «Lydløs». Bruk systemvolumet for å endre hvor høyt de snakker." diff --git a/locale/pl.po b/locale/pl.po index e07aaba..3bf8fda 100644 --- a/locale/pl.po +++ b/locale/pl.po @@ -1310,3 +1310,7 @@ msgstr "Pozycja odtwarzania" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Głosy Kokoro nie obsługują tego ustawienia." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Głosy Kokoro uwzględniają tylko „Wyciszenie”. Głośność zmienisz w ustawieniach głośności systemu." diff --git a/locale/pt-BR.po b/locale/pt-BR.po index 053c9dc..799180e 100644 --- a/locale/pt-BR.po +++ b/locale/pt-BR.po @@ -1686,3 +1686,7 @@ msgstr "Posição da reprodução" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "As vozes Kokoro não aceitam este ajuste." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "As vozes Kokoro só obedecem ao Mudo. Use o volume do sistema para mudar a altura da fala." diff --git a/locale/pt.po b/locale/pt.po index f47fe3c..69aa635 100644 --- a/locale/pt.po +++ b/locale/pt.po @@ -1308,3 +1308,7 @@ msgstr "Posição da reprodução" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "As vozes Kokoro não suportam esta definição." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "As vozes Kokoro só respeitam o Silêncio. Use o volume do sistema para alterar a intensidade." diff --git a/locale/ro.po b/locale/ro.po index 60ec6d6..302bb29 100644 --- a/locale/ro.po +++ b/locale/ro.po @@ -1310,3 +1310,7 @@ msgstr "Poziția redării" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Vocile Kokoro nu acceptă această setare." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Vocile Kokoro respectă doar „Mut”. Folosiți volumul sistemului pentru a schimba cât de tare vorbesc." diff --git a/locale/ru.po b/locale/ru.po index af9aed1..797d737 100644 --- a/locale/ru.po +++ b/locale/ru.po @@ -1318,3 +1318,7 @@ msgstr "Позиция воспроизведения" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Голоса Kokoro не поддерживают этот параметр." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Голоса Kokoro учитывают только «Без звука». Громкость меняйте системным регулятором." diff --git a/locale/sk.po b/locale/sk.po index 806fcc3..7f2c6cf 100644 --- a/locale/sk.po +++ b/locale/sk.po @@ -1310,3 +1310,7 @@ msgstr "Pozícia prehrávania" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Hlasy Kokoro toto nastavenie nepodporujú." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Hlasy Kokoro rešpektujú len „Stlmiť“. Hlasitosť zmeníte v systémovej hlasitosti." diff --git a/locale/sv.po b/locale/sv.po index 16a6a84..89e157b 100644 --- a/locale/sv.po +++ b/locale/sv.po @@ -1310,3 +1310,7 @@ msgstr "Uppspelningsposition" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Kokoro-röster stöder inte den här inställningen." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Kokoro-röster följer bara ”Tyst”. Använd systemets volym för att ändra hur högt de talar." diff --git a/locale/tr.po b/locale/tr.po index 1e7ea01..0556049 100644 --- a/locale/tr.po +++ b/locale/tr.po @@ -1310,3 +1310,7 @@ msgstr "Oynatma konumu" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Kokoro sesleri bu ayarı desteklemiyor." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Kokoro sesleri yalnızca “Sessiz” ayarına uyar. Ses düzeyini değiştirmek için sistem sesini kullanın." diff --git a/locale/tts-biglinux.pot b/locale/tts-biglinux.pot index b025e37..e3e25cf 100644 --- a/locale/tts-biglinux.pot +++ b/locale/tts-biglinux.pot @@ -2141,3 +2141,8 @@ msgstr "" # File: tts-biglinux/usr/share/biglinux/tts-biglinux/ui/main_view.py, line: 1296 msgid "Kokoro voices do not support this setting." msgstr "" + +# +# File: tts-biglinux/usr/share/biglinux/tts-biglinux/ui/main_view.py, line: 1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "" diff --git a/locale/uk.po b/locale/uk.po index b75260d..53e4e35 100644 --- a/locale/uk.po +++ b/locale/uk.po @@ -1310,3 +1310,7 @@ msgstr "Позиція відтворення" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Голоси Kokoro не підтримують цей параметр." + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Голоси Kokoro враховують лише «Без звуку». Гучність змінюйте системним регулятором." diff --git a/locale/zh.po b/locale/zh.po index ff87b3e..f661f1d 100644 --- a/locale/zh.po +++ b/locale/zh.po @@ -1310,3 +1310,7 @@ msgstr "播放位置" #: usr/share/biglinux/tts-biglinux/ui/main_view.py:1296 msgid "Kokoro voices do not support this setting." msgstr "Kokoro 语音不支持此设置。" + +#: usr/share/biglinux/tts-biglinux/ui/main_view.py:1305 +msgid "Kokoro voices only follow Mute. Use the system volume to change how loud they are." +msgstr "Kokoro 语音只遵循“静音”。请使用系统音量调整说话的音量。" diff --git a/tests/test_kde_launchers.py b/tests/test_kde_launchers.py index 7eba14a..23a1c90 100644 --- a/tests/test_kde_launchers.py +++ b/tests/test_kde_launchers.py @@ -77,3 +77,11 @@ def test_kglobalaccel_copy_follows_the_configured_key(home): def test_no_kglobalaccel_copy_is_created(home): DIS.ensure_desktop_file("Alt+V") assert not (_kga(home) / "biglinux-tts-speak.desktop").exists() + + +def test_only_the_program_counts_not_an_argument(home): + other = _kga(home) / "bigtts.desktop" + other.write_text("[Desktop Entry]\nType=Application\nExec=echo biglinux-tts\n") + + assert DIS.remove_stale_kde_launchers() == [] + assert other.exists() diff --git a/tests/test_koko_text.py b/tests/test_koko_text.py index a2db29e..62794e6 100644 --- a/tests/test_koko_text.py +++ b/tests/test_koko_text.py @@ -82,3 +82,11 @@ def test_word_longer_than_the_limit_is_cut(): def test_ordinary_text_is_kept(): assert prep("Olá, tudo bem? Sim.") == "Olá, tudo bem?\nSim." + + +def test_closers_after_the_last_sign_are_kept_before_it(): + # A period after a closing quote/bracket would leave '".' or ').' for koko. + assert prep('Ele disse ("Pare!".)') == 'Ele disse ("Pare")!' + assert prep('Ele disse "Pare!"') == 'Ele disse "Pare"!' + assert prep("(Ok!)") == "(Ok)!" + assert prep('Olá "mundo"') == 'Olá "mundo".' diff --git a/tests/test_speech_fixes.py b/tests/test_speech_fixes.py index 231bb2c..c6e831d 100644 --- a/tests/test_speech_fixes.py +++ b/tests/test_speech_fixes.py @@ -285,24 +285,20 @@ def test_no_piper_voices_without_any_piper_engine(tmp_path, monkeypatch): assert vm._discover_piper_voices() == [] -# ── 3. Kokoro: pitch and volume sliders are insensitive ────────────── +# ── 3. Kokoro: pitch slider insensitive, volume only mutes ─────────── -@pytest.mark.parametrize(("backend", "volume", "pitch_ok", "volume_ok"), [ - ("kokoro", 75, False, False), - ("kokoro", 0, False, True), # muted: the slider stays usable to unmute - ("piper", 75, True, True), - ("rhvoice", 75, True, True), -]) -def test_kokoro_disables_the_sliders_it_ignores(backend, volume, pitch_ok, volume_ok): +@pytest.mark.parametrize("backend", ["kokoro", "piper", "rhvoice"]) +def test_kokoro_disables_pitch_and_explains_volume(backend): mv = importlib.import_module("ui.main_view") view = SimpleNamespace( _pitch_scale=mock.Mock(), _pitch_row=mock.Mock(), _volume_scale=mock.Mock(), _volume_row=mock.Mock(), - _settings=SimpleNamespace(speech=SimpleNamespace(volume=volume)), ) mv.MainView._update_kokoro_unsupported_sliders(view, backend) - view._pitch_scale.set_sensitive.assert_called_with(pitch_ok) - view._volume_scale.set_sensitive.assert_called_with(volume_ok) - tip = view._pitch_row.set_tooltip_text.call_args.args[0] - assert (tip is None) == (backend != "kokoro") + view._pitch_scale.set_sensitive.assert_called_with(backend != "kokoro") + # Volume stays usable everywhere: Kokoro honors mute (0). + view._volume_scale.set_sensitive.assert_not_called() + for row in (view._pitch_row, view._volume_row): + tip = row.set_tooltip_text.call_args.args[0] + assert (tip is None) == (backend != "kokoro") diff --git a/tests/test_startup_fixes.py b/tests/test_startup_fixes.py index cb73148..97631fc 100644 --- a/tests/test_startup_fixes.py +++ b/tests/test_startup_fixes.py @@ -1,5 +1,6 @@ """Startup and desktop-integration regressions (review of 2026-11-02).""" import subprocess +from pathlib import Path from types import SimpleNamespace import config @@ -24,6 +25,25 @@ def test_corrupt_settings_are_kept_and_history_stays_off(): assert config.SETTINGS_FILE.with_suffix(".json.corrupt").read_text() == "{not json" +def test_an_earlier_corrupt_copy_is_never_overwritten(): + config.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + first = config.SETTINGS_FILE.with_suffix(".json.corrupt") + first.write_text("first") + config.SETTINGS_FILE.write_text("second") + + config.load_settings() + + assert first.read_text() == "first" + assert config.SETTINGS_FILE.with_suffix(".json.corrupt.1").read_text() == "second" + + +def test_huge_float_falls_back_for_that_key_only(): + s = config._deserialize_settings({ + "speech": {"volume": 42, "kokoro": {"blend_ratio": 10**400}}, + }) + assert s.speech.volume == 42 + + def test_unreadable_settings_do_not_crash(): config.SETTINGS_FILE.mkdir(parents=True) # a directory: read_text raises OSError assert isinstance(config.load_settings(), config.AppSettings) @@ -56,15 +76,16 @@ def test_unknown_desktop_does_not_claim_a_shortcut(monkeypatch): class FakeXfconf: - def __init__(self, bindings): + def __init__(self, bindings, list_status=0): self.bindings = dict(bindings) self.calls = [] + self.list_status = list_status def run(self, args, **kwargs): self.calls.append(args) if "-l" in args: out = "".join(f"{k} {v}\n" for k, v in self.bindings.items()) - return SimpleNamespace(returncode=0, stdout=out) + return SimpleNamespace(returncode=self.list_status, stdout=out) prop = args[args.index("-p") + 1] if "-r" in args: self.bindings.pop(prop, None) @@ -73,8 +94,8 @@ def run(self, args, **kwargs): return SimpleNamespace(returncode=0, stdout="") -def _xfce(monkeypatch, bindings): - fake = FakeXfconf(bindings) +def _xfce(monkeypatch, bindings, list_status=0): + fake = FakeXfconf(bindings, list_status) monkeypatch.setattr(subprocess, "run", fake.run) monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/" + name) return fake @@ -117,6 +138,24 @@ def test_xfce_already_right_writes_nothing(monkeypatch): assert all("-l" in c for c in fake.calls) +def test_xfce_unreadable_list_writes_nothing(monkeypatch): + fake = _xfce(monkeypatch, {"/commands/custom/v": "xfce4-terminal"}, list_status=1) + + assert DIS.register_xfce_shortcut("v", "/usr/bin/biglinux-tts-speak") is False + assert all("-l" in c for c in fake.calls) + + +def test_xfce_already_right_still_drops_old_bindings(monkeypatch): + fake = _xfce(monkeypatch, { + "/commands/custom/v": "/usr/bin/biglinux-tts-speak", + "/commands/custom/s": "/usr/bin/biglinux-tts-speak", + }) + + assert DIS.register_xfce_shortcut("v", "/usr/bin/biglinux-tts-speak") + assert fake.bindings == {"/commands/custom/v": "/usr/bin/biglinux-tts-speak"} + assert not any("-n" in c for c in fake.calls) + + # ── selection capture on X11 ──────────────────────────────────────── @@ -163,3 +202,29 @@ def test_tray_helper_error_is_read_and_reported_on_exit(caplog): assert keep is False assert "No system tray" in caplog.text assert exits == [1] + + +def test_tray_helper_obeys_quit_while_waiting_for_a_tray(tmp_path): + """No tray host yet: "quit" (the tray switched off) still ends it at once.""" + import os + import sys + import time + + import pytest + + pytest.importorskip("PySide6.QtWidgets") + from services.tray_service import _HELPER_SCRIPT + + env = dict(os.environ, QT_QPA_PLATFORM="offscreen") # no tray host there + proc = subprocess.Popen( + [sys.executable, "-s", "-c", _HELPER_SCRIPT, "t", "t", "", "", str( + Path(config.__file__).parent / "services" / "tray_icon_frames.py")], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env, + ) + time.sleep(1.5) + assert proc.poll() is None # still waiting for a tray + t0 = time.monotonic() + proc.stdin.write(b'{"cmd": "quit"}\n') + proc.stdin.flush() + assert proc.wait(timeout=5) == 0 + assert time.monotonic() - t0 < 2 diff --git a/usr/share/biglinux/tts-biglinux/config.py b/usr/share/biglinux/tts-biglinux/config.py index 530a636..31801a2 100644 --- a/usr/share/biglinux/tts-biglinux/config.py +++ b/usr/share/biglinux/tts-biglinux/config.py @@ -192,8 +192,13 @@ def load_settings() -> AppSettings: # Keep the broken file for the user (the next save replaces it), # and an existing user's History is never switched on by itself. logger.warning("Corrupt settings file (%s), using defaults", e) + backup = SETTINGS_FILE.with_suffix(".json.corrupt") + n = 1 + while backup.exists(): # never overwrite an earlier copy + backup = SETTINGS_FILE.with_suffix(f".json.corrupt.{n}") + n += 1 try: - os.replace(SETTINGS_FILE, SETTINGS_FILE.with_suffix(".json.corrupt")) + os.replace(SETTINGS_FILE, backup) except OSError: pass settings.history.enabled = False @@ -228,7 +233,7 @@ def _safe_int(d: dict, key: str, default: int) -> int: def _safe_float(d: dict, key: str, default: float) -> float: try: value = float(d.get(key, default)) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): # OverflowError: 10**400 return default return value if math.isfinite(value) else default diff --git a/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py b/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py index 81770b5..7169ba2 100644 --- a/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py +++ b/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py @@ -299,6 +299,17 @@ def purge_stale_kde_components(keep: str = "biglinux_tts_speak_desktop") -> None def _kde_launcher_dir() -> Path: return Path.home() / ".local" / "share" / "kglobalaccel" + @staticmethod + def _runs_this_app(command: str) -> bool: + """The Exec program (not an argument) is one of our launchers.""" + import shlex + + try: + argv = shlex.split(command) + except ValueError: + return False + return bool(argv) and Path(argv[0]).name in ("biglinux-tts", "biglinux-tts-speak") + @classmethod def remove_stale_kde_launchers(cls) -> list[Path]: """Delete our old launchers from ~/.local/share/kglobalaccel/. @@ -313,8 +324,8 @@ def remove_stale_kde_launchers(cls) -> list[Path]: text = path.read_text(encoding="utf-8", errors="replace") except OSError: continue - execs = [line for line in text.splitlines() if line.startswith("Exec=")] - if not any("biglinux-tts" in line for line in execs): + execs = [line[5:] for line in text.splitlines() if line.startswith("Exec=")] + if not any(cls._runs_this_app(command) for command in execs): continue try: path.unlink() @@ -588,34 +599,37 @@ def is_ours(command: str) -> bool: except (OSError, subprocess.TimeoutExpired) as e: logger.error("Failed to read XFCE shortcuts: %s", e) return False + if result.returncode != 0: + # Without the list, another command on the key could be overwritten. + logger.error("Failed to read XFCE shortcuts (xfconf-query exit %d)", result.returncode) + return False bindings = {} - if result.returncode == 0: - for line in result.stdout.splitlines(): - parts = line.split(None, 1) - if len(parts) == 2 and parts[0].startswith("/commands/custom/"): - bindings[parts[0]] = parts[1].strip() + for line in result.stdout.splitlines(): + parts = line.split(None, 1) + if len(parts) == 2 and parts[0].startswith("/commands/custom/"): + bindings[parts[0]] = parts[1].strip() current = bindings.get(prop_path) - if current == exec_path: - return True # already right: no churn at every start - if current is not None and not is_ours(current): + if current is not None and current != exec_path and not is_ours(current): logger.warning("XFCE shortcut %s is already used by %s", xfce_accel, current) return False # Set the new binding first, then drop our old ones: an app that quits - # in between never leaves the user without a shortcut. - try: - subprocess.run( - [ - "xfconf-query", "-c", channel, - "-p", prop_path, - "-n", "-t", "string", "-s", exec_path, - ], - timeout=5, check=True, - ) - except (OSError, subprocess.TimeoutExpired, subprocess.CalledProcessError) as e: - logger.error("Failed to register XFCE shortcut: %s", e) - return False + # in between never leaves the user without a shortcut. Already right: + # nothing is written (no churn at every start). + if current != exec_path: + try: + subprocess.run( + [ + "xfconf-query", "-c", channel, + "-p", prop_path, + "-n", "-t", "string", "-s", exec_path, + ], + timeout=5, check=True, + ) + except (OSError, subprocess.TimeoutExpired, subprocess.CalledProcessError) as e: + logger.error("Failed to register XFCE shortcut: %s", e) + return False for prop, command in bindings.items(): if prop != prop_path and is_ours(command): try: diff --git a/usr/share/biglinux/tts-biglinux/services/kokoro_voice_service.py b/usr/share/biglinux/tts-biglinux/services/kokoro_voice_service.py index e52fc11..0c1adf0 100644 --- a/usr/share/biglinux/tts-biglinux/services/kokoro_voice_service.py +++ b/usr/share/biglinux/tts-biglinux/services/kokoro_voice_service.py @@ -307,7 +307,7 @@ def build_koko_command( # Two or more sentence signs in a row ("?!", "Fim.!", "etc….") — also when # closing quotes or brackets sit between them ('"Pare!".', "(Ok!)."). _CLOSERS = "\"'”’»)]}" -_RE_PUNCT_RUN = re.compile(r"[.!?…](?:[\"'”’»)\]}]*[.!?…])+") +_RE_PUNCT_RUN = re.compile(r"[.!?…](?:[\"'”’»)\]}]*[.!?…])+[\"'”’»)\]}]*") _RE_SENTENCE_END = re.compile(r"(?<=[.!?…;:])\s+") @@ -387,7 +387,12 @@ def prepare_koko_text(text: str, limit: int = KOKO_MAX_SEGMENT_CHARS) -> str: piece = piece.strip().rstrip(",") if not any(ch.isalnum() for ch in piece): continue - if piece[-1] not in ".!?…;:": + core = piece.rstrip(_CLOSERS) + if core[-1] in ".!?…;:": + # 'disse "Pare!"' → 'disse "Pare"!': a period after the + # quote would leave the piece '".' for koko. + piece = core[:-1] + piece[len(core):] + core[-1] + else: piece += "." lines.append(piece) return "\n".join(lines) diff --git a/usr/share/biglinux/tts-biglinux/services/tray_service.py b/usr/share/biglinux/tts-biglinux/services/tray_service.py index 7c03796..05f5a10 100644 --- a/usr/share/biglinux/tts-biglinux/services/tray_service.py +++ b/usr/share/biglinux/tts-biglinux/services/tray_service.py @@ -88,9 +88,21 @@ def send(data: dict) -> None: # No tray host (GNOME without AppIndicator, or the panel not up yet at # login): wait a little for one, then give up so the app knows. + # The parent's commands are kept (and "quit" obeyed) meanwhile: it waits + # for us to exit when the tray is switched off. import time + stdin_fd = sys.stdin.fileno() + os.set_blocking(stdin_fd, False) + early_input = bytearray() deadline = time.monotonic() + 15 while not QSystemTrayIcon.isSystemTrayAvailable(): + try: + chunk = os.read(stdin_fd, 65536) + if not chunk or b'"quit"' in chunk: + sys.exit(0) # parent gone, or tray switched off + early_input.extend(chunk) + except BlockingIOError: + pass if time.monotonic() > deadline: send({"event": "error", "message": "No system tray on this desktop. Tray icon is disabled."}) sys.exit(2) @@ -238,9 +250,7 @@ def on_activated(reason) -> None: # all into TextIOWrapper's buffer, select() then reports the fd idle, and # the remaining lines sit unprocessed until the NEXT message — which made # the menu lag exactly one update behind. - stdin_fd = sys.stdin.fileno() - os.set_blocking(stdin_fd, False) - inbuf = bytearray() + inbuf = early_input def read_lines(): while True: diff --git a/usr/share/biglinux/tts-biglinux/ui/main_view.py b/usr/share/biglinux/tts-biglinux/ui/main_view.py index baa7800..9d30040 100644 --- a/usr/share/biglinux/tts-biglinux/ui/main_view.py +++ b/usr/share/biglinux/tts-biglinux/ui/main_view.py @@ -1134,6 +1134,10 @@ def _on_voices_discovered(self, catalog: VoiceCatalog) -> None: current_backend = catalog.backends_available[0] filtered = catalog.get_by_backend(current_backend) + logger.info( + "Auto-switched backend from %s to %s (no voices for original)", + self._settings.speech.backend, current_backend, + ) self._settings.speech.backend = current_backend self._settings_service.save(self._settings) @@ -1148,11 +1152,7 @@ def _on_voices_discovered(self, catalog: VoiceCatalog) -> None: self._backend_combo.set_selected( backend_idx_map.get(current_backend, 0) ) - - logger.info( - "Auto-switched backend from %s to %s (no voices for original)", - self._settings.speech.backend, current_backend, - ) + self._refresh_backend_controls(current_backend) if not filtered: # No voices for selected backend @@ -1287,21 +1287,24 @@ def _update_pitch_row_for_backend(self, backend: str) -> None: def _update_kokoro_unsupported_sliders(self, backend: str) -> None: """Kokoro (koko pipe) plays the audio itself and has no pitch or volume - option: those sliders would do nothing, so they are made insensitive. + option: the pitch slider would do nothing, so it is made insensitive. - Volume 0 (mute) is still honored by Kokoro: a muted volume slider stays - usable, so the person can never be locked in silence. + The volume slider stays usable — Kokoro honors 0 (mute) — and its + tooltip says that only mute applies. """ is_kokoro = backend == TTSBackend.KOKORO.value - tip = _("Kokoro voices do not support this setting.") pitch_scale = getattr(self, "_pitch_scale", None) if pitch_scale is not None: pitch_scale.set_sensitive(not is_kokoro) - self._pitch_row.set_tooltip_text(tip if is_kokoro else None) - volume_scale = getattr(self, "_volume_scale", None) - if volume_scale is not None: - volume_scale.set_sensitive(not is_kokoro or self._settings.speech.volume <= 0) - self._volume_row.set_tooltip_text(tip if is_kokoro else None) + self._pitch_row.set_tooltip_text( + _("Kokoro voices do not support this setting.") if is_kokoro else None + ) + if getattr(self, "_volume_row", None) is not None: + # Kept usable: Kokoro honors mute (0), just not the level between. + self._volume_row.set_tooltip_text( + _("Kokoro voices only follow Mute. Use the system volume to change how loud they are.") + if is_kokoro else None + ) def _on_pitch_changed(self, value: float) -> None: """Handle pitch change.""" @@ -1313,6 +1316,19 @@ def _on_volume_changed(self, value: float) -> None: self._settings.speech.volume = int(value) self._settings_service.save(self._settings) + def _refresh_backend_controls(self, backend: str) -> None: + """Kokoro-only combos, pitch row and sliders for ``backend``. + + Also after an automatic switch (no voices, cancelled installation), + which does not go through _on_backend_selected. + """ + is_kokoro = backend == TTSBackend.KOKORO.value + self._kokoro_emotion_combo.set_visible(is_kokoro) + self._kokoro_blend_combo.set_visible(is_kokoro) + if is_kokoro: + self._populate_kokoro_blend_combo() + self._update_pitch_row_for_backend(backend) + def _on_backend_selected(self, index: int) -> None: """Handle backend selection.""" if self._updating_ui: @@ -1326,19 +1342,12 @@ def _on_backend_selected(self, index: int) -> None: backend = backend_map.get(index, TTSBackend.RHVOICE.value) logger.debug("Backend selected: index=%d → %s", index, backend) - # Show Kokoro-specific controls only for Kokoro engine - is_kokoro = backend == TTSBackend.KOKORO.value - self._kokoro_emotion_combo.set_visible(is_kokoro) - self._kokoro_blend_combo.set_visible(is_kokoro) - if is_kokoro: - self._populate_kokoro_blend_combo() - # Stop any active speech before switching self._tts.stop() self._settings.speech.backend = backend self._settings_service.save(self._settings) - self._update_pitch_row_for_backend(backend) + self._refresh_backend_controls(backend) # Prewarm the new backend's model (no audio) so the first Alt+V is warm. self._tts.prewarm(backend, self._settings.speech.voice_id) @@ -1416,6 +1425,7 @@ def _revert_to_rhvoice(self) -> None: self._settings.speech.backend = TTSBackend.RHVOICE.value self._settings_service.save(self._settings) self._backend_combo.set_selected(0) + self._refresh_backend_controls(TTSBackend.RHVOICE.value) if self._catalog: self._on_voices_discovered(self._catalog) From d0f6789771f766ce0cca26a693aa848b29edb137 Mon Sep 17 00:00:00 2001 From: ruscher Date: Tue, 3 Nov 2026 02:47:21 -0300 Subject: [PATCH 05/10] fix: a settings.json that cannot be read is never replaced A read error (permissions, a folder in its place) is not corruption: the file is left as it is and this run does not save over it. Bad UTF-8 is still treated as corrupt and kept as a .corrupt copy. Stale KDE launchers are matched on the [Desktop Entry] Exec only: a desktop action that runs biglinux-tts does not make a launcher ours. --- tests/test_kde_launchers.py | 11 ++++++ tests/test_startup_fixes.py | 38 ++++++++++++++++++- usr/share/biglinux/tts-biglinux/config.py | 20 +++++++++- .../services/desktop_integration_service.py | 15 +++++++- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/tests/test_kde_launchers.py b/tests/test_kde_launchers.py index 23a1c90..d76b9d1 100644 --- a/tests/test_kde_launchers.py +++ b/tests/test_kde_launchers.py @@ -85,3 +85,14 @@ def test_only_the_program_counts_not_an_argument(home): assert DIS.remove_stale_kde_launchers() == [] assert other.exists() + + +def test_a_desktop_action_running_us_does_not_count(home): + other = _kga(home) / "bigtts.desktop" + other.write_text( + "[Desktop Entry]\nType=Application\nExec=someone-elses-app\n\n" + "[Desktop Action Read]\nExec=biglinux-tts --speak\n" + ) + + assert DIS.remove_stale_kde_launchers() == [] + assert other.exists() diff --git a/tests/test_startup_fixes.py b/tests/test_startup_fixes.py index 97631fc..768a138 100644 --- a/tests/test_startup_fixes.py +++ b/tests/test_startup_fixes.py @@ -44,11 +44,45 @@ def test_huge_float_falls_back_for_that_key_only(): assert s.speech.volume == 42 -def test_unreadable_settings_do_not_crash(): - config.SETTINGS_FILE.mkdir(parents=True) # a directory: read_text raises OSError +def test_unreadable_settings_do_not_crash(monkeypatch): + monkeypatch.setattr(config, "_save_blocked", False) + config.SETTINGS_FILE.mkdir(parents=True) # a directory: reading raises OSError assert isinstance(config.load_settings(), config.AppSettings) +def test_a_read_error_never_replaces_valid_settings(monkeypatch): + """Permissions, not corruption: the file is kept and never written over.""" + monkeypatch.setattr(config, "_save_blocked", False) + config.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + config.SETTINGS_FILE.write_text('{"show_welcome": false}') + path_type = type(config.SETTINGS_FILE) + real_read = path_type.read_bytes + + def read_bytes(self): + if self == config.SETTINGS_FILE: + raise PermissionError(13, "Permission denied") + return real_read(self) + + monkeypatch.setattr(path_type, "read_bytes", read_bytes) + + s = config.load_settings() + config.save_settings(s) + + assert s.history.enabled is False + assert config.SETTINGS_FILE.read_text() == '{"show_welcome": false}' + assert not config.SETTINGS_FILE.with_suffix(".json.corrupt").exists() + + +def test_bad_utf8_is_treated_as_corrupt(monkeypatch): + monkeypatch.setattr(config, "_save_blocked", False) + config.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + config.SETTINGS_FILE.write_bytes(b"\xff\xfe{") + + config.load_settings() + + assert config.SETTINGS_FILE.with_suffix(".json.corrupt").exists() + + def test_restore_defaults_keeps_the_shared_object(): svc = settings_service.SettingsService() shared = svc.get() # what TTSService and the History hold diff --git a/usr/share/biglinux/tts-biglinux/config.py b/usr/share/biglinux/tts-biglinux/config.py index 31801a2..ad1b473 100644 --- a/usr/share/biglinux/tts-biglinux/config.py +++ b/usr/share/biglinux/tts-biglinux/config.py @@ -175,20 +175,33 @@ class AppSettings: # ── Settings Persistence ────────────────────────────────────────────── +# Set when settings.json exists but cannot be read (permissions, a folder in +# its place): the file may be fine, so this run never writes over it. +_save_blocked = False + + def load_settings() -> AppSettings: """Load settings from disk, with legacy migration and defaults.""" + global _save_blocked settings = AppSettings() # Try loading current settings if SETTINGS_FILE.exists(): try: - data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + raw = SETTINGS_FILE.read_bytes() + except OSError as e: + logger.warning("Cannot read %s (%s): using defaults, not saving", SETTINGS_FILE, e) + _save_blocked = True + settings.history.enabled = False + return settings + try: + data = json.loads(raw.decode("utf-8")) # bad UTF-8: ValueError if not isinstance(data, dict): raise TypeError("settings root is not an object") settings = _deserialize_settings(data) logger.debug("Settings loaded from %s", SETTINGS_FILE) return settings - except (OSError, OverflowError, json.JSONDecodeError, KeyError, TypeError, ValueError, AttributeError) as e: + except (OverflowError, json.JSONDecodeError, KeyError, TypeError, ValueError, AttributeError) as e: # Keep the broken file for the user (the next save replaces it), # and an existing user's History is never switched on by itself. logger.warning("Corrupt settings file (%s), using defaults", e) @@ -215,6 +228,9 @@ def load_settings() -> AppSettings: def save_settings(settings: AppSettings) -> None: """Save settings to disk as JSON (atomically: a crash never truncates it).""" + if _save_blocked: + logger.warning("Settings not saved: %s could not be read at start", SETTINGS_FILE) + return CONFIG_DIR.mkdir(parents=True, exist_ok=True) tmp = SETTINGS_FILE.with_suffix(".json.tmp") tmp.write_text(json.dumps(asdict(settings), indent=2, ensure_ascii=False), encoding="utf-8") diff --git a/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py b/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py index 7169ba2..d25ca62 100644 --- a/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py +++ b/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py @@ -310,6 +310,18 @@ def _runs_this_app(command: str) -> bool: return False return bool(argv) and Path(argv[0]).name in ("biglinux-tts", "biglinux-tts-speak") + @staticmethod + def _main_exec(text: str) -> str: + """Exec of the [Desktop Entry] group (actions have their own Exec).""" + group = "" + for line in text.splitlines(): + line = line.strip() + if line.startswith("[") and line.endswith("]"): + group = line + elif group == "[Desktop Entry]" and line.startswith("Exec="): + return line[5:] + return "" + @classmethod def remove_stale_kde_launchers(cls) -> list[Path]: """Delete our old launchers from ~/.local/share/kglobalaccel/. @@ -324,8 +336,7 @@ def remove_stale_kde_launchers(cls) -> list[Path]: text = path.read_text(encoding="utf-8", errors="replace") except OSError: continue - execs = [line[5:] for line in text.splitlines() if line.startswith("Exec=")] - if not any(cls._runs_this_app(command) for command in execs): + if not cls._runs_this_app(cls._main_exec(text)): continue try: path.unlink() From 7d622bc83fd95138a6138d1c9118fa7f7cd63102 Mon Sep 17 00:00:00 2001 From: ruscher Date: Tue, 3 Nov 2026 03:03:27 -0300 Subject: [PATCH 06/10] fix: bounded espeak-ng fallback, atomic audio export - espeak-ng CLI fallback: a hung process is stopped after 120 s of work, so the reading and the queue go on; time spent paused is not counted. - "Save audio as" copies to a temporary file next to the destination and renames it: a failed or interrupted export never leaves a truncated file, and keeps the source file's permissions. --- tests/test_history_fixes.py | 31 +++++++++++++++++++ tests/test_speech_fixes.py | 22 +++++++++++++ .../tts-biglinux/services/history_service.py | 19 +++++++++++- .../tts-biglinux/services/tts_service.py | 23 ++++++++++++-- 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/tests/test_history_fixes.py b/tests/test_history_fixes.py index bc9710b..8efb2b1 100644 --- a/tests/test_history_fixes.py +++ b/tests/test_history_fixes.py @@ -450,3 +450,34 @@ def record(widget, props, values): assert labels[player._play_btn] == _("Play") player.cleanup() ap.AudioPlayerWidget._active_player = None + + +def test_export_never_leaves_a_truncated_file(tmp_path, monkeypatch): + import shutil as _shutil + + from services import history_service as hs + + src = tmp_path / "a.wav" + src.write_bytes(b"RIFF" + b"x" * 1000) + src.chmod(0o644) + dest = tmp_path / "out" / "saved.wav" + dest.parent.mkdir() + dest.write_bytes(b"old") + monkeypatch.setattr(hs, "audio_path", lambda entry: src) + + def broken_copy(a, b): + pathlib_b = type(src)(b) + pathlib_b.write_bytes(b"RI") # cut short + raise OSError("disk full") + + real_copy = _shutil.copyfile + monkeypatch.setattr(_shutil, "copyfile", broken_copy) + assert hs.export_audio({}, str(dest)) is False + assert dest.read_bytes() == b"old" + assert [p.name for p in dest.parent.iterdir()] == ["saved.wav"] + + # (not monkeypatch.undo(): it would also undo conftest's user-data guards) + monkeypatch.setattr(_shutil, "copyfile", real_copy) + assert hs.export_audio({}, str(dest)) is True + assert dest.read_bytes() == src.read_bytes() + assert dest.stat().st_mode & 0o777 == 0o644 diff --git a/tests/test_speech_fixes.py b/tests/test_speech_fixes.py index c6e831d..5c5a514 100644 --- a/tests/test_speech_fixes.py +++ b/tests/test_speech_fixes.py @@ -190,6 +190,28 @@ def test_stop_kills_the_espeak_ng_command(fakes, monkeypatch): assert time.monotonic() - started < 3 +def test_a_hung_espeak_ng_is_stopped_but_a_pause_is_not_counted(fakes, monkeypatch): + install(make_bin_dir_on_path(monkeypatch), "espeak-ng", ESPEAK_HANG) + monkeypatch.setattr(ts, "_get_tts_engine", lambda: None) + monkeypatch.setattr(ts, "_ESPEAK_SYNTH_TIMEOUT", 2.0) + svc = ts.TTSService() + assert svc.speak("Texto qualquer.", backend="espeak-ng", voice_id="espeak-pt-br", volume=50) + reading = svc._reading + assert _until(lambda: reading.live_procs(), 3) + espeak = reading.live_procs()[0] + + svc._paused = True # time paused does not count + time.sleep(3.2) + assert espeak.poll() is None + svc._paused = False + + assert espeak.wait(timeout=5) is not None # then the 2 s budget runs out + reading.threads[0].join(timeout=3) + assert not reading.threads[0].is_alive() + assert any("too long" in line for line in svc._stderr_tail) + svc.stop() + + def make_bin_dir_on_path(monkeypatch) -> Path: import os diff --git a/usr/share/biglinux/tts-biglinux/services/history_service.py b/usr/share/biglinux/tts-biglinux/services/history_service.py index 4e8e1f2..ef5b19a 100644 --- a/usr/share/biglinux/tts-biglinux/services/history_service.py +++ b/usr/share/biglinux/tts-biglinux/services/history_service.py @@ -20,6 +20,7 @@ import re import shutil import struct +import tempfile import threading import time import uuid @@ -477,11 +478,27 @@ def export_audio(entry: dict, destination: str) -> bool: path = audio_path(entry) if path is None: return False + # Copied next to the destination, then renamed: an export cut short + # (error, app quit) never leaves a truncated file under the chosen name. + temporary = None try: - shutil.copyfile(path, destination) + fd, temporary = tempfile.mkstemp( + dir=os.path.dirname(destination) or ".", + prefix=f".{os.path.basename(destination)}.", + suffix=".part", + ) + os.close(fd) + shutil.copyfile(path, temporary) + shutil.copymode(path, temporary) # mkstemp makes it 0600 + os.replace(temporary, destination) return True except OSError as e: logger.warning("Could not export %s: %s", path, e) + if temporary is not None: + try: + os.unlink(temporary) + except OSError: + pass return False diff --git a/usr/share/biglinux/tts-biglinux/services/tts_service.py b/usr/share/biglinux/tts-biglinux/services/tts_service.py index 0df73de..de8ca43 100644 --- a/usr/share/biglinux/tts-biglinux/services/tts_service.py +++ b/usr/share/biglinux/tts-biglinux/services/tts_service.py @@ -234,6 +234,10 @@ def heard(self, now: float, paused_since: float | None) -> float | None: return max(0.0, now - self.audio_started_at - paused) +# Seconds espeak-ng may spend on one chunk (time paused excluded). +_ESPEAK_SYNTH_TIMEOUT = 120.0 + + class TTSService: """Speak/stop lifecycle for every backend.""" @@ -880,9 +884,22 @@ def synth(chunk: str) -> str | None: reading.procs.append(proc) # Stop must reach it if not self._is_current(reading.gen): proc.kill() # stopped while it was starting - # No timeout: a paused reading freezes it too (SIGSTOP), and - # Stop now reaches it. - _out, err = proc.communicate(chunk.encode("utf-8")) + # Bounded, but time spent paused (SIGSTOP'd with the reading) + # does not count: a long pause must not kill it. + data, budget = chunk.encode("utf-8"), _ESPEAK_SYNTH_TIMEOUT + while True: + try: + _out, err = proc.communicate(data, timeout=1.0) + break + except subprocess.TimeoutExpired: + data = None # already being fed: never send it twice + if not self._paused: + budget -= 1.0 + if budget <= 0: + proc.kill() + _out, err = proc.communicate() + self._stderr_tail.append("espeak-ng took too long and was stopped") + break if proc.returncode == 0 and os.path.getsize(path) > 100: return path if proc.returncode >= 0: # not killed by Stop From 0ebbd33f494643f5997a43b03fc707a9d2b2ee2e Mon Sep 17 00:00:00 2001 From: ruscher Date: Tue, 3 Nov 2026 03:15:57 -0300 Subject: [PATCH 07/10] fix: heard time and audio export details - A pause that ended before any sound (voice still loading) no longer counts against the heard time, so a short reading is not recorded as 0 s. - "Save audio as": an existing file keeps its own permissions (a 0600 file is never widened), and a symlinked destination is followed, not replaced. --- tests/test_history_fixes.py | 22 +++++++++++++++++++ tests/test_speech_fixes.py | 9 ++++++++ .../tts-biglinux/services/history_service.py | 6 ++++- .../tts-biglinux/services/tts_service.py | 6 ++--- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/tests/test_history_fixes.py b/tests/test_history_fixes.py index 8efb2b1..3c2c095 100644 --- a/tests/test_history_fixes.py +++ b/tests/test_history_fixes.py @@ -481,3 +481,25 @@ def broken_copy(a, b): assert hs.export_audio({}, str(dest)) is True assert dest.read_bytes() == src.read_bytes() assert dest.stat().st_mode & 0o777 == 0o644 + + +def test_export_keeps_an_existing_files_permissions_and_follows_links(tmp_path, monkeypatch): + import os + + from services import history_service as hs + + src = tmp_path / "a.wav" + src.write_bytes(b"RIFF" + b"y" * 100) + src.chmod(0o644) + monkeypatch.setattr(hs, "audio_path", lambda entry: src) + + private = tmp_path / "private.wav" + private.write_bytes(b"old") + private.chmod(0o600) + assert hs.export_audio({}, str(private)) + assert private.stat().st_mode & 0o777 == 0o600 # never widened + + link = tmp_path / "link.wav" + os.symlink(private, link) + assert hs.export_audio({}, str(link)) + assert link.is_symlink() and private.read_bytes() == src.read_bytes() diff --git a/tests/test_speech_fixes.py b/tests/test_speech_fixes.py index 5c5a514..585921d 100644 --- a/tests/test_speech_fixes.py +++ b/tests/test_speech_fixes.py @@ -165,6 +165,15 @@ def test_pause_state_is_cleared_when_the_request_ends(fakes): assert svc.pause() # a stale pause never blocks the next one +def test_a_pause_that_ended_before_any_sound_is_forgotten(fakes): + svc = ts.TTSService() + reading = _reading(svc, "Texto") + reading.paused_for = 0.8 # paused and resumed while the voice loaded + svc._on_audio(reading) + later = reading.audio_started_at + 0.5 + assert abs(reading.heard(later, None) - 0.5) < 1e-6 # not 0.0 + + # ── 6. Stop reaches the espeak-ng command ──────────────────────────── ESPEAK_HANG = f"""#!{sys.executable} diff --git a/usr/share/biglinux/tts-biglinux/services/history_service.py b/usr/share/biglinux/tts-biglinux/services/history_service.py index ef5b19a..a613a31 100644 --- a/usr/share/biglinux/tts-biglinux/services/history_service.py +++ b/usr/share/biglinux/tts-biglinux/services/history_service.py @@ -481,6 +481,8 @@ def export_audio(entry: dict, destination: str) -> bool: # Copied next to the destination, then renamed: an export cut short # (error, app quit) never leaves a truncated file under the chosen name. temporary = None + # A symlink is followed (like a plain copy would), not replaced. + destination = os.path.realpath(destination) try: fd, temporary = tempfile.mkstemp( dir=os.path.dirname(destination) or ".", @@ -489,7 +491,9 @@ def export_audio(entry: dict, destination: str) -> bool: ) os.close(fd) shutil.copyfile(path, temporary) - shutil.copymode(path, temporary) # mkstemp makes it 0600 + # mkstemp makes it 0600: an existing file keeps its own permissions + # (never widened), a new one gets the recording's. + shutil.copymode(destination if os.path.exists(destination) else path, temporary) os.replace(temporary, destination) return True except OSError as e: diff --git a/usr/share/biglinux/tts-biglinux/services/tts_service.py b/usr/share/biglinux/tts-biglinux/services/tts_service.py index de8ca43..7fbfe71 100644 --- a/usr/share/biglinux/tts-biglinux/services/tts_service.py +++ b/usr/share/biglinux/tts-biglinux/services/tts_service.py @@ -1108,9 +1108,9 @@ def _on_audio(self, reading: _Reading) -> None: now = time.monotonic() reading.audio_started_at = now paused_since = self._paused_since if self._paused else None - if paused_since is not None: - # Paused before any sound: only the pause from now on counts. - reading.paused_for -= now - paused_since + # Pauses before any sound are not heard time: forget them; if + # still paused, only the pause from now on will count. + reading.paused_for = -(now - paused_since) if paused_since is not None else 0.0 reading.audio_started = True if reading is self._reading and self._is_current(reading.gen): self._mark_audio_started() From 30d5b527fbe783f4d7b82f5d2e7fe157d0b99a99 Mon Sep 17 00:00:00 2001 From: ruscher Date: Tue, 3 Nov 2026 03:35:18 -0300 Subject: [PATCH 08/10] fix: pause accounting under the lock; export keeps the file's group - The first-sound bookkeeping takes the pause lock, so a resume() at that instant cannot make a pause count as heard time. - Replacing an existing file on export keeps its group when possible. - The symlink export test now proves the target is refreshed. --- tests/test_history_fixes.py | 1 + .../tts-biglinux/services/history_service.py | 4 ++++ .../biglinux/tts-biglinux/services/tts_service.py | 15 +++++++++------ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/test_history_fixes.py b/tests/test_history_fixes.py index 3c2c095..9b8356a 100644 --- a/tests/test_history_fixes.py +++ b/tests/test_history_fixes.py @@ -501,5 +501,6 @@ def test_export_keeps_an_existing_files_permissions_and_follows_links(tmp_path, link = tmp_path / "link.wav" os.symlink(private, link) + private.write_bytes(b"stale") # so the check proves the target was refreshed assert hs.export_audio({}, str(link)) assert link.is_symlink() and private.read_bytes() == src.read_bytes() diff --git a/usr/share/biglinux/tts-biglinux/services/history_service.py b/usr/share/biglinux/tts-biglinux/services/history_service.py index a613a31..5d3bbec 100644 --- a/usr/share/biglinux/tts-biglinux/services/history_service.py +++ b/usr/share/biglinux/tts-biglinux/services/history_service.py @@ -494,6 +494,10 @@ def export_audio(entry: dict, destination: str) -> bool: # mkstemp makes it 0600: an existing file keeps its own permissions # (never widened), a new one gets the recording's. shutil.copymode(destination if os.path.exists(destination) else path, temporary) + try: # and keeps its group (best effort: needs membership) + os.chown(temporary, -1, os.stat(destination).st_gid) + except OSError: + pass os.replace(temporary, destination) return True except OSError as e: diff --git a/usr/share/biglinux/tts-biglinux/services/tts_service.py b/usr/share/biglinux/tts-biglinux/services/tts_service.py index 7fbfe71..494b0e0 100644 --- a/usr/share/biglinux/tts-biglinux/services/tts_service.py +++ b/usr/share/biglinux/tts-biglinux/services/tts_service.py @@ -1105,12 +1105,15 @@ def _begin_playback(self, reading: _Reading, proc: subprocess.Popen) -> bool: def _on_audio(self, reading: _Reading) -> None: """Sound of ``reading`` is being produced.""" if not reading.audio_started: - now = time.monotonic() - reading.audio_started_at = now - paused_since = self._paused_since if self._paused else None - # Pauses before any sound are not heard time: forget them; if - # still paused, only the pause from now on will count. - reading.paused_for = -(now - paused_since) if paused_since is not None else 0.0 + # Under the lock: a resume() in between would add its pause after + # this reset and count time that was not heard. + with self._pause_lock: + now = time.monotonic() + reading.audio_started_at = now + paused_since = self._paused_since if self._paused else None + # Pauses before any sound are not heard time: forget them; if + # still paused, only the pause from now on will count. + reading.paused_for = -(now - paused_since) if paused_since is not None else 0.0 reading.audio_started = True if reading is self._reading and self._is_current(reading.gen): self._mark_audio_started() From 2f17043bfbefc4d5dfb8eb0609b463cbd37c541c Mon Sep 17 00:00:00 2001 From: ruscher Date: Tue, 3 Nov 2026 03:51:02 -0300 Subject: [PATCH 09/10] build: dependencies match what the app runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - speech-dispatcher dropped: the app never uses it (rhvoice still pulls it in); the old "python speechd module" note was obsolete. - xdg-utils added: xdg-open is the History "Open file location" fallback. - pkgconf in makedepends: alsa-sys finds alsa-lib through pkg-config. - optdepends: paru/yay/pamac for Piper packages only in the AUR. - No more qdbus6/qdbus (qt6-tools, never a dependency): the KDE shortcut cleanup talks D-Bus through Gio and dbus-send. - The History folder comes from GLib's XDG user dirs ("Músicas"), so it no longer depends on the xdg-user-dir program. --- pkgbuild/PKGBUILD | 5 +- .../services/desktop_integration_service.py | 117 +++++++----------- .../tts-biglinux/services/history_service.py | 18 +-- 3 files changed, 57 insertions(+), 83 deletions(-) diff --git a/pkgbuild/PKGBUILD b/pkgbuild/PKGBUILD index 38579ee..d6722bb 100644 --- a/pkgbuild/PKGBUILD +++ b/pkgbuild/PKGBUILD @@ -36,10 +36,11 @@ depends=( # Default voice engine, ready out of the box (Brazilian Portuguese) 'rhvoice' 'rhvoice-voice-leticia-f123' - 'speech-dispatcher' # python speechd module; legacy speech-dispatcher voices # Selected-text capture 'wl-clipboard' # Wayland (wl-paste); also provided by wl-clipboard-rs 'xsel' # X11 + # Desktop + 'xdg-utils' # xdg-open: History "Open file location" fallback # System tray icon (enabled by default) and media controls 'pyside6' 'qt6-svg' # the tray icons are SVG @@ -48,6 +49,7 @@ makedepends=( 'git' 'rust>=1.85' 'cargo' + 'pkgconf' # alsa-sys finds alsa-lib through pkg-config ) checkdepends=( 'python-pytest' @@ -59,6 +61,7 @@ optdepends=( 'piper-voices-pt-br: Brazilian Portuguese Piper voices, preinstalled (BigLinux repository; piper-voices-pt-BR in testing)' 'piper-tts-bin: Piper command-line fallback (BigLinux repository; the native engine needs only voices)' 'rhvoice-brazilian-portuguese-complementary-dict-biglinux: extra pronunciation dictionary for RHVoice (BigLinux repository)' + 'paru: build Piper packages from the AUR when they are not in your repositories (or yay, or pamac with AUR enabled)' ) # TTS_BIGLINUX_SOURCE builds another repository or branch, e.g. a checkout # not published yet: TTS_BIGLINUX_SOURCE="file://$PWD/..#branch=my-branch" makepkg -si diff --git a/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py b/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py index d25ca62..4e7f474 100644 --- a/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py +++ b/usr/share/biglinux/tts-biglinux/services/desktop_integration_service.py @@ -100,22 +100,23 @@ def reload_kglobalaccel() -> None: pass # Method 2: Plasma 6/5 reparseConfiguration - for cmd in ["qdbus6", "qdbus"]: - try: - subprocess.run( - [ - cmd, - "org.kde.kglobalaccel", - "/kglobalaccel", - "org.kde.KGlobalAccel.reparseConfiguration", - ], - timeout=3, - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except (OSError, subprocess.TimeoutExpired): - pass + try: + subprocess.run( + [ + "dbus-send", + "--session", + "--type=method_call", + "--dest=org.kde.kglobalaccel", + "/kglobalaccel", + "org.kde.KGlobalAccel.reparseConfiguration", + ], + timeout=3, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except (OSError, subprocess.TimeoutExpired): + pass # Method 3: also notify KGlobalSettings (Legacy) try: @@ -151,46 +152,20 @@ def radical_dbus_cleanup() -> None: ("biglinux-tts-speak.desktop", "AmdRender"), ] for comp, action in zombies: - for dbus_cmd in [ - ["qdbus6"], - ["qdbus"], - [ - "dbus-send", - "--session", - "--type=method_call", - "--dest=org.kde.kglobalaccel", - ], - ]: - try: - if "dbus-send" in dbus_cmd: - subprocess.run( - dbus_cmd - + [ - "/kglobalaccel", - "org.kde.KGlobalAccel.unregister", - f"string:{comp}", - f"string:{action}", - ], - timeout=1, - stderr=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - ) - else: - subprocess.run( - dbus_cmd - + [ - "org.kde.kglobalaccel", - "/kglobalaccel", - "org.kde.KGlobalAccel.unregister", - comp, - action, - ], - timeout=1, - stderr=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - ) - except Exception: - pass + try: + subprocess.run( + [ + "dbus-send", "--session", "--type=method_call", + "--dest=org.kde.kglobalaccel", "/kglobalaccel", + "org.kde.KGlobalAccel.unregister", + f"string:{comp}", f"string:{action}", + ], + timeout=1, + stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + ) + except (OSError, subprocess.TimeoutExpired): + pass @staticmethod def unregister_shortcut_from_memory() -> None: @@ -257,8 +232,6 @@ def purge_stale_kde_components(keep: str = "biglinux_tts_speak_desktop") -> None by hand), so old copies of ours are deleted too — otherwise the zombies come back with their default Alt+V before the app ever starts. """ - import shutil - DesktopIntegrationService.remove_stale_kde_launchers() # D-Bus object-path names (dots/dashes → underscores) of components @@ -268,27 +241,25 @@ def purge_stale_kde_components(keep: str = "biglinux_tts_speak_desktop") -> None "tts_speak_desktop", "br_com_biglinux_tts_desktop", ] - qdbus = next((c for c in ("qdbus6", "qdbus") if shutil.which(c)), None) - if not qdbus: + # Over D-Bus directly (no qdbus: it is not installed everywhere). + try: + from gi.repository import Gio + + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + except Exception as e: + logger.debug("No session bus for the shortcut cleanup: %s", e) return for name in stale: if name == keep: continue try: - subprocess.run( - [ - qdbus, - "org.kde.kglobalaccel", - f"/component/{name}", - "org.kde.kglobalaccel.Component.cleanUp", - ], - timeout=3, - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + bus.call_sync( + "org.kde.kglobalaccel", f"/component/{name}", + "org.kde.kglobalaccel.Component", "cleanUp", + None, None, Gio.DBusCallFlags.NONE, 3000, None, ) - except (OSError, subprocess.TimeoutExpired): - pass + except Exception: + pass # not registered (the usual case) logger.debug("Purged stale KDE shortcut components (kept %s)", keep) # Launchers older versions (or System Settings) left in the kglobalaccel diff --git a/usr/share/biglinux/tts-biglinux/services/history_service.py b/usr/share/biglinux/tts-biglinux/services/history_service.py index 5d3bbec..f882946 100644 --- a/usr/share/biglinux/tts-biglinux/services/history_service.py +++ b/usr/share/biglinux/tts-biglinux/services/history_service.py @@ -54,17 +54,17 @@ def _get_music_dir() -> Path: if _MUSIC_DIR is not None: return _MUSIC_DIR + # GLib reads ~/.config/user-dirs.dirs itself ("Músicas", "Musik"…): no + # xdg-user-dir program needed. try: - import subprocess + from gi.repository import GLib - proc = subprocess.run( - ["xdg-user-dir", "MUSIC"], capture_output=True, text=True, timeout=3, - ) - if proc.returncode == 0 and proc.stdout.strip(): - _MUSIC_DIR = Path(proc.stdout.strip()) - return _MUSIC_DIR - except (FileNotFoundError, subprocess.TimeoutExpired): - pass + music = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_MUSIC) + except Exception: + music = None + if music: + _MUSIC_DIR = Path(music) + return _MUSIC_DIR _MUSIC_DIR = Path.home() / "Music" return _MUSIC_DIR From b13b5270432056afe07cdc44b0a773aa98fc6743 Mon Sep 17 00:00:00 2001 From: ruscher Date: Tue, 3 Nov 2026 03:59:39 -0300 Subject: [PATCH 10/10] =?UTF-8?q?docs:=20README=20rewritten=20=E2=80=94=20?= =?UTF-8?q?screenshots,=20install,=20usage,=20troubleshooting,=20architect?= =?UTF-8?q?ure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every claim checked against the code: Kokoro records with koko pipe -o (not koko stream), current dependencies, test count, Piper voices are packages (only Kokoro voices are downloads). Adds a troubleshooting section (shortcut on KDE, user-site shadowing, downgrades, tray on GNOME, debug log) and Mermaid diagrams of the components and request states. --- README.md | 1248 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 746 insertions(+), 502 deletions(-) diff --git a/README.md b/README.md index cab0d9a..8482c52 100644 --- a/README.md +++ b/README.md @@ -1,471 +1,485 @@ +
+ +BigLinux TTS icon + # BigLinux TTS -

- BigLinux TTS -

- -

- Select any text, press Alt+V, and hear it — native text-to-speech for the Linux desktop -

- -

- License - Version - GTK4 - libadwaita 1.5+ - Rust - Python - 4 TTS engines - 30 languages - 262 tests -

+**Select any text, press Alt+V, and hear it.**
+Text-to-speech for the Linux desktop, with local voices, a global shortcut and real playback controls. + +License: GPL-3.0-or-later +Version 4.1.0 +Platform: Arch, Manjaro, BigLinux +GTK4 and libadwaita 1.5+ +Rust 1.85+ +Python 3.10+ +30 languages + +

+ + + + BigLinux TTS main window, ready to speak + + +
--- -## Table of Contents +**BigLinux TTS** is the text-to-speech tool of [BigLinux](https://www.biglinux.com.br/), a Brazilian distribution based on Manjaro and Arch Linux. Select text in any application, press the shortcut, and it is read aloud; press it again to stop. The package installs a Brazilian Portuguese voice that works without extra setup. All voices run on your computer. + +The app uses Python, GTK4 and libadwaita for the interface and a native Rust engine (PyO3) for espeak-ng and Piper. You can choose between four speech engines: **RHVoice**, **espeak-ng**, **Piper** and **Kokoro**. -- [About](#about) +## Table of contents + +- [Highlights](#highlights) - [Screenshots](#screenshots) -- [History](#history) -- [Features](#features) -- [TTS Engines](#tts-engines) -- [Architecture](#architecture) +- [Quick start](#quick-start) - [Installation](#installation) - [Usage](#usage) -- [Configuration](#configuration) +- [Speech engines](#speech-engines) +- [Text processing](#text-processing) +- [Configuration and files](#configuration-and-files) +- [Troubleshooting](#troubleshooting) +- [Architecture](#architecture) +- [Technical details](#technical-details) - [Internationalization](#internationalization) -- [Technical Details](#technical-details) -- [Testing](#testing) -- [Building from Source](#building-from-source) +- [Development and testing](#development-and-testing) +- [Packaging](#packaging) +- [Project structure](#project-structure) +- [Project history](#project-history) +- [Contributing](#contributing) - [License](#license) - [Authors](#authors) --- -## About - -**BigLinux TTS** is a native Linux desktop application that reads text aloud. Built with GTK4, libadwaita and a native Rust engine, it is the built-in text-to-speech tool of [BigLinux](https://www.biglinux.com.br/) — a Brazilian Linux distribution based on Manjaro/Arch Linux. - -Select any text on screen, press **Alt+V**, and hear it read aloud. Press again to stop. No complicated setup: installing the package brings a ready Brazilian Portuguese voice. - -### Use Cases +## Highlights + +| | | +| --- | --- | +| **One shortcut** | Alt+V by default, and you can change it. Press it to read the selected text and press it again to stop, also while the voice is still loading. | +| **Four engines** | RHVoice (the default, with the Brazilian Portuguese voice *Letícia*), espeak-ng (100+ languages), Piper (neural, native ONNX) and Kokoro (neural, voice blending). | +| **Clear status** | The *Ready to speak* card shows the real state: ready, loading the voice, speaking, stopped, or an error with its reason and a way to fix it, such as *Open Voice Manager*. Errors are never silent. | +| **Checked shortcut** | The shortcut is registered through the desktop's own API and the app reports it as active only after the desktop confirms it. On KDE, conflicts with other actions are shown by name. | +| **Playback controls** | Pause and resume where the audio stopped, a tray icon that pulses while reading, and an MPRIS mini-player for desktop media controls and media keys. | +| **History** | Every reading you heard is kept with its text and the exact audio that played, whatever the engine. You can search it, play it again or export it, and deleted entries go to the Trash. | +| **Text cleanup** | Abbreviations, numbers, dates and money are read as words, special characters are read by name, and HTML and Markdown are removed. The rules follow the language of the selected voice. | +| **Speech Dispatcher never started** | The app talks to every engine directly. It never starts or queries `speech-dispatcher`, because starting it can make other installed modules speak. | +| **Modern interface** | GTK4 + libadwaita (GNOME HIG): adaptive down to 360 px wide, light and dark styles, RTL support, reduced motion, accessible names. | +| **30 languages** | Every string is translated. Portuguese (Brazil) and Portuguese (Portugal) are separate catalogs. | +| **Official repositories only** | Every required dependency comes from the official Arch/Manjaro repositories. BigLinux extras are optional. | -- **Accessibility** — screen reading for users with visual impairments or reading difficulties -- **Multitasking** — listen to articles, documents, and emails while doing other things -- **Language learning** — hear correct pronunciation in 100+ languages -- **Proofreading** — catch writing errors by listening to what was written -- **Productivity** — convert passive reading into active listening +--- -### What Sets It Apart +## Screenshots -1. **4 TTS engines** — RHVoice, espeak-ng (native FFI), Piper (native ONNX) and Kokoro neural voices -2. **Native Rust engine** — espeak-ng via direct FFI and Piper ONNX inference via `ort`; the GIL is released during synthesis, so the window never freezes -3. **Honest feedback** — the window shows the real state (loading the voice, speaking, stopped, error with the reason and how to fix it); nothing fails silently -4. **Reliable global shortcut** — registered through the desktop's own API and confirmed before the app says it works; conflicts are named -5. **Smart text processing** — expands abbreviations, normalizes numbers, pronounces special characters, strips HTML/Markdown -6. **Desktop integration** — system tray with playback controls, MPRIS media controls, notifications -7. **Modern UI** — GTK4 + libadwaita (GNOME HIG), adaptive down to 360 px wide, light/dark, RTL, reduced motion -8. **30 languages** — every string translated; Portuguese of Brazil and of Portugal kept separate -9. **Plain Arch packaging** — every dependency comes from the official Arch/Manjaro repositories +| Ready to speak (light) | Ready to speak (dark) | +| :---: | :---: | +| ![Main window, light style](screenshots/main-light.png) | ![Main window, dark style](screenshots/main-dark.png) | +| **Speaking** | **Error with a way out** | +| ![Reading in progress](screenshots/speaking.png) | ![Error state with an Open Voice Manager button](screenshots/error.png) | +| **Engine status** | **Voice Manager** | +| ![Status of each engine](screenshots/engine-status.png) | ![Voice Manager listing Kokoro voices](screenshots/voice-manager.png) | +| **History** (main menu → History) | **Brazilian Portuguese interface** | +| ![History page with saved readings](screenshots/history.png) | ![Main window in pt-BR](screenshots/main-pt-BR.png) | +| **First run** | **Advanced options** | +| ![Welcome dialog](screenshots/welcome.png) | ![Advanced options with the active shortcut](screenshots/advanced-options.png) | + + + + + + +
Narrow window layout
Narrow window (360–600 px)
+ Screenshots of version 4.1.0 with the BigLinux icon theme, rendered headless in a clean profile. The history entries are sample data. +
--- -## Screenshots +## Quick start -| Ready to speak (dark) | Ready to speak (light) | -|---|---| -| ![Main window, dark theme](screenshots/main-dark.png) | ![Main window, light theme](screenshots/main-light.png) | +```bash +sudo pacman -S tts-biglinux # BigLinux (other distributions: see Installation) +``` -| Speaking | Error with a way out | -|---|---| -| ![Speaking state](screenshots/speaking.png) | ![Error state with Open Voice Manager](screenshots/error.png) | +1. Open **BigLinux Text to Speech** from the application menu, or run `biglinux-tts`. +2. On the first run, a welcome dialog explains the basics. RHVoice with *Letícia* is already selected. +3. Select text in any application and press Alt+V. Press it again to stop. -| Engine status | Voice Manager | -|---|---| -| ![Engine status for each engine](screenshots/engine-status.png) | ![Voice Manager with Kokoro voices](screenshots/voice-manager.png) | +The window is only for settings. The shortcut works with the window closed. -| History (main menu → History) | Advanced options | -|---|---| -| ![History view](screenshots/history.png) | ![Advanced options with the shortcut](screenshots/advanced-options.png) | +--- -| Narrow window (360–600 px) | First run | Brazilian Portuguese | -|---|---|---| -| ![Narrow layout](screenshots/narrow.png) | ![Welcome dialog](screenshots/welcome.png) | ![Main window in pt-BR](screenshots/main-pt-BR.png) | +## Installation -Screenshots of the real application, version 4.1.0 (BigLinux icon theme, rendered headless in a clean profile; sample history entries). +### From the BigLinux repositories ---- +```bash +sudo pacman -S tts-biglinux +``` -## History +### Manjaro and Arch Linux -BigLinux TTS was born from a practical need: making text-to-speech accessible and easy on the Linux desktop. +The package builds from its PKGBUILD. All of its dependencies are in the official repositories (`core`, `extra`), so you do not need the BigLinux or Big Community repositories: -| Date | Version | Milestone | -|------|---------|-----------| -| Sep 2021 | — | First commit by Bruno Gonçalves: initial web-based interface | -| Mar 2022 | — | Rafael Ruscher joins: icon design, CSS refinements, translations | -| Aug 2022 | — | PKGBUILD packaging, i18n with 29 locales, CI/CD workflow | -| Dec 2023 | — | Volume/pitch/rate range inputs, UI polish | -| Feb 2026 | 3.0 | **Full rewrite**: web UI → GTK4 + libadwaita + Python. speech-dispatcher integration, Piper Neural TTS, tray icon (PySide6 subprocess), text processor with abbreviation expansion | -| Mar 2026 | 3.1 | Native RHVoice backend, parallel voice discovery, Python DBus launcher | -| Mar 2026 | 3.2 | Voice Manager dialog with install/remove, theme support, khotkeys sync | -| Jun 2026 | **4.0** | **Native Rust engine** (PyO3): espeak-ng FFI, Piper ONNX inference via `ort` with model caching (7× faster short text). Kokoro Neural TTS integration. Complete i18n audit. Full codebase cleanup | -| Sep 2026 | 4.0 | History in SQLite with retention, Piper streaming, systray playback controls (pause/resume), MPRIS mini-player, pt-BR number/date normalization, diagnostics | -| Oct 2026 | 4.0 | Kokoro fixed for the global shortcut, silent startup, real loading/error states, *Ready to speak* card, engine status, KGlobalAccel shortcut registration with conflict detection, GIL released in Rust, adaptive layout, tray icon breathing on HiDPI, 100 automated tests | -| Oct 2026 | **4.1** | **Release**: history for every engine, long texts streamed by espeak-ng too (bounded memory), text rules follow the voice's language, complete translations in 30 languages, dependencies from the official repositories only, release versioning, CI | +```bash +git clone https://github.com/biglinux/tts-biglinux.git +cd tts-biglinux/pkgbuild +makepkg -si +``` ---- +The package contains the application, the native engine, RHVoice with the Brazilian Portuguese voice *Letícia*, espeak-ng, the tray icon (PySide6), the History player (GStreamer) and the clipboard tools. That is everything you need to select text and press Alt+V. -## Features +> [!NOTE] +> The PKGBUILD's `source=` is the upstream Git repository, so `makepkg` builds `main` from GitHub and not your local files. To build another repository or branch, such as a checkout you have not published yet, set `TTS_BIGLINUX_SOURCE`: +> +> ```bash +> cd tts-biglinux/pkgbuild +> TTS_BIGLINUX_SOURCE="file://$PWD/..#branch=my-branch" makepkg -si +> ``` +> +> A `file://` Git source only sees **committed** work on that branch. -### Text Reading +### Optional voices and extras -- **Configurable global hotkey** (default Alt+V) — select text anywhere, press to speak, press again to stop (also while the voice is still loading) -- **"Ready to speak" card** — shows the real state (ready, loading voice, speaking, stopped, error with the reason and a fix such as *Open Voice Manager*) and the shortcut as keyboard keys, with the desktop's confirmation that it is active -- **Engine status** — RHVoice, espeak-ng, Piper and Kokoro each show *Not installed*, *No voices*, *Ready*, and for the selected engine *Loading*, *Speaking* or *Error* -- **System tray icon** — breathes (opacity only) while reading; left-click speaks or stops, right-click offers Pause/Play and Stop while reading, plus Read text, Settings and Quit; the tooltip shows the current shortcut -- **Notifications** — what is being read, and a clear message when something fails or nothing is selected -- **Built-in voice test** — text field to type and hear with the current voice settings -- **History** — every reading that was heard, with its audio, whatever the engine (main menu → History, Ctrl+H; see [History](#reading-history)) -- **Silent start** — opening the app never speaks and never starts speech-dispatcher -- **Long texts** — read to the end with any character limit, including *Unlimited*: espeak-ng and Piper stream sentence chunks, Kokoro reads line by line +```bash +# English RHVoice voice (official repositories) +sudo pacman -S rhvoice-voice-evgeniy-eng -### Voice Control +# BigLinux repositories +sudo pacman -S biglinux-kokoro-tts # Kokoro neural engine: koko, model, base voices +sudo pacman -S piper-voices-pt-br # Brazilian Portuguese Piper voices (stable) +sudo pacman -S piper-voices-pt-BR # the same voices, as spelled in biglinux-testing +``` -- **Speed** — scale from -100 (slow) to +100 (fast) -- **Pitch** — scale from -100 (low) to +100 (high); for Piper it controls *expressiveness* -- **Volume** — scale from 0 (mute) to 100 (max) -- **Voice selection** — list filtered by engine: "Name — Language · Neural / High quality" -- **Kokoro** — expression presets (neutral, happy, calm, urgent, narrative) and blending with a second voice +You can also install voices from the **Voice Manager** in the app: RHVoice, Piper and espeak-ng voices as pacman packages, and Kokoro voices as downloads. -### Reading History +
+About the two Piper package spellings and the AUR -Main menu → **History** (Ctrl+H) is always available, also with saving turned off (old readings stay). Saving is on for new installations; settings from earlier versions keep their choice. Advanced options: *Save history*, *Keep the audio*, *Keep the text*, *Keep at most* (100 … 5000 or unlimited). +BigLinux stable spells the Piper voice package `piper-voices-pt-br` and testing spells it `piper-voices-pt-BR`. Both ship the same files, so you can install only one. When Piper is selected, the app looks for both spellings: first an installed one, then one in your repositories. It never offers the second spelling when one is already installed. -- **What is kept** for each reading that was actually heard: the text as given and as processed, engine, voice, date and time, duration, outcome (*completed*, *stopped* by the user, or *error* after sound started), and the audio -- **The audio is the audio that was played**, not a second synthesis: +If neither spelling is in your repositories, the app falls back to the AUR and says so before installing. It builds the AUR package as you, never as root, using `pamac build` when pamac's AUR support is on, or else `paru` or `yay`. These helpers are optional dependencies. -| Engine | How the audio is kept | -|--------|-----------------------| -| RHVoice | `RHVoice-test` writes WAV to stdout; the app passes it to `aplay` and writes the same bytes to the history file | -| Kokoro | `koko stream` writes one WAV stream to stdout; same relay as RHVoice (koko's own `pipe` mode plays by itself and could not be kept) | -| espeak-ng, Piper | each WAV chunk is appended to the history file when it starts playing (long texts: one file for the whole reading) | -| Speech Dispatcher | not a selectable engine since 4.0: speech-dispatcher plays on its own, so its audio could only be captured from the sound server — which this app never does | +
-- A stopped reading keeps the audio up to the stop (at most a fraction of a second not heard, still in the player's buffer) -- **The page**: cards with the text (expandable), engine, voice, date, duration and outcome; player; copy, *Read again* with the current voice, *Save audio as…*, open the folder, delete. Search (text, voice, engine, date), filters (all dates, today, yesterday, last 7 days; engine), newest or oldest first, list or grid, multiple selection (save to a folder, delete). New readings appear by themselves; pages of 50 load as you scroll -- **Safe deleting**: one entry can be undone for 5 seconds; a selection asks first; files go to the **Trash** (restored files come back as entries on the next start) -- **Recovery**, at every start, off the main thread: entries are rebuilt from the files when the index is damaged (the damaged file is kept as `history.db.damaged-…`), files without an entry come back, a recording interrupted by a crash is finished and kept, durations of older entries are filled in, a legacy `history.json` is migrated once. Nothing saved is ever deleted by the recovery -- Saving runs in a worker: it never delays the start of speech (first audio measured equal with history on and off) and a failure (full disk, no permission) never stops a reading +### Running from a source checkout -### Voice Manager +```bash +git clone https://github.com/biglinux/tts-biglinux.git +cd tts-biglinux -- Install and remove voices for RHVoice, Piper, espeak-ng (pacman packages via `pkexec`) and Kokoro (downloaded voice files) -- Play/stop a sample with the same button, search by name or language, size shown before installing -- Kokoro downloads: real progress, **Cancel**, verification (incomplete or damaged files are rejected), free-space check, atomic update of `voices.bin` -- Removal asks for confirmation +# 1. Build the native engine against the SYSTEM ONNX Runtime +# (linking a pip-provided onnxruntime breaks Piper at runtime) +cd tts-engine +ORT_LIB_LOCATION=/usr/lib ORT_PREFER_DYNAMIC_LINK=1 cargo build --release --locked +cd .. -### Text Processing +# 2. Link it into the application directory (a checkout's own build wins +# over an installed /usr/lib/tts-biglinux/tts_engine.so) +ln -sf ../../../../tts-engine/target/release/libtts_engine.so \ + usr/share/biglinux/tts-biglinux/tts_engine.so -The rules follow the **language of the selected voice** (an English voice on a Portuguese desktop gets English rules). Each option applies to every engine. +# 3. Run. The launchers detect the checkout and use its code and catalogs. +./usr/bin/biglinux-tts --debug +``` -| Option | What it does | Example | -|--------|--------------|---------| -| Expand abbreviations | Expands slang and titles per language; never inside e-mails, URLs or hyphenated words | `vc` → "você", `Dr. Silva` → "doutor Silva", `w/o` → "without" | -| Normalize numbers (pt) | Numbers, money, percentages, dates and times as words | `R$ 1.234,56`, `08/10/2026`, `21:00` → "vinte e uma horas" | -| Read special characters | Symbols by name (pt, en, es); off: symbols the engines would read are removed | `#` → "cerquilha"/"hash" | -| Remove formatting | HTML tags and Markdown markers; headings keep a pause, numbered lists keep their numbers | `**bold**` → "bold" | -| Read URLs | On: the domain (`https://www.example.com/a?b=1` → "example.com"); off: removed | | -| Character limit | Unlimited, 1K, 5K, 10K, 50K, 100K — cut at a sentence or word boundary | | +Without the native engine, espeak-ng and Piper fall back to their command-line programs (`espeak-ng`, and `piper-tts` from `piper-tts-bin`). -### Keyboard Shortcuts +While the checkout's own app is the one running, the global shortcut it registers runs the checkout's `usr/bin/biglinux-tts-speak`. -| Shortcut | Action | -|----------|--------| -| Alt+V (default) | Speak/stop selected text (toggle) | -| Ctrl+H | Open History | -| Escape / Alt+Left | Back from History | -| F10 | Open the main menu | -| Ctrl+Q | Quit application | +### Uninstalling -Changing the shortcut (Advanced options → Keyboard shortcut) registers it with the desktop and only reports success once the desktop confirms it. On KDE Plasma this uses KGlobalAccel over D-Bus: a key already used by another action is reported by name and the previous shortcut is kept. A shortcut needs Ctrl, Alt or Super (or a function key) so normal typing is never captured. GNOME, XFCE and Cinnamon use their own settings. +```bash +sudo pacman -R tts-biglinux +``` ---- +Removing the package leaves your personal data in your home folder: settings, history and downloaded Kokoro voices. To remove it as well, delete those folders (see [Configuration and files](#configuration-and-files)). -## TTS Engines +### Dependencies -### 1. RHVoice +Every required package comes from the official Arch/Manjaro repositories. This list matches `pkgbuild/PKGBUILD` (`epoch=1`, `pkgver=4.1.0`, `pkgrel=5`). -High-quality multilingual TTS — **the default engine**, installed with the package together with the Brazilian Portuguese voice *Letícia*. Played as `RHVoice-test | aplay` (no speech-dispatcher), both for reading and for the Voice Manager preview; the text goes through stdin, so its size is not limited. +
+Required (depends) -| Voice | Language | Quality | -|-------|----------|---------| -| Letícia F123 | pt-BR | ★★★★ | -| Evgeniy | English | ★★★★ | -| + others | Multiple | ★★★–★★★★ | +| Package | Why | +| --- | --- | +| `python`, `python-gobject` | Python 3 and PyGObject | +| `gtk4`, `libadwaita>=1:1.5` | Interface (`Adw.AlertDialog`, `Adw.OverlaySplitView`, `Adw.Breakpoint`) | +| `python-numpy` | Kokoro voice downloads (`.pt` → `.npy` conversion) | +| `polkit` | `pkexec` to install or remove voice packages | +| `onnxruntime` | Piper ONNX inference in the native engine (provided by `onnxruntime-cpu` and variants) | +| `espeak-ng` | espeak voices and the Piper phonemizer (linked by `tts_engine.so`) | +| `alsa-utils` | `aplay`: every engine plays through it | +| `alsa-lib` | Linked by `tts_engine.so` (rodio/cpal) | +| `sox` | Volume on the subprocess fallback paths | +| `gstreamer`, `gst-plugins-base`, `gst-plugins-good` | History player (`playbin`, `wavparse`, audio sinks) | +| `rhvoice`, `rhvoice-voice-leticia-f123` | Default engine and voice | +| `wl-clipboard` | Selected text on Wayland (`wl-paste`, also provided by `wl-clipboard-rs`) | +| `xsel` | Selected text on X11 | +| `xdg-utils` | `xdg-open`, the History *Open file location* fallback | +| `pyside6`, `qt6-svg` | System tray icon (the tray icons are SVG) | + +
+ +
+Build and check (makedepends, checkdepends) -Settings from versions that used the old speech-dispatcher backend are moved to RHVoice. +| Package | Why | +| --- | --- | +| `git` | Fetch the sources | +| `rust>=1.85`, `cargo` | Build `tts_engine.so` | +| `pkgconf` | `alsa-sys` finds `alsa-lib` through pkg-config | +| `python-pytest` | `check()` runs the Python test suite | -### 2. espeak-ng (Native FFI) ⚡ +
-Direct C FFI to `libespeak-ng.so` from the Rust engine — no subprocess for synthesis. +
+Optional (optdepends) -- `AUDIO_OUTPUT_SYNCHRONOUS` mode: espeak-ng renders PCM and never opens the audio device itself (it is also Piper's phonemizer) -- One-time initialization via `OnceLock`, all calls serialized -- Texts longer than 600 characters are streamed in sentence chunks: sound starts at once, memory stays bounded and Stop takes effect between chunks (the command-line fallback reads the text from stdin) -- 100+ languages with basic quality +| Package | Repository | Why | +| --- | --- | --- | +| `rhvoice-voice-evgeniy-eng` | official | RHVoice English male voice | +| `xclip` | official | Selection capture on X11 when `xsel` is not available | +| `biglinux-kokoro-tts` | BigLinux | Kokoro neural voices (`koko`, model, base voices) | +| `piper-voices-pt-br` | BigLinux | Brazilian Portuguese Piper voices, preinstalled (`piper-voices-pt-BR` in testing) | +| `piper-tts-bin` | BigLinux | Piper command-line fallback (the native engine needs only the voices) | +| `rhvoice-brazilian-portuguese-complementary-dict-biglinux` | BigLinux | Extra pronunciation dictionary for RHVoice | +| `paru` (or `yay`, or `pamac` with AUR enabled) | AUR / BigLinux | Build Piper packages from the AUR when your repositories do not have them | -### 3. Piper (Native ONNX Inference) ★★★★★ +
-Neural TTS with near-human quality. ONNX models run locally through the `ort` crate — the `piper-tts` binary is only a fallback. +`speech-dispatcher` is not a dependency of this package. The `rhvoice` package may still pull it in, but this app never starts it. -**Pipeline**: text → espeak-ng IPA phonemes (FFI) → phoneme IDs → ONNX model → WAV → `aplay` +--- -| Feature | Detail | -|---------|--------| -| Runtime | `ort` 2.0 (system ONNX Runtime, dynamic link) | -| Model cache | load once, reuse; prewarmed in the background after startup | -| Long text | sentence chunks synthesized while the previous one plays | -| Performance | 7× faster than the subprocess for short text | +## Usage -### 4. Kokoro (Neural TTS) ★★★★★ +### The window -Neural voices in Portuguese, English, Spanish, French, Italian, Japanese, Hindi and Chinese, with voice blending and expression presets. It runs through the `koko` binary from the `biglinux-kokoro-tts` package (model + base voices). +| Area | What it does | +| --- | --- | +| **Ready to speak** card | Current state (ready / loading voice / speaking / stopped / error with the fix) and the shortcut drawn as keys, plus whether the desktop confirmed it | +| **Voice settings** (sidebar) | Engine (*TTS Backend*), **Engine status** of all four engines, voice, speed, pitch, volume; for Kokoro also *Expression style* and *Voice blend* | +| **Test voice** | Type a short text and hear it with the current settings | +| Text options | Expand abbreviations, read special characters, remove formatting, read URLs, character limit | +| **Advanced options** | Keyboard shortcut, tray icon, history (save, keep audio, keep text, keep at most), audio playback mode, media player | +| Main menu (F10) | History, Show tray icon, Restore Defaults…, Welcome, About BigLinux TTS, Quit | -- One command builder (`kokoro_voice_service.build_koko_command`) serves both reading and the Voice Manager preview: explicit model, voices file, language from the voice prefix, and a private writable work directory (`$XDG_RUNTIME_DIR/biglinux-tts/koko`) -- `koko pipe` streams sentence by sentence (sentences longer than 220 characters are split, punctuation runs collapsed); the card shows *Loading voice* until koko reports that audio started, and a crash of koko is reported as an error -- The selected voice is checked against `voices.bin` before speaking (koko would silently use another voice) -- Voice blending: mix two voices (`voice.W+voice.W`); expression presets adjust the speed -- More voices can be downloaded in the Voice Manager +Engine status shows each engine as *Not installed*, *No voices* or *Ready*. For the selected engine it can also show *Loading*, *Speaking* or *Error*. -### Automatic Voice Discovery +The slider ranges are speed −100…+100, pitch −100…+100 and volume 0…100. The defaults are −25, −25 and 75. Piper has no real pitch control, so the pitch row is relabeled *Expressiveness* for Piper. Kokoro does not follow pitch or volume levels; see [Speech engines](#speech-engines). -All engines are scanned in parallel in a background thread: +### Global shortcut -1. **RHVoice**: scans `/usr/share/RHVoice/voices/` (speech-dispatcher is not queried: starting it can make other installed modules speak) -2. **espeak-ng**: `espeak-ng --voices` → language code, gender -3. **Piper**: scans `/usr/share/piper-voices/` and `~/.local/share/piper-voices/` for `.onnx` + `.onnx.json` -4. **Kokoro**: `koko voices` on the active `voices.bin` (system base voices + user downloads) +The default is Alt+V. You can change it in **Advanced options → Keyboard shortcut**. The shortcut must include Ctrl, Alt or Super (or be a function key), so that normal typing is never captured. -Result: a `VoiceCatalog` with every voice plus, per engine, whether it is installed and has voices (shown as *Engine status*). +When pressed, it runs `biglinux-tts-speak`, which hands the request to the running app or starts one: ---- +1. While a reading is loading or playing, it **stops** it (in the default *Interrupt previous* playback mode). +2. If text is selected, it **reads** it with the configured engine and voice. +3. If nothing is selected, a short notification says so. -## Architecture +| Desktop | How the shortcut is registered | +| --- | --- | +| KDE Plasma | KGlobalAccel over D-Bus (`doRegister`, `setForeignShortcut`), then read back with `allShortcutInfos`. Other actions using the key are found with `getGlobalShortcutsByKey`, reported by name, and the previous key is kept. If KGlobalAccel cannot be reached, the app writes `kglobalshortcutsrc` instead. | +| GNOME (and Unity) | `gsettings`: a custom keybinding in `org.gnome.settings-daemon.plugins.media-keys` | +| Cinnamon | `gsettings`: `org.cinnamon.desktop.keybindings` custom list | +| Xfce | `xfconf-query`, channel `xfce4-keyboard-shortcuts` | +| Any other desktop | The app does not claim a shortcut. Add one in your desktop's keyboard settings with the command **`biglinux-tts-speak`**. | -``` -┌──────────────────────────────────────────────────────────────────────────┐ -│ main.py — CLI args (--speak, --debug, --version), logging, App.run() │ -├──────────────────────────────────────────────────────────────────────────┤ -│ application.py — TTSApplication (Adw.Application, single instance) │ -│ startup → shortcut registration (worker) → tray → MPRIS → prewarm │ -│ --speak: capture selection (worker) → speak / stop (toggle) │ -├────────────────────────┬────────────────────────┬────────────────────────┤ -│ UI layer (GTK thread) │ Service layer │ Data layer │ -├────────────────────────┼────────────────────────┼────────────────────────┤ -│ window.py │ tts_service.py │ config.py │ -│ ├ OverlaySplitView │ ├ speak / stop │ ├ AppSettings │ -│ ├ header + main menu │ ├ LOADING/SPEAKING/ │ ├ TTSBackend/TTSState │ -│ └ status bar │ │ ERROR + reasons │ └ JSON load/save │ -│ main_view.py │ └ per-engine paths │ settings_service.py │ -│ ├ Ready to speak card │ shortcut_service.py │ └ debounced save │ -│ ├ engine status │ └ KGlobalAccel D-Bus │ history_db.py │ -│ └ voice settings │ voice_manager.py │ └ SQLite + retention │ -│ components.py │ └ discovery + status │ │ -│ └ ShortcutKeys, pills │ kokoro_voice_service.py│ │ -│ voice_manager_dialog.py│ └ koko argv, downloads│ │ -│ history_view.py │ clipboard_service.py │ │ -│ audio_player.py (Gst) │ tray_service.py ──────────▶ PySide6 subprocess │ -│ welcome_dialog.py │ tray_icon_frames.py │ (QSystemTrayIcon) │ -│ │ mpris_service.py │ │ -│ │ desktop_integration_* │ │ -├────────────────────────┴────────────────────────┴────────────────────────┤ -│ tts_engine.so (Rust / PyO3) — GIL released during synthesis │ -│ espeak-ng FFI (synchronous) · Piper ONNX via ort (model cache) │ -├──────────────────────────────────────────────────────────────────────────┤ -│ External: RHVoice-test · koko · aplay · wl-paste/xsel · pkexec pacman │ -└──────────────────────────────────────────────────────────────────────────┘ -``` +Registration runs in a background thread when the app starts. The card, Advanced options, the status bar and the tray tooltip update when the desktop answers. -### Request Lifecycle (TTS State Machine) +### Tray icon and media controls -``` - speak() audio starts - IDLE ─────────────────▶ LOADING ─────────────────────▶ SPEAKING - ▲ │ (synthesizing, │ - │ │ loading the model) │ playback ends - │ stop() / Alt+V │ │ - ├────────────────────────┴──────────────────────────────┤ - │ │ - │ engine failed │ - │ ┌──────────────── ERROR ◀───────────────────┘ - └────────────┘ (reason + recovery action kept until the next speak/stop) -``` +The tray icon is on by default. You can turn it off in Advanced options or with main menu → *Show tray icon*. -- `LOADING` is real: engines that synthesize first (Piper, Kokoro, espeak-ng) switch to `SPEAKING` when the player starts, or when `koko` reports *Streaming audio* -- `ERROR` carries a translated message and an action (`voice-manager` or `retry`); it is never reset to idle behind the user's back -- A monotonic request generation discards stale audio from an interrupted request -- State listeners always run on the GTK main thread; stale notifications from workers are dropped +| Action | Idle | While reading | +| --- | --- | --- | +| Left click | Read the selected text | Stop | +| Right-click menu | Read text · Settings · Quit | Pause / Play · Stop · Settings · Quit | +| Icon | Static | Pulses (opacity only). Dims while paused. Stays steady when reduced motion is on | +| Tooltip | *Select text and press Alt+V to hear it* | *Playing…* / *Paused* | ---- +With the tray icon on, closing the window hides it to the tray. **Pause** freezes the player in place and **Play** continues from the same point. -## Installation +The **MPRIS** player (`org.mpris.MediaPlayer2.biglinux-tts`) shows a system mini-player with Play/Pause, Stop and progress while a reading plays. It responds to media keys, and Play when idle reads the last text again. You can turn it off in Advanced options → *Media player*. -### BigLinux / Manjaro / Arch Linux +Notifications show what is being read, and give a clear message when something fails or nothing is selected. -On BigLinux the package is in the BigLinux repositories: +### Command line -```bash -sudo pacman -S tts-biglinux -``` +| Command | What it does | +| --- | --- | +| `biglinux-tts` | Open the window, or bring the running one forward | +| `biglinux-tts --speak` | Read or stop the selected text (the shortcut action) | +| `biglinux-tts-speak` | Same as `biglinux-tts --speak`; this is the command bound to the shortcut | +| `biglinux-tts --debug` | Detailed logging on the terminal | +| `biglinux-tts --version` | Print the version | -On Manjaro or Arch Linux, build it from the PKGBUILD — every dependency is in the official repositories (`core`, `extra`), no BigLinux or Big Community repository is needed: +The app runs as a single instance (GApplication `br.com.biglinux.tts`). A second command is passed to the instance that is already running. After the last reading, the app stays alive for a few seconds, so a quick second Alt+V reuses it. -```bash -git clone https://github.com/biglinux/tts-biglinux.git -cd tts-biglinux/pkgbuild -makepkg -si -``` +### Playback modes -This installs the application, the native engine, RHVoice with the Brazilian Portuguese voice *Letícia*, espeak-ng (voices for 100+ languages), the tray icon (PySide6), the History player (GStreamer) and the clipboard tools — everything needed to select text and press Alt+V. +You set this in **Advanced options → Audio playback mode**: -Optional voices: +| Mode | A new reading while one is playing… | +| --- | --- | +| **Interrupt previous** (default) | replaces it. The shortcut works as a toggle and stops a reading in progress | +| **Queue** | waits and plays when the current one ends. Stop also clears the queue | +| **Simultaneous** | plays alongside it. Stop and Pause apply to every reading | -```bash -# English RHVoice voice (official repositories) -sudo pacman -S rhvoice-voice-evgeniy-eng +### History -# BigLinux repositories only: -sudo pacman -S biglinux-kokoro-tts # Kokoro neural voices -sudo pacman -S piper-voices-pt-br # Brazilian Portuguese Piper voices (stable) -sudo pacman -S piper-voices-pt-BR # the same voices, as named in biglinux-testing -``` +Open it from the main menu → **History** (Ctrl+H). Saving is on for new installations. Settings from earlier versions keep their own choice, and History is never switched on silently for an existing user. -Piper voices can also be downloaded in the Voice Manager. +- **What is kept** for each reading that was actually heard: the original text, the processed text, engine, voice, date and time, duration, outcome (*completed*, *stopped* by you, or *error* after sound started), and the audio. +- **The audio is what was played**, not a second synthesis. A stopped reading keeps the audio up to the point where you stopped it. -The Piper voice packages are spelled differently in BigLinux stable -(`piper-voices-pt-br`) and testing (`piper-voices-pt-BR`); both ship the same -files, so only one can be installed. When Piper is selected, the app looks -for both spellings and never offers the second one if either is installed. -Only when neither is in the repositories does it fall back to the AUR. It -builds the AUR package as you, never as root, with `pamac build` when pamac's -AUR support is on, or with `paru`/`yay`, and it says so before installing. + | Engine | How the audio is captured | + |---|---| + | RHVoice | `RHVoice-test` writes WAV to stdout. The app sends it to `aplay` and writes the same bytes to the history file | + | espeak-ng, Piper | Each WAV chunk is appended to the history file when it starts playing. A long text gives one file for the whole reading | + | Kokoro | `koko pipe -o ` writes everything it plays straight into the history file | -### Uninstall +- **The page** shows cards with the text (expandable), engine, voice, date, duration and outcome, a player, and actions: copy, *Read again with the current voice*, *Save audio as…*, open the folder, delete. You can search (text, voice, engine, date), filter (all dates, today, yesterday, last 7 days, engine), sort newest or oldest first, switch between list and grid, and select several entries to save them to a folder or delete them. New readings appear on their own, and entries load in pages as you scroll. +- **Deleting is safe.** A single entry can be undone with *Undo*, a selection asks for confirmation first, and the files go to the **Trash**. Files you restore from the Trash come back as entries on the next start. +- **Recovery runs at every start**, outside the main thread. A damaged index is set aside as `history.db.damaged-…` and rebuilt from the files. Files without an entry come back, a recording interrupted by a crash is finished and kept, and a legacy `history.json` is migrated once. Recovery never deletes anything that was saved. +- **Limits** are set in *Keep at most* (100, 500, 1000, 5000 or unlimited). Saving runs in a background thread: it never delays the start of speech, and a failure such as a full disk or a missing permission never stops a reading. -```bash -sudo pacman -R tts-biglinux -``` +### Voice Manager -Personal data stays in your home folder (settings, history, downloaded voices — see [File Locations](#file-locations)); delete those folders to remove it too. +- Install and remove voices for RHVoice, Piper and espeak-ng (pacman packages through `pkexec`) and Kokoro (downloaded voice files). +- Before installing, the app checks the package with `pacman -Si`. It only ever runs `pkexec pacman -S --needed`, never `-Sy`, because refreshing the databases without upgrading would be a partial upgrade. +- Each voice has one button to play and stop a sample. You can search by name or language, and the size is shown before you install. +- Kokoro downloads show real progress and have a **Cancel** button. Downloads are verified (incomplete or damaged files are rejected) and free space is checked first. `voices.bin` is updated atomically. +- Removing a voice asks for confirmation. -### Run without Installing (Development) +### Keyboard shortcuts in the window -```bash -git clone https://github.com/biglinux/tts-biglinux.git -cd tts-biglinux +| Shortcut | Action | +| --- | --- | +| Alt+V (global, configurable) | Read / stop the selected text | +| Ctrl+H | Open History | +| Esc / Alt+← | Back from History | +| F10 | Open the main menu | +| Ctrl+Q | Quit | -# Build the native Rust engine against the SYSTEM onnxruntime -cd tts-engine -ORT_LIB_LOCATION=/usr/lib ORT_PREFER_DYNAMIC_LINK=1 cargo build --release -cd .. +--- -# Link the .so into the application directory -ln -sf ../../../../tts-engine/target/release/libtts_engine.so \ - usr/share/biglinux/tts-biglinux/tts_engine.so +## Speech engines -# Run -cd usr/share/biglinux/tts-biglinux -python main.py --debug -``` +| | RHVoice | espeak-ng | Piper | Kokoro | +| --- | --- | --- | --- | --- | +| **Kind** | Parametric, multilingual | Formant, very light | Neural (ONNX) | Neural (ONNX) | +| **Default** | **Yes**, voice *Letícia F123* (pt-BR) | | | | +| **Comes with the package** | Yes (`rhvoice`, `rhvoice-voice-leticia-f123`) | Yes (`espeak-ng`) | Engine yes, voices optional | No (`biglinux-kokoro-tts`) | +| **How it runs** | `RHVoice-test` → WAV on stdout → `aplay` | Native FFI to `libespeak-ng`. Fallback: `espeak-ng` command | Native ONNX via the Rust engine. Fallback: `piper-tts` command | `koko pipe` (sentence by sentence) | +| **Languages** | Voices installed (pt-BR, English, others) | 100+ | Voices installed | pt-BR, en-US, en-GB, es, fr, it, hi, ja, zh | +| **Speed** | Yes | Yes | Yes | Yes (speed × expression preset) | +| **Pitch** | Yes | Yes | Shown as *Expressiveness* (noise scale) | No (slider disabled) | +| **Volume** | Yes | Yes | Yes | Only mute (0) | +| **Long texts** | One stream, text through stdin | 600-character chunks, streamed | 600-character chunks, streamed | One sentence per line, long sentences split | -### Dependencies +
+RHVoice (default) -#### Required (installed with the package) +- Installed with the package together with the Brazilian Portuguese voice *Letícia*. It plays as `RHVoice-test | aplay` for reading and for the Voice Manager preview. +- The text goes through stdin, so there is no size limit. +- Voices are found by scanning `/usr/share/RHVoice/voices/` and `/usr/local/share/RHVoice/voices/`. Speech Dispatcher is not queried. +- Settings that still name the removed `speech-dispatcher` engine (dropped in 4.0) are switched to RHVoice. -All from the official Arch/Manjaro repositories. +
-| Package | Why | -|---------|-----| -| `python`, `python-gobject` | Python 3 and PyGObject | -| `gtk4`, `libadwaita>=1:1.5` | Interface (AlertDialog, OverlaySplitView, Breakpoint) | -| `python-numpy` | Kokoro voice download conversion (`.pt` → `.npy`) | -| `polkit` | `pkexec` to install/remove voice packages | -| `onnxruntime` | Piper neural inference (system library, dynamic link; provided by `onnxruntime-cpu` and variants) | -| `espeak-ng` | espeak voices and Piper phonemizer (linked by `tts_engine.so`) | -| `alsa-utils`, `alsa-lib` | `aplay` — every engine plays through it; `libasound` for the native engine | -| `sox` | Volume control on the subprocess fallback paths | -| `gstreamer`, `gst-plugins-base`, `gst-plugins-good` | History player (`playbin`, `wavparse`, audio sink) | -| `rhvoice`, `rhvoice-voice-leticia-f123` | Default engine and voice, ready out of the box | -| `speech-dispatcher` | Required by RHVoice's package (the app itself never starts it) | -| `wl-clipboard`, `xsel` | Selected text on Wayland (`wl-paste`; `wl-clipboard-rs` also provides it) and X11 | -| `pyside6`, `qt6-svg` | System tray icon (SVG icons) | - -#### Build and check +
+espeak-ng -| Package | Why | -|---------|-----| -| `git`, `rust>=1.85`, `cargo` | Fetch sources, build `tts_engine.so` (also needs `pkgconf` from `base-devel`) | -| `python-pytest` | `check()` runs the test suite | +- Calls `libespeak-ng.so` directly from the Rust engine, with no synthesis subprocess. It runs in `AUDIO_OUTPUT_SYNCHRONOUS` mode, so espeak-ng renders PCM and never opens the audio device itself. espeak-ng is also Piper's phonemizer. +- It is initialized once (`OnceLock`) and all calls are serialized. +- Texts longer than 600 characters are streamed in sentence chunks. Sound starts right away, memory stays bounded, and Stop takes effect between chunks. +- Without the native engine, the `espeak-ng` command is used. A hung process is stopped after 120 s of work (time spent paused does not count). +- Voices come from `espeak-ng --voices`. -#### Optional +
-| Package | Repository | Why | -|---------|------------|-----| -| `rhvoice-voice-evgeniy-eng` | official | RHVoice English voice | -| `xclip` | official | X11 selection capture when `xsel` is missing | -| `biglinux-kokoro-tts` | BigLinux | Kokoro neural voices (`koko`, model, base voices) | -| `piper-voices-pt-br` | BigLinux | Brazilian Portuguese Piper voices, preinstalled (`piper-voices-pt-BR` in testing) | -| `piper-tts-bin` | BigLinux | Piper command-line fallback (the native engine needs only voices) | -| `rhvoice-brazilian-portuguese-complementary-dict-biglinux` | BigLinux | Extra pronunciation dictionary for RHVoice | +
+Piper ---- +- **Pipeline:** text → espeak-ng IPA phonemes (FFI) → phoneme IDs → ONNX model (`ort` crate, system ONNX Runtime, dynamic link) → WAV → `aplay`. +- The model is loaded once and cached. The selected voice is prewarmed in the background after startup, with no audio, so the first Alt+V starts faster. +- On long texts, the next chunk is synthesized while the current one plays. +- If the native engine is missing or fails, the `piper-tts` command (`piper-tts-bin`) is used, and its output is played through sox `play` so the volume still applies. +- Voices (`.onnx` + `.onnx.json`) are found in `/usr/share/piper-voices/`, `/usr/local/share/piper-voices/` and `~/.local/share/piper-voices/`. -## Usage +
-### GUI +
+Kokoro -```bash -biglinux-tts # Open the window (or bring the running one forward) -biglinux-tts --debug # Detailed logging -biglinux-tts --version # Print the version -``` +- Runs the `koko` binary from `biglinux-kokoro-tts` (model in `/usr/share/biglinux-kokoro-tts/model/`, base voices in `/usr/share/biglinux-kokoro-tts/voices/voices.bin`). +- One builder (`kokoro_voice_service.build_koko_command`) creates the command for both reading and the Voice Manager preview. It passes an explicit model, the voices file, the language from the voice prefix (`pf_`/`pm_` → pt-br, `af_` → en-us, …), and a private writable work directory (`$XDG_RUNTIME_DIR/biglinux-tts/koko`). koko fails with *Permission denied* when it runs in the read-only install folder. +- It uses `koko pipe -o `: one sentence per line, played as soon as it is synthesized, with everything written to a WAV file (the history recording). `koko stream` is not used because it mixes log lines into the audio. +- The card shows *Loading voice* until koko reports *Streaming audio*. A crash of koko is reported as an error. +- The selected voice is checked against `voices.bin` before speaking, because koko would otherwise use another voice without saying so. +- **Voice blending** mixes a second voice (`voice.W+voice.W`). **Expression presets** (neutral, happy, calm, urgent, narrative) adjust the speed. +- **Pitch** is disabled, because koko has no such option. **Volume** only honors mute (0); use the system volume for the level. +- You can download more voices in the Voice Manager. They are stored in `~/.local/share/biglinux-tts/kokoro-voices/voices.bin`, together with the base voices. -### Keyboard Shortcut (CLI) +
-```bash -biglinux-tts-speak # What Alt+V runs: biglinux-tts --speak -``` +All engines are scanned in parallel in a background thread. The result is a voice catalog, plus each engine's status (installed, has voices) as shown under *Engine status*. -`--speak` is forwarded to the running instance (GApplication) or starts one: +--- -1. Reading or loading a voice → stop (in the default *interrupt* playback mode) -2. Text selected → read it with the configured engine and voice -3. Nothing selected → a short notification says so +## Text processing -### Typical Workflow +`services/text_processor.py` prepares the text before synthesis. The rules follow the **language of the selected voice**, so an English voice on a Portuguese desktop gets English rules. The options apply to every engine. -1. **First launch**: the welcome dialog explains features and setup -2. **Configure**: choose the engine and voice, adjust speed/pitch/volume -3. **Test**: type text in the test field and press *Test voice* (or Enter) -4. **Daily use**: select text anywhere → Alt+V → listen +| Option | What it does | Example | +| --- | --- | --- | +| Expand abbreviations | Expands slang and titles (pt, en, es). Never applied inside e-mail addresses, URLs or hyphenated words | `vc` → "você", `Dr. Silva` → "doutor Silva", `w/o` → "without" | +| Read special characters | Reads symbols by name (pt, en, es). When off, the symbols that engines would read aloud are removed | `#` → "cerquilha" / "hash" | +| Remove formatting | Removes HTML tags and Markdown markers. Headings keep a pause and numbered lists keep their numbers | `**bold**` → "bold" | +| Read URLs | On: reads only the domain. Off: removes the URL | `https://www.example.com/a?b=1` → "example.com" | +| Character limit | Unlimited, 1K, 5K, 10K, 50K or 100K, cut at a sentence or word boundary | | +| Number normalization | Portuguese voices: numbers, money, percentages, dates and times as words. On by default, with no switch in the window (settings key `text.normalize_numbers`) | `R$ 1.234,56`, `08/10/2026`, `21:00` → "vinte e uma horas" | + +
+Pipeline order + +1. **Character limit:** cut at a sentence or word boundary. +2. **Strip formatting:** only real HTML tags are removed (`x < 5 e y > 3` stays), plus Markdown bold, italic, code, links, headings and bullets. HTML entities are always decoded. +3. **URLs:** replaced by the domain, or removed together with the space before them. +4. **Numbers** (pt): numbers, money, percentages, dates and times (feminine hours, seconds). This step runs before special characters, so `%` and `R$` become words. +5. **Abbreviations:** one precompiled pattern per language, and titles drop their period. With the option off, slang is spelled out so RHVoice does not expand it on its own (real words are left alone). +6. **Special characters:** read by name, or the symbols espeak-ng-based engines would read are removed. +7. **Cleanup:** spaces are collapsed and paragraph breaks are kept as pauses. + +
--- -## Configuration - -### File Locations +## Configuration and files | Path | Content | -|------|---------| -| `~/.config/biglinux-tts/settings.json` | All app settings (JSON) | -| `~/Music/tts-biglinux/` (XDG Music folder) | Reading history: `