Speech history plugin added. Code and documentation audit completed. Preparing for tagged release.
This commit is contained in:
56
AGENTS.md
Normal file
56
AGENTS.md
Normal 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 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.
|
||||
89
CLAUDE.md
89
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)
|
||||
```
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .plugin import SpeechHistory
|
||||
@@ -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
|
||||
@@ -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)
|
||||
2
src/cthulhu/plugins/SpeechHistory/__init__.py
Normal file
2
src/cthulhu/plugins/SpeechHistory/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .plugin import SpeechHistory
|
||||
|
||||
@@ -11,4 +11,5 @@ python3.install_sources(
|
||||
install_data(
|
||||
'plugin.info',
|
||||
install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'SpeechHistory'
|
||||
)
|
||||
)
|
||||
|
||||
9
src/cthulhu/plugins/SpeechHistory/plugin.info
Normal file
9
src/cthulhu/plugins/SpeechHistory/plugin.info
Normal 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
|
||||
|
||||
406
src/cthulhu/plugins/SpeechHistory/plugin.py
Normal file
406
src/cthulhu/plugins/SpeechHistory/plugin.py
Normal 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")
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
175
src/cthulhu/speech_history.py
Normal file
175
src/cthulhu/speech_history.py
Normal 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
|
||||
Reference in New Issue
Block a user