Speech history plugin added. Code and documentation audit completed. Preparing for tagged release.

This commit is contained in:
Storm Dragon
2025-12-22 19:43:41 -05:00
parent 10b3592173
commit 200faa9e36
19 changed files with 773 additions and 367 deletions

56
AGENTS.md Normal file
View File

@@ -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 codes 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.

View File

@@ -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)
```
```

View File

@@ -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
```
sudo pacman -S meson ninja gettext python-dasbus
```

View File

@@ -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

View File

@@ -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<version>
git push origin v<version>
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.

View File

@@ -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

View File

@@ -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)
summary(summary)

View File

@@ -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",

View File

@@ -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')
subdir('plugins')

View File

@@ -1 +0,0 @@
from .plugin import SpeechHistory

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,2 @@
from .plugin import SpeechHistory

View File

@@ -11,4 +11,5 @@ python3.install_sources(
install_data(
'plugin.info',
install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'SpeechHistory'
)
)

View File

@@ -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

View File

@@ -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")

View File

@@ -7,6 +7,7 @@ subdir('HelloCthulhu')
subdir('IndentationAudio')
subdir('OCR')
subdir('PluginManager')
subdir('SpeechHistory')
subdir('SimplePluginSystem')
subdir('hello_world')
subdir('self_voice')
subdir('self_voice')

View File

@@ -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)

View File

@@ -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