From 200faa9e36e78e98475c3b4e40dd1db6ebb41839 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Mon, 22 Dec 2025 19:43:41 -0500 Subject: [PATCH] Speech history plugin added. Code and documentation audit completed. Preparing for tagged release. --- AGENTS.md | 56 +++ CLAUDE.md | 89 ++-- README-DEVELOPMENT.md | 9 +- README.md | 2 +- RELEASE-HOWTO | 99 ++--- ci/build_and_install.sh | 10 +- meson.build | 4 +- pyproject.toml | 2 +- src/cthulhu/meson.build | 3 +- .../SpeechHistory.disabled/__init__.py | 1 - .../SpeechHistory.disabled/plugin.info | 8 - .../plugins/SpeechHistory.disabled/plugin.py | 235 ---------- src/cthulhu/plugins/SpeechHistory/__init__.py | 2 + .../meson.build | 3 +- src/cthulhu/plugins/SpeechHistory/plugin.info | 9 + src/cthulhu/plugins/SpeechHistory/plugin.py | 406 ++++++++++++++++++ src/cthulhu/plugins/meson.build | 3 +- src/cthulhu/speech.py | 24 +- src/cthulhu/speech_history.py | 175 ++++++++ 19 files changed, 773 insertions(+), 367 deletions(-) create mode 100644 AGENTS.md delete mode 100644 src/cthulhu/plugins/SpeechHistory.disabled/__init__.py delete mode 100644 src/cthulhu/plugins/SpeechHistory.disabled/plugin.info delete mode 100644 src/cthulhu/plugins/SpeechHistory.disabled/plugin.py create mode 100644 src/cthulhu/plugins/SpeechHistory/__init__.py rename src/cthulhu/plugins/{SpeechHistory.disabled => SpeechHistory}/meson.build (98%) create mode 100644 src/cthulhu/plugins/SpeechHistory/plugin.info create mode 100644 src/cthulhu/plugins/SpeechHistory/plugin.py create mode 100644 src/cthulhu/speech_history.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..08afdb0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md (Codex CLI guidance) + +This repository is a screen reader. Prioritize accessibility, correctness, and stability over “clever” changes. + +## System interactions +- If a command requires `sudo`, stop and ask the user to run it (no password entry is possible from here). + +## Build / run quick refs +- Local dev build + install: `./build-local.sh` +- Quick local sanity checks: `./test-local.sh` +- Clean local artifacts/install: `./clean-local.sh` +- Meson (manual): + - `meson setup _build --prefix=$HOME/.local` + - `meson compile -C _build` + - `meson install -C _build` + +## Coding guidelines +- **When modifying existing code:** follow the surrounding code’s conventions. +- **When writing new code from scratch:** prefer + - variables: `camelCase` + - functions/methods: `snake_case` + - classes: `PascalCase` +- Add debug logs where helpful for troubleshooting. When adding timestamps to logs, use: `"Message [timestamp]"` (message first). + +## Accessibility requirements (high priority) +### General +- Screen-reader-first UX: assume non-visual navigation. +- No keyboard traps; Tab/Shift+Tab must move through all controls. +- Use clear, complete labels (e.g., “Confirm Password”, not “Confirm”). +- **No Speech-Dispatcher usage in GUI apps** (no `spd-say`); rely on the accessibility API. + +### Python GTK (Gtk 3) +- Associate labels with controls (mnemonics + buddy widget): + - `label.set_use_underline(True)` + - `label.set_mnemonic_widget(entry)` +- For `Gtk.TextView`, call `set_accepts_tab(False)` so Tab moves focus. +- For custom widgets, set accessible name/role via ATK where applicable. + +### PySide6 / Qt +- Set `setAccessibleName()` on all widgets. +- Associate labels via `setBuddy()`. +- Use `setAccessibleDescription()` when extra context is needed. + +## Shell script rules +- **Bash variables must be `camelCase`** (except system env vars like `ACCESSIBILITY_ENABLED`). +- If you edit a `#!/usr/bin/env bash`, `#!/bin/bash`, or POSIX `sh` script, run `shellcheck` and fix issues. +- Avoid colorized output unless explicitly requested (accessibility-first; keep scripts simple). + +## Plugins (Cthulhu) +- System plugins live in `src/cthulhu/plugins/`. +- Follow existing plugin patterns (keybinding registration, GTK dialog/window patterns, debug logging). +- Ensure plugin install integration is wired via Meson (`src/cthulhu/plugins/meson.build` + plugin subdir `meson.build`). + +## Meson install reminder (important) +- If you add new Python modules under `src/cthulhu/`, update `src/cthulhu/meson.build` so they get installed (otherwise imports can fail after install). +- If you add a new plugin directory, update `src/cthulhu/plugins/meson.build` and add a `meson.build` in the plugin directory. diff --git a/CLAUDE.md b/CLAUDE.md index 3ef1d12..96f5ecf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,15 +23,12 @@ Cthulhu is a fork of the Orca screen reader, providing access to the graphical d ./clean-local.sh ``` -### System Build (Autotools) +### System Build (Meson) ```bash # Configure and build for system installation -./autogen.sh --prefix=/usr -make -make install - -# Or use CI script -ci/build_and_install.sh +meson setup _build --prefix=/usr +meson compile -C _build +sudo meson install -C _build ``` ### Alternative Build (Python packaging) @@ -82,7 +79,7 @@ src/cthulhu/plugins/MyPlugin/ ├── __init__.py # Package import: from .plugin import MyPlugin ├── plugin.py # Main implementation ├── plugin.info # Metadata -└── Makefile.am # Build system integration +└── meson.build # Meson install integration ``` #### Minimal Plugin Template @@ -177,22 +174,30 @@ Version = 1.0.0 Website = https://example.com ``` -**Makefile.am template:** -```makefile -pluginname_PYTHON = \ - __init__.py \ - plugin.py +**meson.build template:** +```meson +plugin_python_sources = files([ + '__init__.py', + 'plugin.py', +]) -pluginnamedir = $(pkgdatadir)/cthulhu/plugins/PluginName +python3.install_sources( + plugin_python_sources, + subdir: 'cthulhu/plugins/PluginName' +) -pluginname_DATA = \ - plugin.info - -EXTRA_DIST = $(pluginname_DATA) +install_data( + 'plugin.info', + install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'PluginName' +) ``` **Build Integration:** -Add plugin to `src/cthulhu/plugins/Makefile.am` SUBDIRS line. +Add plugin to `src/cthulhu/plugins/meson.build`: + +```meson +subdir('PluginName') +``` #### Advanced Plugin Features @@ -220,12 +225,12 @@ Add plugin to `src/cthulhu/plugins/Makefile.am` SUBDIRS line. - Community: IRC #stormux on irc.stormux.org ### Key Dependencies -- Python 3.3+, pygobject-3.0, pluggy, gtk+-3.0 +- Python 3.10+, pygobject-3.0, pluggy, gtk+-3.0 - AT-SPI2, ATK for accessibility - Optional: BrlTTY/BrlAPI (braille), Speech Dispatcher, liblouis, GStreamer ### Version Information -Current version in `src/cthulhu/cthulhuVersion.py`, codename "plugins" +Current version and codename in `src/cthulhu/cthulhuVersion.py` ### Self-voicing Feature Direct speech output via Unix socket: @@ -263,9 +268,8 @@ The test system uses keystroke recording/playback with speech and braille output ### **Major Architectural Differences** #### **Build Systems** -- **Cthulhu**: Autotools (102 Makefile.am files) - mature, stable build system -- **Orca**: Meson/Ninja (33 meson.build files, 84 legacy makefiles) - modern, faster builds -- **Integration Consideration**: Should Cthulhu migrate to Meson for faster builds and better dependencies? +- **Cthulhu**: Meson/Ninja (primary build system) +- **Orca**: Meson/Ninja #### **Plugin Architecture** - **Cthulhu**: Extensive pluggy-based plugin system with 9 core plugins @@ -492,7 +496,7 @@ src/cthulhu/plugins/YourPlugin/ ├── __init__.py # Import: from .plugin import YourPlugin ├── plugin.py # Main plugin class ├── plugin.info # Metadata (name, version, description) -└── Makefile.am # Build system integration +└── meson.build # Meson install integration ``` ### **Essential Plugin Files** @@ -514,14 +518,22 @@ builtin = false hidden = false ``` -#### **`Makefile.am`** - Build Integration -```makefile -cthulhu_python_PYTHON = \ - __init__.py \ - plugin.info \ - plugin.py +#### **`meson.build`** - Build Integration +```meson +yourplugin_python_sources = files([ + '__init__.py', + 'plugin.py', +]) -cthulhu_pythondir=$(pkgpythondir)/plugins/YourPlugin +python3.install_sources( + yourplugin_python_sources, + subdir: 'cthulhu/plugins/YourPlugin' +) + +install_data( + 'plugin.info', + install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'YourPlugin' +) ``` ### **Plugin Class Template** @@ -645,14 +657,9 @@ self.registerGestureByString( ### **Plugin Registration & Activation** #### **Add to Build System** -1. **Add to `src/cthulhu/plugins/Makefile.am`**: - ```makefile - SUBDIRS = YourPlugin OtherPlugin1 OtherPlugin2 ... - ``` - -2. **Add to `configure.ac`**: - ``` - src/cthulhu/plugins/YourPlugin/Makefile +1. **Add to `src/cthulhu/plugins/meson.build`**: + ```meson + subdir('YourPlugin') ``` #### **Add to Default Active Plugins** @@ -835,4 +842,4 @@ cd /home/storm/devel/orca && meson setup _build && meson compile -C _build # Test D-Bus interface # (requires running Orca instance with D-Bus support) -``` \ No newline at end of file +``` diff --git a/README-DEVELOPMENT.md b/README-DEVELOPMENT.md index 40670c1..0025013 100644 --- a/README-DEVELOPMENT.md +++ b/README-DEVELOPMENT.md @@ -11,7 +11,8 @@ To develop Cthulhu without overwriting your system installation, use the provide ./build-local.sh # Clean build and rebuild everything -./build-local.sh --clean +./clean-local.sh --build-only +./build-local.sh ``` This installs Cthulhu to `~/.local/bin/cthulhu` without touching your system installation. @@ -98,10 +99,10 @@ git status ## Dependencies - **Runtime**: python3, pygobject-3.0, pluggy, AT-SPI2 -- **Build**: autotools, gettext, intltool +- **Build**: meson, ninja, gettext - **Optional**: dasbus (for D-Bus service), BrlTTY, speech-dispatcher Install build dependencies on Arch Linux: ```bash -sudo pacman -S autoconf automake intltool gettext python-dasbus -``` \ No newline at end of file +sudo pacman -S meson ninja gettext python-dasbus +``` diff --git a/README.md b/README.md index e76ff2a..883a3af 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ toolkit, OpenOffice/LibreOffice, Gecko, WebKitGtk, and KDE Qt toolkit. ### Core Requirements -* **Python 3.3+** - Python platform +* **Python 3.10+** - Python platform * **pygobject-3.0** - Python bindings for the GObject library * **gtk+-3.0** - GTK+ toolkit (minimal usage for AT-SPI integration) * **AT-SPI2** - Assistive Technology Service Provider Interface diff --git a/RELEASE-HOWTO b/RELEASE-HOWTO index 1921273..f12a39f 100644 --- a/RELEASE-HOWTO +++ b/RELEASE-HOWTO @@ -1,84 +1,55 @@ -This document provides a step-by-step list to remind Cthulhu -maintainers how to make a release. +This document provides a step-by-step reminder for making a Cthulhu release. -The general instructions for a release are here: +PREPARE SOURCES: +---------------- - https://wiki.gnome.org/MaintainersCorner/Releasing - -See also: - - https://discourse.gnome.org/t/new-gnome-versioning-scheme/4235 - -Here's a summary for Cthulhu: - -PREPARE SOURCES FOR THE RELEASE: -------------------------------- - -Make sure you are up to date: +Make sure you are up to date and clean: git pull git status -Update ./NEWS with changes from the last tagged release. You can use -commands like the following: +Decide the release version: -Detailed commits since the CTHULHU_40_BETA tag: + - Update the version in `meson.build` + - Update the version in `src/cthulhu/cthulhuVersion.py` - git log CTHULHU_40_BETA.. +Update release notes: -Short list of translation changes with author names and files: - - git log CTHULHU_40_BETA.. --grep translation --pretty=format:"%s - %an" --name-only - -Quick-and-dirty formatted list of translation changes: - - git log CTHULHU_40_BETA.. --grep translation --pretty=format:"%s,%an" --name-only | - awk -F/ '/\.(po|am)/ {gsub("(\.po|Makefile.am)", "", $NF); printf(",%s",$NF); next;} - {gsub("(Updated* |Add(ed)* | translation| help)", "", $0); printf("\n%s",$0);}' | - awk -F, '!seen[$0]++ {if (NF == 3) printf(" %-15s %-25s %s\n", $3, $1, $2);}' | - sort - -Short list of non-translation commits: - - git log CTHULHU_40_BETA.. --grep translation --invert-grep --pretty=format:" * %s%n" - -NOTE: You should also make sure the external dependencies listed in -configure.ac and README are accurate. + - Update `NEWS` (or your preferred changelog location) with user-visible changes. -BUILD THE RELEASE: ------------------ +BUILD + SANITY CHECK: +--------------------- -./autogen.sh --prefix=`pwd`/bld && make && make install && make distcheck +Build with Meson: -COMMIT RELEASE CHANGES AND TAG THE RELEASE: -------------------------------------------- + meson setup _build_release --prefix=/usr --buildtype=release + meson compile -C _build_release -git commit -a -git push -git tag -a -s CTHULHU_40_RC -git push origin CTHULHU_40_RC +Optional sanity checks: + + python3 -m compileall -q src/cthulhu + meson test -C _build_release # may be empty + +Create a source tarball: + + meson dist -C _build_release -UPLOAD THE RELEASE: ------------------- +TAG + PUSH: +----------- -scp cthulhu-40.rc.tar.xz yourusername@master.gnome.org: -ssh master.gnome.org -ftpadmin install cthulhu-40.rc.tar.xz +Commit release changes, tag, and push: + + git commit -a + git push + git tag -a v + git push origin v -BUMP THE VERSION: ------------------ +PACKAGING NOTES: +---------------- -Modify this line in ./configure.ac: - - m4_define([cthulhu_version], [40.rc]) - -The major version (40) increments by 1 each new GNOME release cycle. -The minor version proceeds as follows: alpha, beta, rc, 0, 1, 2, 3, etc. - -Modify ./README.md to make sure it has the right Cthulhu version. - -git commit -a -git push +Arch packaging lives in `distro-packages/Arch-Linux/PKGBUILD`. +If the package version is derived from `src/cthulhu/cthulhuVersion.py`, +ensure that file matches the release version. diff --git a/ci/build_and_install.sh b/ci/build_and_install.sh index 257992d..467b0cc 100644 --- a/ci/build_and_install.sh +++ b/ci/build_and_install.sh @@ -1,9 +1,7 @@ #!/bin/sh -set -eux -o pipefail +set -eux -mkdir -p _build -cd _build -../autogen.sh --prefix=/usr -make -make install +meson setup _build --prefix=/usr --buildtype=debugoptimized +meson compile -C _build +meson install -C _build diff --git a/meson.build b/meson.build index b025b96..c3aeef7 100644 --- a/meson.build +++ b/meson.build @@ -6,7 +6,7 @@ project('cthulhu', python = import('python') i18n = import('i18n') -python_minimum_version = '3.3' +python_minimum_version = '3.10' python3 = python.find_installation('python3', required: true) if not python3.language_version().version_compare(f'>= @python_minimum_version@') error(f'Python @python_minimum_version@ or newer is required.') @@ -121,4 +121,4 @@ subdir('icons') subdir('po') subdir('src') -summary(summary) \ No newline at end of file +summary(summary) diff --git a/pyproject.toml b/pyproject.toml index af2f579..dd30b4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "cthulhu" dynamic = ["version"] description = "Fork of the Orca screen reader based on gnome-45" readme = "README.md" -requires-python = ">=3.3" +requires-python = ">=3.10" license = { text = "LGPL-2.1-or-later" } dependencies = [ "pygobject>=3.18", diff --git a/src/cthulhu/meson.build b/src/cthulhu/meson.build index 64dff3b..52296f4 100644 --- a/src/cthulhu/meson.build +++ b/src/cthulhu/meson.build @@ -75,6 +75,7 @@ cthulhu_python_sources = files([ 'sound.py', 'sound_generator.py', 'speech_and_verbosity_manager.py', + 'speech_history.py', 'speech.py', 'spellcheck.py', 'speechdispatcherfactory.py', @@ -154,4 +155,4 @@ install_data( # Subdirectories subdir('backends') subdir('scripts') -subdir('plugins') \ No newline at end of file +subdir('plugins') diff --git a/src/cthulhu/plugins/SpeechHistory.disabled/__init__.py b/src/cthulhu/plugins/SpeechHistory.disabled/__init__.py deleted file mode 100644 index d3ff8fe..0000000 --- a/src/cthulhu/plugins/SpeechHistory.disabled/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .plugin import SpeechHistory \ No newline at end of file diff --git a/src/cthulhu/plugins/SpeechHistory.disabled/plugin.info b/src/cthulhu/plugins/SpeechHistory.disabled/plugin.info deleted file mode 100644 index 92f1a40..0000000 --- a/src/cthulhu/plugins/SpeechHistory.disabled/plugin.info +++ /dev/null @@ -1,8 +0,0 @@ -name = Speech History -version = 1.0.0 -description = Keeps a history of all speech output with navigation and clipboard support -authors = Cthulhu Plugin System -website = https://git.stormux.org/storm/cthulhu -copyright = Copyright 2024 Stormux -builtin = true -hidden = false \ No newline at end of file diff --git a/src/cthulhu/plugins/SpeechHistory.disabled/plugin.py b/src/cthulhu/plugins/SpeechHistory.disabled/plugin.py deleted file mode 100644 index 2227f61..0000000 --- a/src/cthulhu/plugins/SpeechHistory.disabled/plugin.py +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env python3 - -import logging -from collections import deque -from cthulhu.plugin import Plugin, cthulhu_hookimpl -from cthulhu import settings_manager -from cthulhu import debug - -logger = logging.getLogger(__name__) - -class SpeechHistory(Plugin): - """Speech History plugin - SAFE manual-only version (no automatic capture).""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - debug.printMessage(debug.LEVEL_INFO, "SpeechHistory SAFE plugin initialized", True) - - # History storage - start with some sample items - self._max_history_size = 50 - self._history = deque([ - "Welcome to safe speech history", - "This version doesn't auto-capture to prevent crashes", - "Use add_to_history() method to manually add items", - "Navigate with Cthulhu+Control+Shift+H (previous)", - "Navigate with Cthulhu+Control+H (next)", - "Copy with Cthulhu+Control+Y" - ], maxlen=self._max_history_size) - self._current_history_index = -1 - - # Keybinding storage - self._kb_nav_prev = None - self._kb_nav_next = None - self._kb_copy_last = None - - # Settings integration - self._settings_manager = settings_manager.getManager() - - @cthulhu_hookimpl - def activate(self, plugin=None): - """Activate the plugin.""" - if plugin is not None and plugin is not self: - return - - try: - debug.printMessage(debug.LEVEL_INFO, "=== SpeechHistory SAFE activation starting ===", True) - - # Load settings - self._load_settings() - - # Register keybindings only - NO speech capture - self._register_keybindings() - - debug.printMessage(debug.LEVEL_INFO, "SpeechHistory SAFE plugin activated successfully", True) - return True - - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"Error activating SpeechHistory SAFE: {e}", True) - return False - - @cthulhu_hookimpl - def deactivate(self, plugin=None): - """Deactivate the plugin.""" - if plugin is not None and plugin is not self: - return - - debug.printMessage(debug.LEVEL_INFO, "Deactivating SpeechHistory SAFE plugin", True) - - # Clear keybindings - self._kb_nav_prev = None - self._kb_nav_next = None - self._kb_copy_last = None - - return True - - def _load_settings(self): - """Load plugin settings.""" - try: - self._max_history_size = self._settings_manager.getSetting('speechHistorySize') or 50 - # Update deque maxlen if needed - if self._history.maxlen != self._max_history_size: - old_history = list(self._history) - self._history = deque(old_history[-self._max_history_size:], maxlen=self._max_history_size) - debug.printMessage(debug.LEVEL_INFO, f"Speech history size: {self._max_history_size}", True) - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"Error loading settings: {e}", True) - self._max_history_size = 50 - - def _register_keybindings(self): - """Register plugin keybindings.""" - try: - # Cthulhu+Control+Shift+H (History previous) - self._kb_nav_prev = self.registerGestureByString( - self._navigate_history_prev, - "Speech history previous", - 'kb:cthulhu+control+shift+h' - ) - - # Cthulhu+Control+H (History next) - self._kb_nav_next = self.registerGestureByString( - self._navigate_history_next, - "Speech history next", - 'kb:cthulhu+control+h' - ) - - # Cthulhu+Control+Y (Copy history) - self._kb_copy_last = self.registerGestureByString( - self._copy_last_spoken, - "Copy speech history item to clipboard", - 'kb:cthulhu+control+y' - ) - - debug.printMessage(debug.LEVEL_INFO, f"Registered keybindings: {bool(self._kb_nav_prev)}, {bool(self._kb_nav_next)}, {bool(self._kb_copy_last)}", True) - - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"Error registering keybindings: {e}", True) - - def _navigate_history_prev(self, script=None, inputEvent=None): - """Navigate to previous item in speech history.""" - try: - if not self._history: - self._present_message("Speech history is empty") - return True - - # Move backward in history (to older items) - if self._current_history_index == -1: - self._current_history_index = len(self._history) - 1 - elif self._current_history_index > 0: - self._current_history_index -= 1 - else: - self._current_history_index = len(self._history) - 1 - - # Present the history item - history_item = self._history[self._current_history_index] - position = self._current_history_index + 1 - self._present_message(f"History {position} of {len(self._history)}: {history_item}") - - return True - - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"Error navigating to previous: {e}", True) - return False - - def _navigate_history_next(self, script=None, inputEvent=None): - """Navigate to next item in speech history.""" - try: - if not self._history: - self._present_message("Speech history is empty") - return True - - # Move forward in history (to newer items) - if self._current_history_index == -1: - self._current_history_index = 0 - elif self._current_history_index < len(self._history) - 1: - self._current_history_index += 1 - else: - self._current_history_index = 0 - - # Present the history item - history_item = self._history[self._current_history_index] - position = self._current_history_index + 1 - self._present_message(f"History {position} of {len(self._history)}: {history_item}") - - return True - - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"Error navigating to next: {e}", True) - return False - - def _copy_last_spoken(self, script=None, inputEvent=None): - """Copy the last spoken text to clipboard.""" - try: - if not self._history: - self._present_message("No speech history to copy") - return True - - # Copy the most recent speech - last_spoken = self._history[-1] - - try: - import gi - gi.require_version("Gtk", "3.0") - from gi.repository import Gtk, Gdk - - clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) - clipboard.set_text(last_spoken, -1) - clipboard.store() - - # Show confirmation - preview = last_spoken[:50] + ('...' if len(last_spoken) > 50 else '') - self._present_message(f"Copied to clipboard: {preview}") - - except Exception as clipboard_error: - debug.printMessage(debug.LEVEL_INFO, f"Clipboard error: {clipboard_error}", True) - self._present_message("Error copying to clipboard") - - return True - - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"Error copying: {e}", True) - return False - - def _present_message(self, message): - """Present a message to the user via speech.""" - try: - if self.app: - state = self.app.getDynamicApiManager().getAPI('CthulhuState') - if state and state.activeScript: - state.activeScript.presentMessage(message, resetStyles=False) - else: - debug.printMessage(debug.LEVEL_INFO, f"Message: {message}", True) - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"Error presenting message: {e}", True) - - def add_to_history(self, text): - """Public method to safely add items to history.""" - try: - if not text or not text.strip(): - return - - clean_text = text.strip() - if len(clean_text) < 2: - return - - # Simple duplicate prevention - if self._history and self._history[-1] == clean_text: - return - - # Add to history - self._history.append(clean_text) - self._current_history_index = -1 - - debug.printMessage(debug.LEVEL_INFO, f"Manually added to history: {clean_text[:50]}{'...' if len(clean_text) > 50 else ''}", True) - - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"Error adding to history: {e}", True) \ No newline at end of file diff --git a/src/cthulhu/plugins/SpeechHistory/__init__.py b/src/cthulhu/plugins/SpeechHistory/__init__.py new file mode 100644 index 0000000..08c77bb --- /dev/null +++ b/src/cthulhu/plugins/SpeechHistory/__init__.py @@ -0,0 +1,2 @@ +from .plugin import SpeechHistory + diff --git a/src/cthulhu/plugins/SpeechHistory.disabled/meson.build b/src/cthulhu/plugins/SpeechHistory/meson.build similarity index 98% rename from src/cthulhu/plugins/SpeechHistory.disabled/meson.build rename to src/cthulhu/plugins/SpeechHistory/meson.build index 8f5370c..75dd195 100644 --- a/src/cthulhu/plugins/SpeechHistory.disabled/meson.build +++ b/src/cthulhu/plugins/SpeechHistory/meson.build @@ -11,4 +11,5 @@ python3.install_sources( install_data( 'plugin.info', install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'SpeechHistory' -) \ No newline at end of file +) + diff --git a/src/cthulhu/plugins/SpeechHistory/plugin.info b/src/cthulhu/plugins/SpeechHistory/plugin.info new file mode 100644 index 0000000..4ab5ada --- /dev/null +++ b/src/cthulhu/plugins/SpeechHistory/plugin.info @@ -0,0 +1,9 @@ +name = Speech History +version = 1.0.0 +description = Shows a searchable history of the last 50 unique utterances spoken by Cthulhu +authors = Stormux +website = https://git.stormux.org/storm/cthulhu +copyright = Copyright 2025 Stormux +builtin = false +hidden = false + diff --git a/src/cthulhu/plugins/SpeechHistory/plugin.py b/src/cthulhu/plugins/SpeechHistory/plugin.py new file mode 100644 index 0000000..89fc1a7 --- /dev/null +++ b/src/cthulhu/plugins/SpeechHistory/plugin.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2025 Stormux +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. + +"""Speech History plugin for Cthulhu.""" + +import logging + +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + +from cthulhu.plugin import Plugin, cthulhu_hookimpl +from cthulhu import debug +from cthulhu import speech_history + +logger = logging.getLogger(__name__) + + +class SpeechHistory(Plugin): + """Plugin that displays a window containing recent spoken output.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._activated = False + self._kbOpenWindow = None + + self._window = None + self._filterEntry = None + self._filterText = "" + self._listStore = None + self._filterModel = None + self._treeView = None + + self._capturePaused = False + + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Plugin initialized", True) + + @cthulhu_hookimpl + def activate(self, plugin=None): + if plugin is not None and plugin is not self: + return + + if self._activated: + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Already activated, skipping", True) + return True + + try: + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Activating plugin", True) + self._register_keybinding() + self._activated = True + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Activated successfully", True) + return True + except Exception as e: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR during activate: {e}", True) + logger.exception("Error activating SpeechHistory plugin") + return False + + @cthulhu_hookimpl + def deactivate(self, plugin=None): + if plugin is not None and plugin is not self: + return + + try: + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Deactivating plugin", True) + self._close_window() + self._kbOpenWindow = None + self._activated = False + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Deactivated successfully", True) + return True + except Exception as e: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR during deactivate: {e}", True) + logger.exception("Error deactivating SpeechHistory plugin") + return False + + def _register_keybinding(self): + try: + if not self.app: + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: No app reference; cannot register keybinding", True) + return + + gestureString = "kb:cthulhu+control+h" + description = "Open speech history" + + self._kbOpenWindow = self.registerGestureByString( + self._open_window, + description, + gestureString, + ) + + if self._kbOpenWindow: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: Registered keybinding {gestureString}", True) + else: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: Failed to register keybinding {gestureString}", True) + except Exception as e: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR registering keybinding: {e}", True) + logger.exception("Error registering keybinding for SpeechHistory") + + def _open_window(self, script=None, inputEvent=None): + try: + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Open window requested", True) + + if self._window: + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Window already open; presenting", True) + self._window.present() + return True + + self._pause_capture() + + self._create_window() + self._window.show_all() + + if self._filterEntry: + self._filterEntry.grab_focus() + + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Window shown", True) + return True + except Exception as e: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR opening window: {e}", True) + logger.exception("Error opening SpeechHistory window") + self._resume_capture() + return False + + def _create_window(self): + self._window = Gtk.Window(title="Speech History - Cthulhu") + self._window.set_default_size(700, 420) + self._window.set_modal(True) + self._window.set_border_width(10) + + self._window.connect("destroy", self._on_window_destroy) + self._window.connect("key-press-event", self._on_window_key_press) + + mainBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + + # Filter row + filterRow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + filterLabel = Gtk.Label(label="_Filter:") + filterLabel.set_use_underline(True) + filterLabel.set_halign(Gtk.Align.START) + + self._filterEntry = Gtk.Entry() + self._filterEntry.set_hexpand(True) + filterLabel.set_mnemonic_widget(self._filterEntry) + self._filterEntry.connect("changed", self._on_filter_changed) + + filterRow.pack_start(filterLabel, False, False, 0) + filterRow.pack_start(self._filterEntry, True, True, 0) + mainBox.pack_start(filterRow, False, False, 0) + + # List + self._listStore = Gtk.ListStore(int, str) + self._filterModel = self._listStore.filter_new() + self._filterModel.set_visible_func(self._filter_visible_func) + + self._treeView = Gtk.TreeView(model=self._filterModel) + self._treeView.set_headers_visible(True) + + selection = self._treeView.get_selection() + selection.set_mode(Gtk.SelectionMode.SINGLE) + + idxRenderer = Gtk.CellRendererText() + idxColumn = Gtk.TreeViewColumn("Item", idxRenderer, text=0) + idxColumn.set_resizable(False) + idxColumn.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) + self._treeView.append_column(idxColumn) + + textRenderer = Gtk.CellRendererText() + textRenderer.set_property("wrap-width", 640) + textRenderer.set_property("wrap-mode", 2) # Pango.WrapMode.WORD_CHAR + textColumn = Gtk.TreeViewColumn("Spoken Text", textRenderer, text=1) + textColumn.set_resizable(True) + textColumn.set_expand(True) + self._treeView.append_column(textColumn) + + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled.add(self._treeView) + mainBox.pack_start(scrolled, True, True, 0) + + # Buttons + buttonRow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + buttonRow.set_halign(Gtk.Align.END) + + copyButton = Gtk.Button(label="Copy to clipboard") + removeButton = Gtk.Button(label="Remove from history") + closeButton = Gtk.Button(label="Close") + + copyButton.connect("clicked", self._on_copy_clicked) + removeButton.connect("clicked", self._on_remove_clicked) + closeButton.connect("clicked", self._on_close_clicked) + + buttonRow.pack_start(copyButton, False, False, 0) + buttonRow.pack_start(removeButton, False, False, 0) + buttonRow.pack_start(closeButton, False, False, 0) + mainBox.pack_start(buttonRow, False, False, 0) + + self._window.add(mainBox) + + self._refresh_list(selectFirst=True) + + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Window created", True) + + def _on_filter_changed(self, entry): + try: + self._filterText = entry.get_text() or "" + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: Filter changed '{self._filterText}'", True) + if self._filterModel: + self._filterModel.refilter() + except Exception as e: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR filtering: {e}", True) + logger.exception("Error updating speech history filter") + + def _filter_visible_func(self, model, treeIter, data=None): + try: + filterText = (self._filterText or "").strip().lower() + if not filterText: + return True + + spokenText = model[treeIter][1] or "" + return spokenText.lower().startswith(filterText) + except Exception as e: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR in filter func: {e}", True) + return True + + def _refresh_list(self, selectFirst=False): + try: + if not self._listStore: + return + + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Refreshing list", True) + self._listStore.clear() + + items = speech_history.get_items() + for idx, item in enumerate(items, start=1): + self._listStore.append([idx, item]) + + if self._filterModel: + self._filterModel.refilter() + + if selectFirst and self._treeView and len(self._filterModel) > 0: + selection = self._treeView.get_selection() + selection.select_path(Gtk.TreePath.new_first()) + except Exception as e: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR refreshing list: {e}", True) + logger.exception("Error refreshing speech history list") + + def _get_selected_text(self): + try: + if not self._treeView: + return None + + selection = self._treeView.get_selection() + model, treeIter = selection.get_selected() + if not treeIter: + return None + + return model[treeIter][1] + except Exception as e: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR getting selection: {e}", True) + logger.exception("Error getting selected speech history item") + return None + + def _on_copy_clicked(self, button): + try: + selectedText = self._get_selected_text() + if not selectedText: + self._present_message("No speech history item selected.") + return + + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.set_text(selectedText, -1) + clipboard.store() + + preview = selectedText[:60] + ("..." if len(selectedText) > 60 else "") + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: Copied to clipboard '{preview}'", True) + self._present_message("Copied to clipboard.") + except Exception as e: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR copying: {e}", True) + logger.exception("Error copying speech history item to clipboard") + self._present_message("Error copying to clipboard.") + + def _on_remove_clicked(self, button): + try: + selectedText = self._get_selected_text() + if not selectedText: + self._present_message("No speech history item selected.") + return + + removed = speech_history.remove(selectedText) + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: Remove requested removed={removed}", True) + if removed: + self._refresh_list(selectFirst=True) + self._present_message("Removed from history.") + else: + self._present_message("Item not found in history.") + except Exception as e: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR removing: {e}", True) + logger.exception("Error removing speech history item") + self._present_message("Error removing item from history.") + + def _on_close_clicked(self, button): + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Close button clicked", True) + self._close_window() + + def _close_window(self): + try: + if self._window: + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Closing window", True) + self._window.destroy() + except Exception as e: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR closing window: {e}", True) + logger.exception("Error closing SpeechHistory window") + self._resume_capture() + + def _on_window_destroy(self, widget): + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Window destroyed", True) + self._window = None + self._filterEntry = None + self._listStore = None + self._filterModel = None + self._treeView = None + self._filterText = "" + self._resume_capture() + + def _on_window_key_press(self, widget, event): + try: + if event.keyval == Gdk.KEY_Escape: + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Escape pressed; closing window", True) + self._close_window() + return True + + if not self._filterEntry or self._filterEntry.is_focus(): + return False + + # If user starts typing anywhere, move focus to filter and update it. + if event.keyval == Gdk.KEY_BackSpace: + currentText = self._filterEntry.get_text() or "" + if currentText: + self._filterEntry.set_text(currentText[:-1]) + self._filterEntry.set_position(-1) + return True + return False + + modifierMask = ( + Gdk.ModifierType.CONTROL_MASK + | Gdk.ModifierType.MOD1_MASK + | Gdk.ModifierType.SUPER_MASK + | Gdk.ModifierType.META_MASK + ) + if event.state & modifierMask: + return False + + keyUnicode = Gdk.keyval_to_unicode(event.keyval) + if not keyUnicode: + return False + + charTyped = chr(keyUnicode) + if not charTyped.isprintable(): + return False + + self._filterEntry.grab_focus() + currentText = self._filterEntry.get_text() or "" + self._filterEntry.set_text(currentText + charTyped) + self._filterEntry.set_position(-1) + return True + except Exception as e: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR in key handler: {e}", True) + logger.exception("Error handling key press in SpeechHistory window") + return False + + def _pause_capture(self): + if self._capturePaused: + return + + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Pausing capture while window is open", True) + speech_history.pause_capture(reason="SpeechHistory window open") + self._capturePaused = True + + def _resume_capture(self): + if not self._capturePaused: + return + + debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Resuming capture (window closed)", True) + speech_history.resume_capture(reason="SpeechHistory window closed") + self._capturePaused = False + + def _present_message(self, message): + try: + if not self.app: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: {message}", True) + return + + state = self.app.getDynamicApiManager().getAPI("CthulhuState") + if state and state.activeScript: + state.activeScript.presentMessage(message, resetStyles=False) + else: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: {message}", True) + except Exception as e: + debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR presenting message: {e}", True) + logger.exception("Error presenting message from SpeechHistory") + diff --git a/src/cthulhu/plugins/meson.build b/src/cthulhu/plugins/meson.build index f536f81..73c5d97 100644 --- a/src/cthulhu/plugins/meson.build +++ b/src/cthulhu/plugins/meson.build @@ -7,6 +7,7 @@ subdir('HelloCthulhu') subdir('IndentationAudio') subdir('OCR') subdir('PluginManager') +subdir('SpeechHistory') subdir('SimplePluginSystem') subdir('hello_world') -subdir('self_voice') \ No newline at end of file +subdir('self_voice') diff --git a/src/cthulhu/speech.py b/src/cthulhu/speech.py index be81c3e..6cd8b5b 100644 --- a/src/cthulhu/speech.py +++ b/src/cthulhu/speech.py @@ -42,6 +42,7 @@ from . import speech_generator from .speechserver import VoiceFamily from .acss import ACSS +from . import speech_history _logger = logger.getLogger() log = _logger.newLog("speech") @@ -143,12 +144,28 @@ def sayAll(utteranceIterator, progressCallback): if settings.silenceSpeech: return if _speechserver: - _speechserver.sayAll(utteranceIterator, progressCallback) + def _speechHistorySayAllWrapper(): + for [context, acss] in utteranceIterator: + try: + utterance = getattr(context, "utterance", None) + if isinstance(utterance, str) and utterance.strip(): + speech_history.add(utterance, source="sayAll") + except Exception: + debug.printException(debug.LEVEL_INFO) + yield [context, acss] + + _speechserver.sayAll(_speechHistorySayAllWrapper(), progressCallback) else: for [context, acss] in utteranceIterator: logLine = f"SPEECH OUTPUT: '{context.utterance}'" debug.printMessage(debug.LEVEL_INFO, logLine, True) log.info(logLine) + try: + utterance = getattr(context, "utterance", None) + if isinstance(utterance, str) and utterance.strip(): + speech_history.add(utterance, source="sayAll-fallback") + except Exception: + debug.printException(debug.LEVEL_INFO) def _speak(text, acss, interrupt): """Speaks the individual string using the given ACSS.""" @@ -166,6 +183,11 @@ def _speak(text, acss, interrupt): debug.printMessage(debug.LEVEL_INFO, f"SPEECH: Blocked by sleep mode: '{text}'", True) return + try: + speech_history.add(text, source="speak") + except Exception: + debug.printException(debug.LEVEL_INFO) + if not _speechserver: logLine = f"SPEECH OUTPUT: '{text}' {acss}" debug.printMessage(debug.LEVEL_INFO, logLine, True) diff --git a/src/cthulhu/speech_history.py b/src/cthulhu/speech_history.py new file mode 100644 index 0000000..37bf756 --- /dev/null +++ b/src/cthulhu/speech_history.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2025 Stormux +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. + +"""Shared speech history buffer. + +This module records the last N unique utterances spoken by Cthulhu. + +Uniqueness is enforced only within the current history window. If an item +falls off the end, it may be added again later. +""" + +from __future__ import annotations + +import threading +from collections import deque + +from . import debug + +_loggerPrefix = "SpeechHistory:" + +_maxHistorySize = 50 +_historyItems = deque() +_historySet = set() + +_lock = threading.Lock() +_pauseCount = 0 +_pausedIgnoreCount = 0 + + +def pause_capture(reason: str = "") -> None: + """Pause capture so speech produced while paused is not recorded.""" + global _pauseCount + with _lock: + _pauseCount += 1 + global _pausedIgnoreCount + _pausedIgnoreCount = 0 + debug.printMessage( + debug.LEVEL_INFO, + f"{_loggerPrefix} capture paused (count={_pauseCount}) reason='{reason}'", + True, + ) + + +def resume_capture(reason: str = "") -> None: + """Resume capture after a pause_capture().""" + global _pauseCount + with _lock: + if _pauseCount <= 0: + _pauseCount = 0 + debug.printMessage( + debug.LEVEL_INFO, + f"{_loggerPrefix} resume requested while not paused reason='{reason}'", + True, + ) + return + + _pauseCount -= 1 + if _pauseCount == 0: + global _pausedIgnoreCount + _pausedIgnoreCount = 0 + debug.printMessage( + debug.LEVEL_INFO, + f"{_loggerPrefix} capture resumed (count={_pauseCount}) reason='{reason}'", + True, + ) + + +def is_capture_paused() -> bool: + with _lock: + return _pauseCount > 0 + + +def add(text: str | None, source: str = "") -> bool: + """Add text to speech history if it's not already present. + + Returns True if the item was added; False otherwise. + """ + if text is None: + return False + + try: + cleanText = text.strip() + except Exception: + return False + + if not cleanText: + return False + + with _lock: + if _pauseCount > 0: + global _pausedIgnoreCount + _pausedIgnoreCount += 1 + if _pausedIgnoreCount in (1, 25, 50, 100): + debug.printMessage( + debug.LEVEL_INFO, + f"{_loggerPrefix} ignoring speech while paused (ignored={_pausedIgnoreCount}) source='{source}' text='{cleanText[:80]}'", + True, + ) + return False + + if cleanText in _historySet: + debug.printMessage( + debug.LEVEL_INFO, + f"{_loggerPrefix} duplicate ignored source='{source}' text='{cleanText[:80]}'", + True, + ) + return False + + _historyItems.appendleft(cleanText) + _historySet.add(cleanText) + + evictedText = None + if len(_historyItems) > _maxHistorySize: + evictedText = _historyItems.pop() + _historySet.discard(evictedText) + + debug.printMessage( + debug.LEVEL_INFO, + f"{_loggerPrefix} added source='{source}' size={len(_historyItems)} text='{cleanText[:80]}'", + True, + ) + if evictedText: + debug.printMessage( + debug.LEVEL_INFO, + f"{_loggerPrefix} evicted size={len(_historyItems)} text='{evictedText[:80]}'", + True, + ) + + return True + + +def get_items() -> list[str]: + """Return a snapshot of history items, newest-first.""" + with _lock: + return list(_historyItems) + + +def remove(text: str | None) -> bool: + """Remove an item from the history (if present).""" + if text is None: + return False + + try: + cleanText = text.strip() + except Exception: + return False + + if not cleanText: + return False + + with _lock: + if cleanText not in _historySet: + debug.printMessage( + debug.LEVEL_INFO, + f"{_loggerPrefix} remove ignored (not found) text='{cleanText[:80]}'", + True, + ) + return False + + global _historyItems + _historyItems = deque(item for item in _historyItems if item != cleanText) + _historySet.discard(cleanText) + + debug.printMessage( + debug.LEVEL_INFO, + f"{_loggerPrefix} removed size={len(_historyItems)} text='{cleanText[:80]}'", + True, + ) + return True