From 390f06e7f4cb9b0f434297c22706693c2bbac6a0 Mon Sep 17 00:00:00 2001 From: Orinks Date: Mon, 2 Feb 2026 02:03:01 +0000 Subject: [PATCH 1/2] fix: remove obsolete accessibletalkingclock directory breaking pytest Removes the old accessibletalkingclock/ directory that was left over from the project rename. This duplicate tests/ folder caused pytest ImportPathMismatchError during collection. Also fixes an unused variable in scripts/generate_sounds.py. Fixes #6 --- accessibletalkingclock/.gitignore | 111 ----- accessibletalkingclock/CHANGELOG | 5 - accessibletalkingclock/LICENSE | 19 - accessibletalkingclock/PHASE2_SUMMARY.md | 165 -------- accessibletalkingclock/README.md | 84 ---- accessibletalkingclock/README.rst | 12 - accessibletalkingclock/THREADING_FIXES.md | 175 -------- accessibletalkingclock/pyproject.toml | 196 --------- .../src/accessibletalkingclock/__init__.py | 0 .../src/accessibletalkingclock/__main__.py | 4 - .../src/accessibletalkingclock/app.py | 389 ------------------ .../accessibletalkingclock/audio/__init__.py | 7 - .../accessibletalkingclock/audio/player.py | 174 -------- .../accessibletalkingclock/generate_sounds.py | 307 -------------- .../accessibletalkingclock/resources/README | 2 - .../resources/sounds/.gitkeep | 1 - .../resources/sounds/ATTRIBUTIONS.md | 58 --- .../resources/sounds/classic/.gitkeep | 0 .../resources/sounds/digital/.gitkeep | 0 .../resources/sounds/nature/.gitkeep | 0 .../src/accessibletalkingclock/soundpack.py | 195 --------- accessibletalkingclock/start.ps1 | 56 --- accessibletalkingclock/start.sh | 36 -- accessibletalkingclock/test_audio_manual.py | 74 ---- accessibletalkingclock/tests/__init__.py | 0 .../tests/accessibletalkingclock.py | 35 -- accessibletalkingclock/tests/conftest.py | 83 ---- accessibletalkingclock/tests/test_app.py | 3 - .../tests/test_audio_player.py | 137 ------ .../tests/test_soundpack.py | 195 --------- .../tests/test_soundpack_integration.py | 161 -------- accessibletalkingclock/uv.lock | 3 - scripts/generate_sounds.py | 2 - 33 files changed, 2689 deletions(-) delete mode 100644 accessibletalkingclock/.gitignore delete mode 100644 accessibletalkingclock/CHANGELOG delete mode 100644 accessibletalkingclock/LICENSE delete mode 100644 accessibletalkingclock/PHASE2_SUMMARY.md delete mode 100644 accessibletalkingclock/README.md delete mode 100644 accessibletalkingclock/README.rst delete mode 100644 accessibletalkingclock/THREADING_FIXES.md delete mode 100644 accessibletalkingclock/pyproject.toml delete mode 100644 accessibletalkingclock/src/accessibletalkingclock/__init__.py delete mode 100644 accessibletalkingclock/src/accessibletalkingclock/__main__.py delete mode 100644 accessibletalkingclock/src/accessibletalkingclock/app.py delete mode 100644 accessibletalkingclock/src/accessibletalkingclock/audio/__init__.py delete mode 100644 accessibletalkingclock/src/accessibletalkingclock/audio/player.py delete mode 100644 accessibletalkingclock/src/accessibletalkingclock/generate_sounds.py delete mode 100644 accessibletalkingclock/src/accessibletalkingclock/resources/README delete mode 100644 accessibletalkingclock/src/accessibletalkingclock/resources/sounds/.gitkeep delete mode 100644 accessibletalkingclock/src/accessibletalkingclock/resources/sounds/ATTRIBUTIONS.md delete mode 100644 accessibletalkingclock/src/accessibletalkingclock/resources/sounds/classic/.gitkeep delete mode 100644 accessibletalkingclock/src/accessibletalkingclock/resources/sounds/digital/.gitkeep delete mode 100644 accessibletalkingclock/src/accessibletalkingclock/resources/sounds/nature/.gitkeep delete mode 100644 accessibletalkingclock/src/accessibletalkingclock/soundpack.py delete mode 100644 accessibletalkingclock/start.ps1 delete mode 100644 accessibletalkingclock/start.sh delete mode 100644 accessibletalkingclock/test_audio_manual.py delete mode 100644 accessibletalkingclock/tests/__init__.py delete mode 100644 accessibletalkingclock/tests/accessibletalkingclock.py delete mode 100644 accessibletalkingclock/tests/conftest.py delete mode 100644 accessibletalkingclock/tests/test_app.py delete mode 100644 accessibletalkingclock/tests/test_audio_player.py delete mode 100644 accessibletalkingclock/tests/test_soundpack.py delete mode 100644 accessibletalkingclock/tests/test_soundpack_integration.py delete mode 100644 accessibletalkingclock/uv.lock diff --git a/accessibletalkingclock/.gitignore b/accessibletalkingclock/.gitignore deleted file mode 100644 index 7b7d14e..0000000 --- a/accessibletalkingclock/.gitignore +++ /dev/null @@ -1,111 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# OSX useful to ignore -*.DS_Store -.AppleDouble -.LSOverride - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.dist-info/ -*.egg-info/ -.installed.cfg -*.egg - -# IntelliJ Idea family of suites -.idea -*.iml -## File-based project format: -*.ipr -*.iws -## mpeltonen/sbt-idea plugin -.idea_modules/ - -# Briefcase log files -logs/ - -# Virtual environments -venv/ -ENV/ -env.bak/ -venv.bak/ - -# PyCharm -.idea/ - -# VS Code -.vscode/ -*.code-workspace - -# pytest -.pytest_cache/ -.coverage -htmlcov/ - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# pipenv -Pipfile.lock - -# PEP 582 -__pypackages__/ - -# Briefcase build artifacts -build/ -dist/ -*.app -*.exe -*.dmg -*.pkg - -# Audio files (will be added in Phase 2-3) -*.wav -*.mp3 -*.ogg -!src/accessibletalkingclock/resources/sounds/.gitkeep diff --git a/accessibletalkingclock/CHANGELOG b/accessibletalkingclock/CHANGELOG deleted file mode 100644 index c1e83a2..0000000 --- a/accessibletalkingclock/CHANGELOG +++ /dev/null @@ -1,5 +0,0 @@ -# Accessible Talking Clock Release Notes - -## 0.0.1 (12 Sep 2025) - -* Initial release diff --git a/accessibletalkingclock/LICENSE b/accessibletalkingclock/LICENSE deleted file mode 100644 index 8ec08ec..0000000 --- a/accessibletalkingclock/LICENSE +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2025, A desktop clock with customizable soundpacks designed for visually impaired users - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/accessibletalkingclock/PHASE2_SUMMARY.md b/accessibletalkingclock/PHASE2_SUMMARY.md deleted file mode 100644 index a150d64..0000000 --- a/accessibletalkingclock/PHASE2_SUMMARY.md +++ /dev/null @@ -1,165 +0,0 @@ -# Phase 2: Audio Playback System - Summary - -## Status: Implementation Complete ✓ - -## What Was Accomplished - -### 1. Audio Infrastructure (Tasks P2-1, P2-2) -- **Installed sound_lib library** - Thread-safe audio playback using BASS audio system -- **Created audio package structure**: - ``` - src/accessibletalkingclock/audio/ - ├── __init__.py - ├── player.py # AudioPlayer class - └── test_sound.wav # Simple 440Hz test beep (0.5 seconds) - ``` -- **Prepared resources directory** for Phase 3 soundpacks: - ``` - src/accessibletalkingclock/resources/sounds/ - └── .gitkeep - ``` - -### 2. AudioPlayer Implementation (Tasks P2-3, P2-4) -- **AudioPlayer class** in `audio/player.py` with: - - Automatic BASS audio system initialization - - Volume control (0-100% with clamping) - - Sound file playback (supports WAV, MP3, OGG, FLAC) - - Playback status checking (`is_playing()`) - - Stop functionality - - Error handling for missing/invalid files - - Thread-safe operation (sound_lib handles threading internally) - -### 3. Test Suite (Task P2-2) -- **Comprehensive test coverage** in `tests/test_audio_player.py`: - - 15 unit tests covering all AudioPlayer functionality - - Tests for initialization, volume control, playback, error handling - - All tests passing ✓ - - TDD approach: Tests written before implementation - -### 4. UI Integration (Task P2-5) -- **Connected AudioPlayer to existing UI**: - - AudioPlayer initialized in `app.startup()` - - Volume button now updates AudioPlayer volume - - "Test Chime" button plays test sound file - - Status label provides feedback for all audio actions - - Error handling for audio initialization failures - -## Technical Achievements - -### Thread-Safe Audio -- sound_lib handles threading internally - no explicit threading needed -- Audio playback does not block UI updates -- Clock continues updating while sounds play - -### Error Handling -- Graceful fallback if audio system fails to initialize -- FileNotFoundError for missing audio files -- Proper exception handling for invalid formats -- Status feedback for all error conditions - -### Accessibility Maintained -- All existing accessibility features preserved -- Status label announces audio events for screen readers -- Keyboard navigation unaffected by audio integration - -## Testing Results - -### Automated Tests -```bash -pytest tests/test_audio_player.py -v -# 15 passed in 0.41s -``` - -All tests pass including: -- Volume control and clamping -- Audio playback with valid files -- Error handling for invalid files/formats -- Multiple sequential playback calls -- Resource cleanup - -### Manual Testing Script -Created `test_audio_manual.py` for quick verification without full GUI: -- Tests AudioPlayer initialization -- Verifies playback works -- Tests volume control -- Confirms non-blocking behavior - -## Code Quality - -### Follows Project Rules -- ✓ TDD approach (tests before implementation) -- ✓ Comprehensive logging for all operations -- ✓ Status feedback via status label -- ✓ Error handling throughout -- ✓ Docstrings for all public methods -- ✓ Git commits with descriptive messages -- ✓ Co-authored attribution - -### Design Patterns -- Global initialization flag for BASS system -- Volume percentage (0-100) converted to decimal (0.0-1.0) internally -- Clean separation: AudioPlayer handles audio, app.py handles UI -- Dependency injection ready for Phase 3 soundpacks - -## Files Modified/Created - -### New Files -- `src/accessibletalkingclock/audio/__init__.py` -- `src/accessibletalkingclock/audio/player.py` -- `src/accessibletalkingclock/audio/test_sound.wav` -- `src/accessibletalkingclock/resources/sounds/.gitkeep` -- `tests/test_audio_player.py` -- `test_audio_manual.py` -- `plans/2025-10-16_phase-2-audio/plan.md` -- `plans/2025-10-16_phase-2-audio/tasks/2025-10-16_12-00-00_phase-2-audio.json` - -### Modified Files -- `src/accessibletalkingclock/app.py` - Integrated AudioPlayer - -## Dependencies Added -- `sound_lib==0.83` -- `pywin32==311` (dependency of sound_lib) -- `platform-utils==1.6.0` (dependency of sound_lib) -- `libloader==1.3.3` (dependency of sound_lib) - -## Next Steps (Phase 3) - -Phase 2 provides the foundation for Phase 3: - -1. **Create actual soundpack audio files**: - - Classic: Westminster chimes - - Nature: Bird/water sounds - - Digital: Beep/tone sounds - -2. **Implement soundpack system**: - - Soundpack class/interface - - Load sounds based on selection - - Play different sounds for hour/half-hour/quarter-hour - -3. **Add interval scheduling**: - - Timer system to trigger chimes - - Respect user interval settings - -## Remaining Task - -### Task P2-6: Manual Testing with NVDA -**Status: Pending** - -This task requires: -1. Launch NVDA screen reader -2. Run the application: `cd accessibletalkingclock && python -m briefcase dev` -3. Verify: - - All controls still accessible via Tab - - Volume button announces new volume level - - "Test Chime" button announces "Playing test audio" - - Audio plays without freezing the UI - - Clock continues updating during audio playback - - Status label announcements work correctly - -**Note**: This requires user interaction as automated testing cannot fully verify screen reader behavior. - -## Summary - -Phase 2 successfully implements a complete audio playback system using sound_lib, with comprehensive testing and UI integration. The system is thread-safe, accessible, and ready for Phase 3 soundpack implementation. All automated tests pass, and the architecture supports the planned soundpack and scheduling features. - -The audio system integrates seamlessly with the existing accessible UI without compromising keyboard navigation or screen reader compatibility. diff --git a/accessibletalkingclock/README.md b/accessibletalkingclock/README.md deleted file mode 100644 index fa95f85..0000000 --- a/accessibletalkingclock/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# Accessible Talking Clock - -A desktop clock application designed specifically for visually impaired users, built with native Windows accessibility support. - -## Features - -### Phase 1 - Complete ✅ -- **Large Digital Clock Display**: Easy-to-read time display that updates every second -- **Screen Reader Compatible**: All controls work with NVDA, JAWS, and Windows Narrator -- **Keyboard Navigation**: Full Tab navigation support through all controls -- **Accessible Time Display**: Clock display is focusable and readable by screen readers -- **Soundpack Selection**: Choose from Classic (Westminster), Nature (Birds & Water), or Digital (Beeps) sound themes -- **Volume Control**: Cycle through volume levels (0%, 25%, 50%, 75%, 100%) -- **Interval Configuration**: Toggle chimes for hourly, half-hour, and quarter-hour intervals -- **Test Functionality**: Test button to preview current soundpack -- **Status Feedback**: Real-time status updates announced to screen readers - -### Planned Features -- **Phase 2**: Background audio playback system with pygame -- **Phase 3**: Built-in soundpack audio files and playback -- **Phase 4**: Settings persistence and configuration dialog -- **Phase 5**: Digital/analog display modes and advanced accessibility features -- **Phase 6**: Packaged Windows executable with bundled resources - -## Accessibility - -This application was designed with accessibility as a primary concern: - -- **Native Windows Controls**: Uses Toga/WinForms widgets that inherit Windows accessibility APIs -- **Tab Navigation**: Logical tab order through all interface elements -- **Screen Reader Support**: Compatible with NVDA, JAWS, and Windows Narrator -- **Keyboard Access**: All functionality available via keyboard -- **Status Announcements**: Changes and actions are announced to assistive technology -- **Focus Management**: Clear focus indicators and proper focus flow - -## Getting Started - -### Prerequisites -- Windows 10 or later -- Python 3.8 or later -- Screen reader (NVDA recommended for testing) - -### Installation -1. Clone this repository -2. Set up virtual environment: `uv venv` -3. Activate environment: `.venv\Scripts\activate` -4. Install dependencies: `uv pip install briefcase toga pygame` - -### Running the Application -Use the provided startup scripts: -- **Windows**: `.\start.ps1` -- **Cross-platform**: `.\start.sh` - -Or run manually: -```bash -python -m briefcase dev -``` - -### Accessibility Testing -1. Start NVDA screen reader -2. Launch the application -3. Use Tab key to navigate between controls -4. Verify all elements are announced properly -5. Test all buttons and controls with Enter/Space keys - -## Technical Details - -- **Framework**: Toga (BeeWare) for cross-platform native GUI -- **Packaging**: Briefcase for distributable executables -- **Accessibility**: Native Windows accessibility APIs via WinForms -- **Threading**: Designed for background audio and timer threads (Phase 2+) - -## Development Status - -✅ **Phase 1 Complete**: Core UI with full accessibility support -🔄 **Phase 2 Next**: Audio system implementation -📋 **Phases 3-6**: Planned features and packaging - -## Contributing - -This project prioritizes accessibility compliance and follows WCAG guidelines. All contributions should maintain or improve accessibility features. - -Generated with [Memex](https://memex.tech) -Co-Authored-By: Memex \ No newline at end of file diff --git a/accessibletalkingclock/README.rst b/accessibletalkingclock/README.rst deleted file mode 100644 index ad88c88..0000000 --- a/accessibletalkingclock/README.rst +++ /dev/null @@ -1,12 +0,0 @@ -Accessible Talking Clock -======================== - -**This cross-platform app was generated by** `Briefcase`_ **- part of** -`The BeeWare Project`_. **If you want to see more tools like Briefcase, please -consider** `becoming a financial member of BeeWare`_. - -My first application - -.. _`Briefcase`: https://briefcase.readthedocs.io/ -.. _`The BeeWare Project`: https://beeware.org/ -.. _`becoming a financial member of BeeWare`: https://beeware.org/contributing/membership diff --git a/accessibletalkingclock/THREADING_FIXES.md b/accessibletalkingclock/THREADING_FIXES.md deleted file mode 100644 index fcb68cd..0000000 --- a/accessibletalkingclock/THREADING_FIXES.md +++ /dev/null @@ -1,175 +0,0 @@ -# Threading Issues and Fixes - -## Problem Summary -When closing the Accessible Talking Clock application, threading errors appear in the console: -- `Windows fatal exception: code 0x80010108` (RPC_E_DISCONNECTED) -- Errors in `toga_winforms\libs\proactor.py` and `pythonnet\__init__.py` - -## Root Cause -This is a **known framework-level issue** with pythonnet and toga-winforms on Windows. The error occurs during application shutdown when: -1. The WinForms event loop (proactor) is shutting down -2. Pythonnet is trying to unload .NET assemblies -3. COM threading conflicts occur between these two shutdown processes - -**Important**: This error happens AFTER the application window has closed and AFTER our cleanup code has run. It does not affect application functionality or user experience. - -## Fixes Implemented - -### 1. Added Proper Application Cleanup (`on_exit` handler) -**File**: `src/accessibletalkingclock/app.py` - -Added async `on_exit()` method that: -- Sets `_shutdown_flag` to stop the clock update task gracefully -- Waits 0.5 seconds for async tasks to complete -- Calls `audio_player.cleanup()` to free audio resources -- Logs all cleanup steps - -```python -async def on_exit(self): - """Clean up resources before application exits.""" - logger.info("Application exit handler called") - - # Signal clock task to stop - self._shutdown_flag = True - - # Give the clock task time to stop gracefully - try: - await asyncio.sleep(0.5) - logger.info("Clock update task stopped") - except Exception as e: - logger.warning(f"Error waiting for clock task: {e}") - - # Clean up audio player - if self.audio_player: - try: - self.audio_player.cleanup() - except Exception as e: - logger.error(f"Error cleaning up audio player: {e}") - - logger.info("Application cleanup completed") - return True -``` - -### 2. Made Clock Update Task Stoppable -**File**: `src/accessibletalkingclock/app.py` - -Modified `_schedule_clock_update()` to: -- Check `_shutdown_flag` in the while loop -- Exit gracefully when flag is set -- Log when the task stops - -```python -async def update_clock(*args): - while not self._shutdown_flag: - try: - self.clock_display.value = self._get_current_time_string() - await asyncio.sleep(1) - except Exception as e: - logger.error(f"Error updating clock display: {e}") - await asyncio.sleep(1) - logger.info("Clock update task stopped") -``` - -### 3. Added AudioPlayer Cleanup -**File**: `src/accessibletalkingclock/audio/player.py` - -Added `cleanup()` method that: -- Stops and frees current audio stream -- Calls `BASS_Free()` to properly free the BASS audio library -- Resets the global `_bass_initialized` flag - -```python -def cleanup(self): - """ - Clean up audio resources. - Should be called when shutting down the application. - """ - global _bass_initialized - - logger.info("Cleaning up AudioPlayer resources") - if self._current_stream: - try: - self._current_stream.stop() - self._current_stream.free() - self._current_stream = None - except Exception as e: - logger.warning(f"Error during AudioPlayer cleanup: {e}") - - # Free BASS library resources - if _bass_initialized: - try: - BASS_Free() - _bass_initialized = False - logger.info("BASS audio system freed") - except Exception as e: - logger.warning(f"Error freeing BASS audio system: {e}") -``` - -### 4. Added sound_lib to Dependencies -**File**: `pyproject.toml` - -Added `sound_lib` to the `requires` list so it's available when running in dev mode: -```toml -requires = [ - "sound_lib", -] -``` - -## Current Status - -### ✅ Fixed -- AudioPlayer resources are properly cleaned up -- BASS audio library is properly freed -- Clock update task stops gracefully -- Application logs cleanup progress -- All user-level resources are freed before shutdown - -### ⚠️ Known Issue (Cannot Fix) -The threading error still appears in the console during shutdown. This is a **pythonnet/toga-winforms framework limitation** and cannot be fixed at the application level. - -**Why this happens**: -- Pythonnet needs to unload .NET assemblies during Python interpreter shutdown -- The WinForms event loop (proactor) is also shutting down simultaneously -- COM threading rules are violated when these two processes race -- This is documented in pythonnet issue #1701 - -**Impact**: -- Error appears AFTER window closes -- Error appears AFTER our cleanup completes -- Does NOT affect application functionality -- Does NOT lose user data -- Does NOT prevent clean shutdown -- Users never see this error (console only) - -## Testing Results - -Cleanup logging shows proper sequence: -1. User closes window -2. `on_exit` handler is called -3. Clock task stops -4. AudioPlayer cleanup runs -5. BASS audio system freed -6. Application cleanup completed -7. **Then** pythonnet threading error occurs - -## Recommendations - -### For Development -- Ignore the threading error messages in console -- Focus on the cleanup log messages to verify proper shutdown -- The error is expected and documented - -### For Distribution -- When packaging with briefcase for distribution, users won't see console output -- The error won't affect the packaged application -- No user-facing impact - -### Future Improvements -- Monitor pythonnet and toga-winforms for framework updates -- Consider alternative audio libraries if BASS contributes to the issue -- Could explore toga-winforms alternatives (toga-gtk on Windows via WSL) - -## References -- pythonnet issue #1701: PythonEngine.Shutdown() threading issues -- COM error 0x80010108: RPC_E_DISCONNECTED (The object invoked has disconnected from its clients) -- Toga documentation: App.on_exit() handler diff --git a/accessibletalkingclock/pyproject.toml b/accessibletalkingclock/pyproject.toml deleted file mode 100644 index b91e602..0000000 --- a/accessibletalkingclock/pyproject.toml +++ /dev/null @@ -1,196 +0,0 @@ -# This project was generated with 0.3.25 using template: https://github.com/beeware/briefcase-template @ v0.3.25 -[tool.briefcase] -project_name = "Accessible Talking Clock" -bundle = "tech.memex" -version = "0.0.1" -url = "https://memex.tech" -license.file = "LICENSE" -author = "A desktop clock with customizable soundpacks designed for visually impaired users" -author_email = "hello@memex.tech" - -[tool.briefcase.app.accessibletalkingclock] -formal_name = "Accessible Talking Clock" -description = "My first application" -long_description = """More details about the app should go here. -""" -sources = [ - "src/accessibletalkingclock", -] -test_sources = [ - "tests", -] - -requires = [ - "sound_lib", -] -test_requires = [ - "pytest", -] - -[tool.briefcase.app.accessibletalkingclock.macOS] -universal_build = true -requires = [ - "toga-cocoa~=0.5.0", - "std-nslog~=1.0.3", -] - -[tool.briefcase.app.accessibletalkingclock.linux] -requires = [ - "toga-gtk~=0.5.0", - # PyGObject 3.52.1 enforces a requirement on libgirepository-2.0-dev. This library - # isn't available on Debian 12/Ubuntu 22.04. If you don't need to support those (or - # older) releases, you can remove this version pin. See beeware/toga#3143. - "pygobject < 3.52.1", -] - -[tool.briefcase.app.accessibletalkingclock.linux.system.debian] -system_requires = [ - # Needed to compile pycairo wheel - "libcairo2-dev", - # One of the following two packages are needed to compile PyGObject wheel. If you - # remove the pygobject pin in the requires list, you should also change to the - # version 2.0 of the girepository library. See beeware/toga#3143. - "libgirepository1.0-dev", - # "libgirepository-2.0-dev", -] - -system_runtime_requires = [ - # Needed to provide GTK and its GI bindings - "gir1.2-gtk-3.0", - # One of the following two packages are needed to use PyGObject at runtime. If you - # remove the pygobject pin in the requires list, you should also change to the - # version 2.0 of the girepository library. See beeware/toga#3143. - "libgirepository-1.0-1", - # "libgirepository-2.0-0", - # Dependencies that GTK looks for at runtime - "libcanberra-gtk3-module", - # Needed to provide WebKit2 at runtime - # Note: Debian 11 requires gir1.2-webkit2-4.0 instead - # "gir1.2-webkit2-4.1", -] - -[tool.briefcase.app.accessibletalkingclock.linux.system.rhel] -system_requires = [ - # Needed to compile pycairo wheel - "cairo-gobject-devel", - # Needed to compile PyGObject wheel - "gobject-introspection-devel", -] - -system_runtime_requires = [ - # Needed to support Python bindings to GTK - "gobject-introspection", - # Needed to provide GTK - "gtk3", - # Dependencies that GTK looks for at runtime - "libcanberra-gtk3", - # Needed to provide WebKit2 at runtime - # "webkit2gtk3", -] - -[tool.briefcase.app.accessibletalkingclock.linux.system.suse] -system_requires = [ - # Needed to compile pycairo wheel - "cairo-devel", - # Needed to compile PyGObject wheel - "gobject-introspection-devel", -] - -system_runtime_requires = [ - # Needed to provide GTK - "gtk3", - # Needed to support Python bindings to GTK - "gobject-introspection", "typelib(Gtk) = 3.0", - # Dependencies that GTK looks for at runtime - "libcanberra-gtk3-module", - # Needed to provide WebKit2 at runtime - # "libwebkit2gtk3", "typelib(WebKit2)", -] - -[tool.briefcase.app.accessibletalkingclock.linux.system.arch] -system_requires = [ - # Needed to compile pycairo wheel - "cairo", - # Needed to compile PyGObject wheel - "gobject-introspection", - # Runtime dependencies that need to exist so that the - # Arch package passes final validation. - # Needed to provide GTK - "gtk3", - # Dependencies that GTK looks for at runtime - "libcanberra", - # Needed to provide WebKit2 - # "webkit2gtk", -] - -system_runtime_requires = [ - # Needed to provide GTK - "gtk3", - # Needed to provide PyGObject bindings - "gobject-introspection-runtime", - # Dependencies that GTK looks for at runtime - "libcanberra", - # Needed to provide WebKit2 at runtime - # "webkit2gtk", -] - -[tool.briefcase.app.accessibletalkingclock.linux.appimage] -manylinux = "manylinux_2_28" - -system_requires = [ - # Needed to compile pycairo wheel - "cairo-gobject-devel", - # Needed to compile PyGObject wheel - "gobject-introspection-devel", - # Needed to provide GTK - "gtk3-devel", - # Dependencies that GTK looks for at runtime, that need to be - # in the build environment to be picked up by linuxdeploy - "libcanberra-gtk3", - "PackageKit-gtk3-module", - "gvfs-client", -] - -linuxdeploy_plugins = [ - "DEPLOY_GTK_VERSION=3 gtk", -] - -[tool.briefcase.app.accessibletalkingclock.linux.flatpak] -flatpak_runtime = "org.gnome.Platform" -flatpak_runtime_version = "48" -flatpak_sdk = "org.gnome.Sdk" - -[tool.briefcase.app.accessibletalkingclock.windows] -requires = [ - "toga-winforms~=0.5.0", -] - -# Mobile deployments -[tool.briefcase.app.accessibletalkingclock.iOS] -requires = [ - "toga-iOS~=0.5.0", - "std-nslog~=1.0.3", -] - -[tool.briefcase.app.accessibletalkingclock.android] -requires = [ - "toga-android~=0.5.0", -] - -base_theme = "Theme.MaterialComponents.Light.DarkActionBar" - -build_gradle_dependencies = [ - "com.google.android.material:material:1.12.0", - # Needed for DetailedList - # "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0", - # Needed for MapView - # "org.osmdroid:osmdroid-android:6.1.20", -] - -# Web deployments -[tool.briefcase.app.accessibletalkingclock.web] -requires = [ - "toga-web~=0.5.0", -] -style_framework = "Shoelace v2.3" - diff --git a/accessibletalkingclock/src/accessibletalkingclock/__init__.py b/accessibletalkingclock/src/accessibletalkingclock/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/accessibletalkingclock/src/accessibletalkingclock/__main__.py b/accessibletalkingclock/src/accessibletalkingclock/__main__.py deleted file mode 100644 index 916d034..0000000 --- a/accessibletalkingclock/src/accessibletalkingclock/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from accessibletalkingclock.app import main - -if __name__ == "__main__": - main().main_loop() diff --git a/accessibletalkingclock/src/accessibletalkingclock/app.py b/accessibletalkingclock/src/accessibletalkingclock/app.py deleted file mode 100644 index 5887bac..0000000 --- a/accessibletalkingclock/src/accessibletalkingclock/app.py +++ /dev/null @@ -1,389 +0,0 @@ -""" -Accessible Talking Clock - A desktop clock application designed for visually impaired users. -""" - -import asyncio -import logging -from datetime import datetime -from pathlib import Path -import toga -from toga.style import Pack -from toga.style.pack import COLUMN, ROW - -from accessibletalkingclock.audio import AudioPlayer -from accessibletalkingclock.soundpack import SoundpackManager - -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -logger = logging.getLogger(__name__) - - -class AccessibleTalkingClock(toga.App): - """Main application class for the Accessible Talking Clock.""" - - def __init__(self, *args, **kwargs): - """Initialize application.""" - super().__init__(*args, **kwargs) - self._clock_task = None - self._shutdown_flag = False - self._last_chime_time = None # Track last chime to prevent duplicates - - def startup(self): - """Initialize the application interface.""" - logger.info("Starting Accessible Talking Clock application") - - # Initialize audio player (Phase 2) - try: - self.audio_player = AudioPlayer(volume_percent=50) - logger.info("Audio player initialized successfully") - except Exception as e: - logger.error(f"Failed to initialize audio player: {e}") - self.audio_player = None - - # Initialize soundpack manager (Phase 3) - try: - sounds_dir = Path(__file__).parent / "resources" / "sounds" - self.soundpack_manager = SoundpackManager(sounds_dir) - - # Discover available soundpacks - available_packs = self.soundpack_manager.discover_soundpacks() - logger.info(f"Discovered soundpacks: {available_packs}") - - # Load default soundpack - default_soundpack = "classic" - if default_soundpack in available_packs: - if self.soundpack_manager.load_soundpack(default_soundpack): - logger.info(f"Default soundpack '{default_soundpack}' loaded successfully") - else: - logger.error(f"Failed to load default soundpack '{default_soundpack}'") - else: - logger.warning(f"Default soundpack '{default_soundpack}' not found") - except Exception as e: - logger.error(f"Failed to initialize soundpack manager: {e}") - self.soundpack_manager = None - - # Create the main window - main_box = toga.Box(style=Pack(direction=COLUMN, padding=10)) - - # Clock display section - using TextInput for screen reader accessibility - self.clock_display = toga.TextInput( - value=self._get_current_time_string(), - readonly=True, - style=Pack( - padding=(20, 0, 20, 0), - text_align="center", - font_size=32, - font_weight="bold" - ) - ) - - # Status label for screen reader feedback - self.status_label = toga.Label( - text="Clock initialized. Use Tab to navigate controls.", - style=Pack( - padding=10, - text_align="left", - font_size=12 - ) - ) - - # Controls section - controls_box = toga.Box(style=Pack(direction=COLUMN, padding=10)) - - # Soundpack selection - soundpack_box = toga.Box(style=Pack(direction=ROW, padding=5)) - soundpack_label = toga.Label( - text="Soundpack:", - style=Pack(padding=(5, 10, 5, 0), width=100) - ) - - # Populate soundpack dropdown with discovered soundpacks (Phase 3) - if self.soundpack_manager: - available_packs = self.soundpack_manager.available_soundpacks - # Capitalize pack names for display - pack_items = [pack.capitalize() for pack in available_packs] - default_value = "Classic" if "Classic" in pack_items else (pack_items[0] if pack_items else "Classic") - else: - pack_items = ["Classic", "Nature", "Digital"] - default_value = "Classic" - - self.soundpack_selection = toga.Selection( - items=pack_items, - value=default_value, - style=Pack(flex=1, padding=5) - ) - self.soundpack_selection.on_change = self._on_soundpack_change - - soundpack_box.add(soundpack_label) - soundpack_box.add(self.soundpack_selection) - - # Volume control (simplified for Phase 1) - volume_box = toga.Box(style=Pack(direction=ROW, padding=5)) - volume_label = toga.Label( - text="Volume: 50%", - style=Pack(padding=(5, 10, 5, 0), flex=1) - ) - - self.volume_button = toga.Button( - text="Change Volume", - on_press=self._change_volume, - style=Pack(padding=5) - ) - - volume_box.add(volume_label) - volume_box.add(self.volume_button) - self.current_volume = 50 - - # Interval configuration - intervals_box = toga.Box(style=Pack(direction=COLUMN, padding=10)) - intervals_title = toga.Label( - text="Chime Intervals:", - style=Pack(padding=(0, 0, 10, 0), font_weight="bold") - ) - - self.hourly_switch = toga.Switch( - text="Hourly chimes", - value=True, - style=Pack(padding=5) - ) - self.hourly_switch.on_change = self._on_interval_change - - self.half_hour_switch = toga.Switch( - text="Half-hour chimes", - value=False, - style=Pack(padding=5) - ) - self.half_hour_switch.on_change = self._on_interval_change - - self.quarter_hour_switch = toga.Switch( - text="Quarter-hour chimes", - value=False, - style=Pack(padding=5) - ) - self.quarter_hour_switch.on_change = self._on_interval_change - - intervals_box.add(intervals_title) - intervals_box.add(self.hourly_switch) - intervals_box.add(self.half_hour_switch) - intervals_box.add(self.quarter_hour_switch) - - # Test chime button - test_button = toga.Button( - text="Test Current Chime", - on_press=self._test_chime, - style=Pack(padding=10, width=200) - ) - - # Settings button - settings_button = toga.Button( - text="Settings", - on_press=self._open_settings, - style=Pack(padding=10, width=200) - ) - - # Add all components to the main layout - controls_box.add(soundpack_box) - controls_box.add(volume_box) - controls_box.add(intervals_box) - - main_box.add(self.clock_display) - main_box.add(controls_box) - main_box.add(test_button) - main_box.add(settings_button) - main_box.add(self.status_label) - - # Create the main window - self.main_window = toga.MainWindow(title=self.formal_name) - self.main_window.content = main_box - self.main_window.show() - - # Initialize audio system placeholder - logger.info("Audio system initialization will be implemented in Phase 2") - - # Start the clock update timer - self._schedule_clock_update() - - logger.info("Application startup completed successfully") - - def _get_current_time_string(self): - """Get the current time as a formatted string.""" - return datetime.now().strftime("%I:%M:%S %p") - - def _schedule_clock_update(self): - """Schedule regular clock display updates and automatic chiming.""" - async def update_clock(*args): - while not self._shutdown_flag: - try: - # Update clock display - current_time = datetime.now() - self.clock_display.value = current_time.strftime("%I:%M:%S %p") - - # Check if we should play a chime (Phase 3) - minute = current_time.minute - - # Prevent duplicate chimes - only chime once per minute - current_minute_key = (current_time.hour, current_time.minute) - if self._last_chime_time != current_minute_key: - chime_type = None - - # Determine chime type based on time and enabled intervals - if minute == 0 and self.hourly_switch.value: - chime_type = "hour" - elif minute == 30 and self.half_hour_switch.value: - chime_type = "half" - elif minute in (15, 45) and self.quarter_hour_switch.value: - chime_type = "quarter" - - # Play the chime if applicable - if chime_type: - self._play_chime(chime_type) - self._last_chime_time = current_minute_key - - await asyncio.sleep(1) - except Exception as e: - logger.error(f"Error updating clock display: {e}") - await asyncio.sleep(1) - logger.info("Clock update task stopped") - - # Schedule the coroutine to run and store reference - self._clock_task = self.add_background_task(update_clock) - - def _on_soundpack_change(self, widget): - """Handle soundpack selection change.""" - selected = widget.value.lower() # Convert display name to soundpack name - logger.info(f"Soundpack changed to: {selected}") - - # Load the selected soundpack (Phase 3) - if self.soundpack_manager: - if self.soundpack_manager.load_soundpack(selected): - self.status_label.text = f"Soundpack changed to {widget.value}" - logger.info(f"Successfully loaded soundpack: {selected}") - else: - self.status_label.text = f"Failed to load {widget.value} soundpack" - logger.error(f"Failed to load soundpack: {selected}") - else: - self.status_label.text = f"Soundpack changed to: {widget.value}" - logger.warning("Soundpack manager not initialized") - - def _change_volume(self, widget): - """Handle volume button press - cycle through volume levels.""" - volumes = [0, 25, 50, 75, 100] - current_index = volumes.index(self.current_volume) - next_index = (current_index + 1) % len(volumes) - self.current_volume = volumes[next_index] - - # Update the volume label - volume_label = widget.parent.children[0] # Get the first child (volume label) - volume_label.text = f"Volume: {self.current_volume}%" - - # Update audio player volume (Phase 2) - if self.audio_player: - self.audio_player.set_volume(self.current_volume) - - logger.info(f"Volume changed to: {self.current_volume}%") - self.status_label.text = f"Volume set to {self.current_volume}%" - - def _on_interval_change(self, widget): - """Handle interval switch changes.""" - intervals = [] - if self.hourly_switch.value: - intervals.append("hourly") - if self.half_hour_switch.value: - intervals.append("half-hour") - if self.quarter_hour_switch.value: - intervals.append("quarter-hour") - - interval_text = ", ".join(intervals) if intervals else "none" - logger.info(f"Chime intervals changed to: {interval_text}") - self.status_label.text = f"Chime intervals: {interval_text}" - # Timer system integration will be implemented in Phase 2 - - def _test_chime(self, widget): - """Test the current chime sound - plays hour chime from current soundpack.""" - current_soundpack_name = self.soundpack_selection.value - logger.info(f"Testing hour chime for soundpack: {current_soundpack_name}") - - # Check if both audio player and soundpack manager are initialized - if not self.audio_player: - self.status_label.text = "Audio player not initialized" - logger.warning("Audio player not initialized, cannot play test sound") - return - - if not self.soundpack_manager or not self.soundpack_manager.current_soundpack: - self.status_label.text = "No soundpack loaded" - logger.warning("No soundpack loaded, cannot play test chime") - return - - # Play hour chime from current soundpack (Phase 3) - try: - soundpack = self.soundpack_manager.current_soundpack - hour_sound = soundpack.get_sound_path("hour") - self.audio_player.play_sound(str(hour_sound)) - self.status_label.text = f"Playing {current_soundpack_name} hour chime at {self.current_volume}%" - logger.info(f"Playing hour chime from {soundpack.name}: {hour_sound}") - except Exception as e: - self.status_label.text = f"Error playing chime: {str(e)}" - logger.error(f"Error playing test chime: {e}") - - def _play_chime(self, chime_type: str): - """ - Play a chime sound from the current soundpack. - - Args: - chime_type: Type of chime to play ("hour", "half", "quarter") - """ - if not self.audio_player: - logger.warning("Cannot play chime: audio player not initialized") - return - - if not self.soundpack_manager or not self.soundpack_manager.current_soundpack: - logger.warning("Cannot play chime: no soundpack loaded") - return - - try: - soundpack = self.soundpack_manager.current_soundpack - chime_path = soundpack.get_sound_path(chime_type) - self.audio_player.play_sound(str(chime_path)) - logger.info(f"Playing {chime_type} chime from {soundpack.name}") - except Exception as e: - logger.error(f"Error playing {chime_type} chime: {e}") - - def _open_settings(self, widget): - """Open the settings dialog.""" - logger.info("Opening settings dialog") - self.status_label.text = "Settings dialog (to be implemented)" - # Settings dialog will be implemented in Phase 4 - - async def on_exit(self): - """Clean up resources before application exits.""" - logger.info("Application exit handler called") - - # Signal clock task to stop - self._shutdown_flag = True - - # Give the clock task time to stop gracefully - try: - await asyncio.sleep(0.5) - logger.info("Clock update task stopped") - except Exception as e: - logger.warning(f"Error waiting for clock task: {e}") - - # Clean up audio player - if self.audio_player: - try: - self.audio_player.cleanup() - except Exception as e: - logger.error(f"Error cleaning up audio player: {e}") - - logger.info("Application cleanup completed") - - # Allow the app to exit - return True - - -def main(): - """Application entry point.""" - return AccessibleTalkingClock( - 'Accessible Talking Clock', - 'tech.memex.accessibletalkingclock' - ) diff --git a/accessibletalkingclock/src/accessibletalkingclock/audio/__init__.py b/accessibletalkingclock/src/accessibletalkingclock/audio/__init__.py deleted file mode 100644 index 5aa5acb..0000000 --- a/accessibletalkingclock/src/accessibletalkingclock/audio/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Audio playback module for Accessible Talking Clock. -""" - -from .player import AudioPlayer - -__all__ = ['AudioPlayer'] diff --git a/accessibletalkingclock/src/accessibletalkingclock/audio/player.py b/accessibletalkingclock/src/accessibletalkingclock/audio/player.py deleted file mode 100644 index 7b6c0f0..0000000 --- a/accessibletalkingclock/src/accessibletalkingclock/audio/player.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -AudioPlayer class for playing sound files with volume control. -Uses sound_lib library for thread-safe audio playback. -""" - -import logging -from pathlib import Path -from sound_lib import stream - -logger = logging.getLogger(__name__) - -# Global flag to track if BASS has been initialized -_bass_initialized = False - - -class AudioPlayer: - """ - Audio player for playing sound files with volume control. - - Uses sound_lib library which provides thread-safe audio playback - without blocking the UI thread. - """ - - def __init__(self, volume_percent=50): - """ - Initialize AudioPlayer. - - Args: - volume_percent (int): Initial volume level (0-100). Defaults to 50. - """ - global _bass_initialized - - # Initialize BASS audio library if not already initialized - if not _bass_initialized: - try: - from sound_lib import output - output.Output() # Initialize default output device - _bass_initialized = True - logger.info("BASS audio system initialized") - except Exception as e: - logger.error(f"Failed to initialize BASS audio system: {e}") - raise - - self._current_stream = None - self._volume = self._clamp_volume(volume_percent) - logger.info(f"AudioPlayer initialized with volume {self._volume}%") - - def _clamp_volume(self, volume_percent): - """ - Clamp volume to valid range (0-100). - - Args: - volume_percent (int): Volume to clamp - - Returns: - int: Clamped volume value - """ - return max(0, min(100, volume_percent)) - - def _convert_volume_to_decimal(self, volume_percent): - """ - Convert volume percentage to decimal (0.0-1.0) for sound_lib. - - Args: - volume_percent (int): Volume percentage (0-100) - - Returns: - float: Volume as decimal (0.0-1.0) - """ - return volume_percent / 100.0 - - def get_volume(self): - """ - Get current volume level. - - Returns: - int: Current volume percentage (0-100) - """ - return self._volume - - def set_volume(self, volume_percent): - """ - Set volume level. - - Args: - volume_percent (int): Volume level (0-100) - """ - self._volume = self._clamp_volume(volume_percent) - logger.info(f"Volume set to {self._volume}%") - - # Update volume of currently playing stream if any - if self._current_stream and self.is_playing(): - self._current_stream.volume = self._convert_volume_to_decimal(self._volume) - - def play_sound(self, file_path): - """ - Play an audio file. - - Args: - file_path (str): Path to audio file to play - - Raises: - FileNotFoundError: If the audio file doesn't exist - Exception: If the audio file format is invalid or cannot be played - """ - path = Path(file_path) - - # Check if file exists - if not path.exists(): - logger.error(f"Audio file not found: {file_path}") - raise FileNotFoundError(f"Audio file not found: {file_path}") - - try: - # Stop any currently playing sound - if self._current_stream: - self.stop() - - # Create new stream and play - logger.info(f"Playing audio file: {file_path}") - self._current_stream = stream.FileStream(file=str(path)) - self._current_stream.volume = self._convert_volume_to_decimal(self._volume) - self._current_stream.play() - - except Exception as e: - logger.error(f"Error playing audio file {file_path}: {e}") - raise - - def stop(self): - """ - Stop currently playing audio. - """ - if self._current_stream: - try: - logger.info("Stopping audio playback") - self._current_stream.stop() - self._current_stream.free() - self._current_stream = None - except Exception as e: - logger.warning(f"Error stopping audio: {e}") - self._current_stream = None - - def is_playing(self): - """ - Check if audio is currently playing. - - Returns: - bool: True if audio is playing, False otherwise - """ - if self._current_stream: - try: - return self._current_stream.is_playing - except Exception: - # If we can't check status, assume not playing - return False - return False - - def cleanup(self): - """ - Clean up audio resources. - Should be called when shutting down the application. - """ - global _bass_initialized - - logger.info("Cleaning up AudioPlayer resources") - if self._current_stream: - try: - self._current_stream.stop() - self._current_stream.free() - self._current_stream = None - except Exception as e: - logger.warning(f"Error during AudioPlayer cleanup: {e}") - - # BASS cleanup is handled automatically by sound_lib's Output destructor - _bass_initialized = False diff --git a/accessibletalkingclock/src/accessibletalkingclock/generate_sounds.py b/accessibletalkingclock/src/accessibletalkingclock/generate_sounds.py deleted file mode 100644 index fc752e8..0000000 --- a/accessibletalkingclock/src/accessibletalkingclock/generate_sounds.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -Generate synthetic chime sounds for soundpacks. - -Creates simple but pleasant chime sounds using synthesized tones. -This provides a quick way to populate soundpacks without requiring external audio files. -""" - -import wave -import math -import struct -import logging -from pathlib import Path -from typing import List - -logger = logging.getLogger(__name__) - - -def generate_tone( - frequency: float, - duration: float, - sample_rate: int = 44100, - amplitude: float = 0.5 -) -> List[float]: - """ - Generate a pure sine wave tone. - - Args: - frequency: Frequency in Hz - duration: Duration in seconds - sample_rate: Sample rate in Hz (default: 44100) - amplitude: Amplitude 0.0-1.0 (default: 0.5) - - Returns: - List of sample values - """ - num_samples = int(duration * sample_rate) - samples = [] - - for i in range(num_samples): - t = i / sample_rate - sample = amplitude * math.sin(2 * math.pi * frequency * t) - samples.append(sample) - - return samples - - -def apply_envelope( - samples: List[float], - attack: float = 0.01, - decay: float = 0.1, - sustain: float = 0.7, - release: float = 0.2, - sample_rate: int = 44100 -) -> List[float]: - """ - Apply ADSR envelope to samples for more natural sound. - - Args: - samples: Audio samples - attack: Attack time in seconds - decay: Decay time in seconds - sustain: Sustain level (0.0-1.0) - release: Release time in seconds - sample_rate: Sample rate in Hz - - Returns: - Samples with envelope applied - """ - num_samples = len(samples) - attack_samples = int(attack * sample_rate) - decay_samples = int(decay * sample_rate) - release_samples = int(release * sample_rate) - - sustain_samples = num_samples - attack_samples - decay_samples - release_samples - if sustain_samples < 0: - sustain_samples = 0 - - enveloped = [] - - for i, sample in enumerate(samples): - if i < attack_samples: - # Attack: ramp up from 0 to 1 - envelope = i / attack_samples - elif i < attack_samples + decay_samples: - # Decay: ramp down from 1 to sustain level - t = (i - attack_samples) / decay_samples - envelope = 1.0 - (1.0 - sustain) * t - elif i < attack_samples + decay_samples + sustain_samples: - # Sustain: hold at sustain level - envelope = sustain - else: - # Release: ramp down from sustain to 0 - t = (i - attack_samples - decay_samples - sustain_samples) / release_samples - envelope = sustain * (1.0 - t) - - enveloped.append(sample * envelope) - - return enveloped - - -def mix_samples(samples_list: List[List[float]]) -> List[float]: - """ - Mix multiple sample lists together. - - Args: - samples_list: List of sample lists to mix - - Returns: - Mixed samples - """ - if not samples_list: - return [] - - max_length = max(len(s) for s in samples_list) - mixed = [0.0] * max_length - - for samples in samples_list: - for i, sample in enumerate(samples): - mixed[i] += sample / len(samples_list) # Average to prevent clipping - - return mixed - - -def save_wav(samples: List[float], filename: Path, sample_rate: int = 44100): - """ - Save samples to WAV file. - - Args: - samples: Audio samples - filename: Output filename - sample_rate: Sample rate in Hz - """ - logger.info(f"Saving {len(samples)} samples to {filename}") - - # Convert to 16-bit integers - max_amplitude = 32767 - int_samples = [int(s * max_amplitude) for s in samples] - - # Pack samples - packed_samples = b''.join(struct.pack(' bool: - """ - Load and validate all sound files. - - Returns: - True if all required sounds are present and valid, False otherwise - """ - logger.info(f"Loading soundpack: {self.name}") - - # Get soundpack directory - soundpack_dir = self.base_path / self.name - - # Check if directory exists - if not soundpack_dir.exists(): - logger.error(f"Soundpack directory not found: {soundpack_dir}") - return False - - if not soundpack_dir.is_dir(): - logger.error(f"Soundpack path is not a directory: {soundpack_dir}") - return False - - # Check for all required sound files - missing_files = [] - for chime_type in self.REQUIRED_CHIMES: - sound_file = soundpack_dir / f"{chime_type}.wav" - if not sound_file.exists(): - missing_files.append(f"{chime_type}.wav") - else: - self._sound_paths[chime_type] = sound_file - - if missing_files: - logger.error(f"Soundpack '{self.name}' missing required files: {missing_files}") - self._sound_paths.clear() - return False - - self._loaded = True - logger.info(f"Soundpack '{self.name}' loaded successfully with {len(self._sound_paths)} sounds") - return True - - def get_sound_path(self, chime_type: str) -> Path: - """ - Get path to specific chime sound. - - Args: - chime_type: Type of chime ("hour", "half", "quarter") - - Returns: - Path to the sound file - - Raises: - RuntimeError: If soundpack not loaded - ValueError: If chime_type is invalid - """ - if not self._loaded: - raise RuntimeError(f"Soundpack '{self.name}' not loaded. Call load() first.") - - if chime_type not in self.REQUIRED_CHIMES: - raise ValueError(f"Invalid chime type: {chime_type}. Must be one of {self.REQUIRED_CHIMES}") - - return self._sound_paths[chime_type] - - @property - def is_loaded(self) -> bool: - """Check if soundpack is fully loaded.""" - return self._loaded - - @property - def available_chimes(self) -> List[str]: - """Get list of available chime types.""" - if not self._loaded: - return [] - return list(self._sound_paths.keys()) - - def __str__(self) -> str: - """String representation of soundpack.""" - status = "loaded" if self._loaded else "not loaded" - return f"Soundpack('{self.name}', {status})" - - def __repr__(self) -> str: - """Developer representation of soundpack.""" - return f"Soundpack(name='{self.name}', base_path='{self.base_path}', loaded={self._loaded})" - - -class SoundpackManager: - """Manages collection of soundpacks and current selection.""" - - def __init__(self, sounds_directory: Path): - """ - Initialize manager with base sounds directory. - - Args: - sounds_directory: Directory containing soundpack subdirectories - """ - self.sounds_directory = Path(sounds_directory) - self._soundpacks = {} - self._current_soundpack = None - - def discover_soundpacks(self) -> List[str]: - """ - Scan directory for available soundpacks. - - Returns: - List of soundpack names (directory names in sounds_directory) - """ - logger.info(f"Discovering soundpacks in: {self.sounds_directory}") - - if not self.sounds_directory.exists(): - logger.warning(f"Sounds directory not found: {self.sounds_directory}") - return [] - - soundpacks = [] - for item in self.sounds_directory.iterdir(): - if item.is_dir() and not item.name.startswith('.'): - soundpacks.append(item.name) - - logger.info(f"Found {len(soundpacks)} soundpacks: {soundpacks}") - return sorted(soundpacks) - - def load_soundpack(self, name: str) -> bool: - """ - Load specific soundpack. - - Args: - name: Name of soundpack to load - - Returns: - True if successfully loaded, False otherwise - """ - logger.info(f"Loading soundpack: {name}") - - # Create Soundpack object if not already cached - if name not in self._soundpacks: - soundpack = Soundpack(name, self.sounds_directory) - if not soundpack.load(): - return False - self._soundpacks[name] = soundpack - - # Set as current soundpack - self._current_soundpack = self._soundpacks[name] - logger.info(f"Current soundpack set to: {name}") - return True - - def get_soundpack(self, name: str) -> Optional[Soundpack]: - """ - Get loaded soundpack by name. - - Args: - name: Name of soundpack - - Returns: - Soundpack object if loaded, None otherwise - """ - return self._soundpacks.get(name) - - @property - def current_soundpack(self) -> Optional[Soundpack]: - """Get currently selected soundpack.""" - return self._current_soundpack - - @property - def available_soundpacks(self) -> List[str]: - """Get list of discovered soundpack names.""" - return self.discover_soundpacks() diff --git a/accessibletalkingclock/start.ps1 b/accessibletalkingclock/start.ps1 deleted file mode 100644 index 9e24bc0..0000000 --- a/accessibletalkingclock/start.ps1 +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env pwsh - -<# -.SYNOPSIS - Start script for Accessible Talking Clock application - -.DESCRIPTION - This script activates the Python virtual environment and starts the - Accessible Talking Clock application in development mode using Briefcase. - -.NOTES - This application is designed for accessibility and works with screen readers - like NVDA and JAWS on Windows. -#> - -# Set error action preference -$ErrorActionPreference = "Stop" - -Write-Host "=== Starting Accessible Talking Clock ===" -ForegroundColor Green -Write-Host "Designed for accessibility with screen reader support" -ForegroundColor Cyan - -try { - # Check if virtual environment exists - if (-not (Test-Path "..\..\.venv")) { - Write-Host "ERROR: Virtual environment not found at ..\..\.venv" -ForegroundColor Red - Write-Host "Please run setup first from the parent directory:" -ForegroundColor Yellow - Write-Host " uv venv && .venv\Scripts\activate && uv pip install briefcase toga pygame" -ForegroundColor Yellow - exit 1 - } - - # Activate virtual environment - Write-Host "Activating Python virtual environment..." -ForegroundColor Yellow - & "..\..\..\.venv\Scripts\Activate.ps1" - - # Check if briefcase is available - try { - python -m briefcase --version | Out-Null - } - catch { - Write-Host "ERROR: Briefcase not found. Installing..." -ForegroundColor Yellow - python -m pip install briefcase - } - - Write-Host "Starting Accessible Talking Clock..." -ForegroundColor Green - Write-Host "Use Tab key to navigate controls, screen readers will announce all elements" -ForegroundColor Cyan - - # Start the application - python -m briefcase dev - -} catch { - Write-Host "ERROR: Failed to start application: $_" -ForegroundColor Red - Write-Host "Check that you're in the correct directory and virtual environment is set up" -ForegroundColor Yellow - exit 1 -} - -Write-Host "Application closed." -ForegroundColor Green \ No newline at end of file diff --git a/accessibletalkingclock/start.sh b/accessibletalkingclock/start.sh deleted file mode 100644 index 6d5e9e5..0000000 --- a/accessibletalkingclock/start.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash - -# Start script for Accessible Talking Clock application -# This script activates the Python virtual environment and starts the -# Accessible Talking Clock application in development mode using Briefcase. - -set -e # Exit on any error - -echo "=== Starting Accessible Talking Clock ===" -echo "Designed for accessibility with screen reader support" - -# Check if virtual environment exists -if [ ! -d "../../.venv" ]; then - echo "ERROR: Virtual environment not found at ../../.venv" - echo "Please run setup first from the parent directory:" - echo " uv venv && .venv/bin/activate && uv pip install briefcase toga pygame" - exit 1 -fi - -# Activate virtual environment -echo "Activating Python virtual environment..." -source ../../.venv/bin/activate - -# Check if briefcase is available -if ! python -m briefcase --version > /dev/null 2>&1; then - echo "ERROR: Briefcase not found. Installing..." - python -m pip install briefcase -fi - -echo "Starting Accessible Talking Clock..." -echo "Use Tab key to navigate controls, screen readers will announce all elements" - -# Start the application -python -m briefcase dev - -echo "Application closed." \ No newline at end of file diff --git a/accessibletalkingclock/test_audio_manual.py b/accessibletalkingclock/test_audio_manual.py deleted file mode 100644 index 64229cf..0000000 --- a/accessibletalkingclock/test_audio_manual.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -Manual test script for AudioPlayer integration. -Run this to verify audio playback works without launching the full GUI. -""" - -import sys -import time -from pathlib import Path - -# Add src to path -src_path = Path(__file__).parent / "src" -sys.path.insert(0, str(src_path)) - -from accessibletalkingclock.audio import AudioPlayer # noqa: E402 - -def main(): - print("=== AudioPlayer Manual Test ===\n") - - # Initialize audio player - print("Initializing AudioPlayer...") - try: - player = AudioPlayer(volume_percent=75) - print(f"✓ AudioPlayer initialized with volume: {player.get_volume()}%\n") - except Exception as e: - print(f"✗ Failed to initialize AudioPlayer: {e}") - return 1 - - # Find test sound file - test_sound = Path(__file__).parent / "src" / "accessibletalkingclock" / "audio" / "test_sound.wav" - if not test_sound.exists(): - print(f"✗ Test sound not found at: {test_sound}") - return 1 - - print(f"Found test sound: {test_sound}\n") - - # Test playback - print("Playing test sound (0.5 seconds)...") - try: - player.play_sound(str(test_sound)) - print("✓ Sound playback started") - - # Check if playing - time.sleep(0.1) - if player.is_playing(): - print("✓ Audio is playing") - else: - print(" Note: Audio may have already finished (short file)") - - # Wait for sound to finish - time.sleep(0.6) - print("✓ Playback complete\n") - - except Exception as e: - print(f"✗ Error during playback: {e}") - return 1 - - # Test volume control - print("Testing volume control...") - for vol in [25, 50, 75, 100]: - player.set_volume(vol) - assert player.get_volume() == vol - print(f"✓ Volume set to {vol}%") - - print("\n=== All Tests Passed ===") - print("\nAudio system is working correctly!") - print("The UI integration should allow:") - print(" - Volume button cycles through 0%, 25%, 50%, 75%, 100%") - print(" - Test Chime button plays the test sound") - print(" - Audio plays without blocking the clock") - - return 0 - -if __name__ == "__main__": - sys.exit(main()) diff --git a/accessibletalkingclock/tests/__init__.py b/accessibletalkingclock/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/accessibletalkingclock/tests/accessibletalkingclock.py b/accessibletalkingclock/tests/accessibletalkingclock.py deleted file mode 100644 index d59e9f3..0000000 --- a/accessibletalkingclock/tests/accessibletalkingclock.py +++ /dev/null @@ -1,35 +0,0 @@ -import os -import sys -import tempfile -from pathlib import Path - -import pytest - - -def run_tests(): - project_path = Path(__file__).parent.parent - os.chdir(project_path) - - # Determine any args to pass to pytest. If there aren't any, - # default to running the whole test suite. - args = sys.argv[1:] - if len(args) == 0: - args = ["tests"] - - returncode = pytest.main( - [ - # Turn up verbosity - "-vv", - # Disable color - "--color=no", - # Overwrite the cache directory to somewhere writable - "-o", - f"cache_dir={tempfile.gettempdir()}/.pytest_cache", - ] + args - ) - - print(f">>>>>>>>>> EXIT {returncode} <<<<<<<<<<") - - -if __name__ == "__main__": - run_tests() diff --git a/accessibletalkingclock/tests/conftest.py b/accessibletalkingclock/tests/conftest.py deleted file mode 100644 index a6c7145..0000000 --- a/accessibletalkingclock/tests/conftest.py +++ /dev/null @@ -1,83 +0,0 @@ -import pytest - - -def _validate_wav_header(path): - with open(path, "rb") as handle: - header = handle.read(12) - if len(header) < 12 or header[0:4] != b"RIFF" or header[8:12] != b"WAVE": - raise ValueError("Invalid WAV header") - - -class FakeFileStream: - def __init__(self, file): - _validate_wav_header(file) - self.file = file - self.volume = 1.0 - self._playing = False - - def play(self): - self._playing = True - - def stop(self): - self._playing = False - - def free(self): - return None - - @property - def is_playing(self): - return self._playing - - -class FakeOutput: - def __init__(self, *args, **kwargs): - return None - - -@pytest.fixture(autouse=True) -def _mock_sound_lib(monkeypatch): - from accessibletalkingclock.audio import player as player_module - import sound_lib - - monkeypatch.setattr(player_module, "_bass_initialized", False) - - try: - import sound_lib.output - monkeypatch.setattr(sound_lib.output, "Output", FakeOutput) - except Exception: - pass - - monkeypatch.setattr(sound_lib.stream, "FileStream", FakeFileStream) - monkeypatch.setattr(player_module.stream, "FileStream", FakeFileStream) - - -@pytest.fixture -def test_sound_path(tmp_path): - wav_path = tmp_path / "test_sound.wav" - num_channels = 1 - sample_rate = 8000 - bits_per_sample = 16 - byte_rate = sample_rate * num_channels * bits_per_sample // 8 - block_align = num_channels * bits_per_sample // 8 - subchunk2_size = 0 - chunk_size = 36 + subchunk2_size - - header = b"".join( - [ - b"RIFF", - chunk_size.to_bytes(4, "little"), - b"WAVE", - b"fmt ", - (16).to_bytes(4, "little"), - (1).to_bytes(2, "little"), - num_channels.to_bytes(2, "little"), - sample_rate.to_bytes(4, "little"), - byte_rate.to_bytes(4, "little"), - block_align.to_bytes(2, "little"), - bits_per_sample.to_bytes(2, "little"), - b"data", - subchunk2_size.to_bytes(4, "little"), - ] - ) - wav_path.write_bytes(header) - return wav_path diff --git a/accessibletalkingclock/tests/test_app.py b/accessibletalkingclock/tests/test_app.py deleted file mode 100644 index e1a335f..0000000 --- a/accessibletalkingclock/tests/test_app.py +++ /dev/null @@ -1,3 +0,0 @@ -def test_first(): - """An initial test for the app.""" - assert 1 + 1 == 2 diff --git a/accessibletalkingclock/tests/test_audio_player.py b/accessibletalkingclock/tests/test_audio_player.py deleted file mode 100644 index 5b874c1..0000000 --- a/accessibletalkingclock/tests/test_audio_player.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -Tests for AudioPlayer class. -Following TDD approach - these tests are written before implementation. -""" - -import pytest -from accessibletalkingclock.audio import AudioPlayer - - -@pytest.fixture -def audio_player(): - """Create an AudioPlayer instance for testing.""" - player = AudioPlayer(volume_percent=50) - yield player - # Cleanup: stop any playing audio - if player.is_playing(): - player.stop() - - -class TestAudioPlayerInitialization: - """Test AudioPlayer initialization and basic properties.""" - - def test_audio_player_initializes_with_default_volume(self): - """AudioPlayer should initialize with default volume of 50%.""" - player = AudioPlayer() - assert player.get_volume() == 50 - - def test_audio_player_initializes_with_custom_volume(self): - """AudioPlayer should initialize with specified volume.""" - player = AudioPlayer(volume_percent=75) - assert player.get_volume() == 75 - - def test_audio_player_volume_clamped_to_valid_range(self): - """AudioPlayer should clamp volume to 0-100 range.""" - player = AudioPlayer(volume_percent=150) - assert player.get_volume() <= 100 - - player = AudioPlayer(volume_percent=-10) - assert player.get_volume() >= 0 - - -class TestAudioPlayerVolumeControl: - """Test volume control methods.""" - - def test_set_volume_changes_volume(self, audio_player): - """set_volume() should update the volume level.""" - audio_player.set_volume(80) - assert audio_player.get_volume() == 80 - - def test_set_volume_clamps_to_maximum(self, audio_player): - """set_volume() should clamp volume to maximum of 100.""" - audio_player.set_volume(150) - assert audio_player.get_volume() == 100 - - def test_set_volume_clamps_to_minimum(self, audio_player): - """set_volume() should clamp volume to minimum of 0.""" - audio_player.set_volume(-10) - assert audio_player.get_volume() == 0 - - -class TestAudioPlayerPlayback: - """Test audio playback functionality.""" - - def test_play_sound_with_valid_file(self, audio_player, test_sound_path): - """play_sound() should successfully play a valid audio file.""" - # Should not raise exception - audio_player.play_sound(str(test_sound_path)) - # Give it a moment to start playing - import time - time.sleep(0.1) - # May or may not be playing depending on file length - # Just verify no exception was raised - - def test_play_sound_with_invalid_file_raises_error(self, audio_player): - """play_sound() should raise FileNotFoundError for missing files.""" - with pytest.raises(FileNotFoundError): - audio_player.play_sound("nonexistent_file.wav") - - def test_is_playing_returns_false_initially(self, audio_player): - """is_playing() should return False when no sound is playing.""" - assert not audio_player.is_playing() - - def test_is_playing_returns_true_during_playback(self, audio_player, test_sound_path): - """is_playing() should return True while sound is playing.""" - audio_player.play_sound(str(test_sound_path)) - import time - time.sleep(0.05) # Brief delay to let playback start - # Check if playing (may be False if sound is very short) - is_playing = audio_player.is_playing() - # This assertion is lenient - sound might finish quickly - assert isinstance(is_playing, bool) - - def test_stop_halts_playback(self, audio_player, test_sound_path): - """stop() should halt audio playback.""" - audio_player.play_sound(str(test_sound_path)) - audio_player.stop() - import time - time.sleep(0.05) - assert not audio_player.is_playing() - - -class TestAudioPlayerErrorHandling: - """Test error handling and edge cases.""" - - def test_play_sound_with_invalid_format_raises_error(self, audio_player, tmp_path): - """play_sound() should raise appropriate error for invalid audio formats.""" - # Create a fake audio file with invalid format - fake_file = tmp_path / "fake.wav" - fake_file.write_text("Not a real WAV file") - - with pytest.raises(Exception): # Could be various exceptions depending on sound_lib - audio_player.play_sound(str(fake_file)) - - def test_multiple_play_calls_handle_gracefully(self, audio_player, test_sound_path): - """Multiple play_sound() calls should handle gracefully (stop previous, play new).""" - # Should not raise exception - audio_player.play_sound(str(test_sound_path)) - audio_player.play_sound(str(test_sound_path)) - # If we get here without exception, test passes - - def test_stop_when_not_playing_does_not_error(self, audio_player): - """stop() should not raise error when nothing is playing.""" - # Should not raise exception - audio_player.stop() - - -class TestAudioPlayerCleanup: - """Test resource cleanup.""" - - def test_audio_player_cleanup_releases_resources(self, test_sound_path): - """AudioPlayer should properly release resources when done.""" - player = AudioPlayer() - player.play_sound(str(test_sound_path)) - player.stop() - # If we can create another player without issues, cleanup worked - player2 = AudioPlayer() - assert player2 is not None diff --git a/accessibletalkingclock/tests/test_soundpack.py b/accessibletalkingclock/tests/test_soundpack.py deleted file mode 100644 index ad29b8e..0000000 --- a/accessibletalkingclock/tests/test_soundpack.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Tests for the Soundpack class. - -Following TDD/BDD approach - tests define expected behavior. -""" - -import pytest -from pathlib import Path -import tempfile -import shutil -from accessibletalkingclock.soundpack import Soundpack - - -class TestSoundpackInitialization: - """Test Soundpack object initialization.""" - - def test_soundpack_creation(self): - """Soundpack can be created with name and base path.""" - base_path = Path(tempfile.gettempdir()) / "test_sounds" - soundpack = Soundpack("classic", base_path) - - assert soundpack.name == "classic" - assert soundpack.base_path == base_path - - def test_soundpack_not_loaded_initially(self): - """New soundpack is not loaded until load() is called.""" - base_path = Path(tempfile.gettempdir()) / "test_sounds" - soundpack = Soundpack("classic", base_path) - - assert not soundpack.is_loaded - - -class TestSoundpackLoading: - """Test soundpack loading behavior.""" - - @pytest.fixture - def temp_soundpack_dir(self): - """Create temporary soundpack directory with test files.""" - temp_dir = Path(tempfile.gettempdir()) / "test_soundpack" - soundpack_dir = temp_dir / "classic" - soundpack_dir.mkdir(parents=True, exist_ok=True) - - # Create dummy audio files - (soundpack_dir / "hour.wav").write_text("dummy audio data") - (soundpack_dir / "half.wav").write_text("dummy audio data") - (soundpack_dir / "quarter.wav").write_text("dummy audio data") - - yield temp_dir - - # Cleanup - shutil.rmtree(temp_dir) - - def test_load_complete_soundpack(self, temp_soundpack_dir): - """When soundpack has all required files, load() returns True.""" - soundpack = Soundpack("classic", temp_soundpack_dir) - - result = soundpack.load() - - assert result is True - assert soundpack.is_loaded - - def test_load_missing_soundpack_directory(self): - """When soundpack directory doesn't exist, load() returns False.""" - base_path = Path(tempfile.gettempdir()) / "nonexistent" - soundpack = Soundpack("classic", base_path) - - result = soundpack.load() - - assert result is False - assert not soundpack.is_loaded - - def test_load_incomplete_soundpack(self): - """When soundpack is missing required files, load() returns False.""" - temp_dir = Path(tempfile.gettempdir()) / "test_incomplete" - soundpack_dir = temp_dir / "classic" - soundpack_dir.mkdir(parents=True, exist_ok=True) - - # Only create hour file, missing half and quarter - (soundpack_dir / "hour.wav").write_text("dummy audio data") - - soundpack = Soundpack("classic", temp_dir) - result = soundpack.load() - - assert result is False - assert not soundpack.is_loaded - - # Cleanup - shutil.rmtree(temp_dir) - - -class TestSoundpackSoundAccess: - """Test accessing sound file paths from soundpack.""" - - @pytest.fixture - def loaded_soundpack(self): - """Create and load a complete soundpack.""" - temp_dir = Path(tempfile.gettempdir()) / "test_access" - soundpack_dir = temp_dir / "classic" - soundpack_dir.mkdir(parents=True, exist_ok=True) - - # Create dummy audio files - (soundpack_dir / "hour.wav").write_text("dummy audio data") - (soundpack_dir / "half.wav").write_text("dummy audio data") - (soundpack_dir / "quarter.wav").write_text("dummy audio data") - - soundpack = Soundpack("classic", temp_dir) - soundpack.load() - - yield soundpack - - # Cleanup - shutil.rmtree(temp_dir) - - def test_get_hour_sound_path(self, loaded_soundpack): - """get_sound_path('hour') returns path to hour.wav.""" - path = loaded_soundpack.get_sound_path("hour") - - assert path.name == "hour.wav" - assert path.exists() - - def test_get_half_sound_path(self, loaded_soundpack): - """get_sound_path('half') returns path to half.wav.""" - path = loaded_soundpack.get_sound_path("half") - - assert path.name == "half.wav" - assert path.exists() - - def test_get_quarter_sound_path(self, loaded_soundpack): - """get_sound_path('quarter') returns path to quarter.wav.""" - path = loaded_soundpack.get_sound_path("quarter") - - assert path.name == "quarter.wav" - assert path.exists() - - def test_get_invalid_chime_type(self, loaded_soundpack): - """get_sound_path() raises ValueError for invalid chime type.""" - with pytest.raises(ValueError, match="Invalid chime type"): - loaded_soundpack.get_sound_path("invalid") - - def test_get_sound_path_before_loading(self): - """get_sound_path() raises RuntimeError if soundpack not loaded.""" - base_path = Path(tempfile.gettempdir()) / "test_sounds" - soundpack = Soundpack("classic", base_path) - - with pytest.raises(RuntimeError, match="not loaded"): - soundpack.get_sound_path("hour") - - -class TestSoundpackAvailableChimes: - """Test querying available chimes in soundpack.""" - - def test_available_chimes_when_loaded(self): - """available_chimes property returns list of chime types when loaded.""" - temp_dir = Path(tempfile.gettempdir()) / "test_chimes" - soundpack_dir = temp_dir / "classic" - soundpack_dir.mkdir(parents=True, exist_ok=True) - - # Create dummy audio files - (soundpack_dir / "hour.wav").write_text("dummy audio data") - (soundpack_dir / "half.wav").write_text("dummy audio data") - (soundpack_dir / "quarter.wav").write_text("dummy audio data") - - soundpack = Soundpack("classic", temp_dir) - soundpack.load() - - available = soundpack.available_chimes - - assert "hour" in available - assert "half" in available - assert "quarter" in available - assert len(available) == 3 - - # Cleanup - shutil.rmtree(temp_dir) - - def test_available_chimes_when_not_loaded(self): - """available_chimes returns empty list when not loaded.""" - base_path = Path(tempfile.gettempdir()) / "test_sounds" - soundpack = Soundpack("classic", base_path) - - assert soundpack.available_chimes == [] - - -class TestSoundpackStringRepresentation: - """Test string representation of soundpack.""" - - def test_str_representation(self): - """str(soundpack) returns descriptive string with name and status.""" - base_path = Path(tempfile.gettempdir()) / "test_sounds" - soundpack = Soundpack("classic", base_path) - - result = str(soundpack) - - assert "classic" in result - assert "not loaded" in result or "loaded" in result diff --git a/accessibletalkingclock/tests/test_soundpack_integration.py b/accessibletalkingclock/tests/test_soundpack_integration.py deleted file mode 100644 index 404733c..0000000 --- a/accessibletalkingclock/tests/test_soundpack_integration.py +++ /dev/null @@ -1,161 +0,0 @@ -""" -Integration tests for soundpack system with real audio files. - -Tests that the generated soundpacks can be discovered and loaded correctly. -These tests require actual sound files and are skipped on CI. -""" - -import pytest -from pathlib import Path -from accessibletalkingclock.soundpack import Soundpack, SoundpackManager - - -# Check if real sound files exist -_sounds_dir = Path(__file__).parent.parent / "src" / "accessibletalkingclock" / "resources" / "sounds" -_has_sound_files = (_sounds_dir / "classic" / "hour.wav").exists() - -pytestmark = pytest.mark.skipif( - not _has_sound_files, - reason="Integration tests require real sound files (run generate_sounds.py first)" -) - - -class TestRealSoundpacks: - """Test integration with actual generated soundpack files.""" - - @pytest.fixture - def sounds_dir(self): - """Get path to real sounds directory.""" - # Path from test file to sounds directory - test_dir = Path(__file__).parent - sounds_dir = test_dir.parent / "src" / "accessibletalkingclock" / "resources" / "sounds" - return sounds_dir - - def test_classic_soundpack_loads(self, sounds_dir): - """Classic soundpack loads successfully with all required files.""" - soundpack = Soundpack("classic", sounds_dir) - - result = soundpack.load() - - assert result is True - assert soundpack.is_loaded - assert "hour" in soundpack.available_chimes - assert "half" in soundpack.available_chimes - assert "quarter" in soundpack.available_chimes - - def test_nature_soundpack_loads(self, sounds_dir): - """Nature soundpack loads successfully with all required files.""" - soundpack = Soundpack("nature", sounds_dir) - - result = soundpack.load() - - assert result is True - assert soundpack.is_loaded - assert len(soundpack.available_chimes) == 3 - - def test_digital_soundpack_loads(self, sounds_dir): - """Digital soundpack loads successfully with all required files.""" - soundpack = Soundpack("digital", sounds_dir) - - result = soundpack.load() - - assert result is True - assert soundpack.is_loaded - assert len(soundpack.available_chimes) == 3 - - def test_all_sound_files_exist(self, sounds_dir): - """All required sound files exist and are accessible.""" - required_files = [ - "classic/hour.wav", - "classic/half.wav", - "classic/quarter.wav", - "nature/hour.wav", - "nature/half.wav", - "nature/quarter.wav", - "digital/hour.wav", - "digital/half.wav", - "digital/quarter.wav", - ] - - for file_path in required_files: - full_path = sounds_dir / file_path - assert full_path.exists(), f"Missing sound file: {file_path}" - assert full_path.stat().st_size > 0, f"Empty sound file: {file_path}" - - -class TestSoundpackManagerIntegration: - """Test SoundpackManager with real soundpacks.""" - - @pytest.fixture - def sounds_dir(self): - """Get path to real sounds directory.""" - test_dir = Path(__file__).parent - sounds_dir = test_dir.parent / "src" / "accessibletalkingclock" / "resources" / "sounds" - return sounds_dir - - @pytest.fixture - def manager(self, sounds_dir): - """Create SoundpackManager with real sounds directory.""" - return SoundpackManager(sounds_dir) - - def test_discovers_all_soundpacks(self, manager): - """SoundpackManager discovers all three soundpacks.""" - soundpacks = manager.discover_soundpacks() - - assert "classic" in soundpacks - assert "nature" in soundpacks - assert "digital" in soundpacks - assert len(soundpacks) >= 3 - - def test_loads_classic_soundpack(self, manager): - """SoundpackManager can load classic soundpack.""" - result = manager.load_soundpack("classic") - - assert result is True - assert manager.current_soundpack is not None - assert manager.current_soundpack.name == "classic" - assert manager.current_soundpack.is_loaded - - def test_loads_nature_soundpack(self, manager): - """SoundpackManager can load nature soundpack.""" - result = manager.load_soundpack("nature") - - assert result is True - assert manager.current_soundpack.name == "nature" - - def test_loads_digital_soundpack(self, manager): - """SoundpackManager can load digital soundpack.""" - result = manager.load_soundpack("digital") - - assert result is True - assert manager.current_soundpack.name == "digital" - - def test_switches_between_soundpacks(self, manager): - """Can switch between different soundpacks.""" - # Load classic - manager.load_soundpack("classic") - assert manager.current_soundpack.name == "classic" - - # Switch to digital - manager.load_soundpack("digital") - assert manager.current_soundpack.name == "digital" - - # Switch to nature - manager.load_soundpack("nature") - assert manager.current_soundpack.name == "nature" - - def test_retrieves_sound_paths(self, manager): - """Can retrieve sound file paths from loaded soundpack.""" - manager.load_soundpack("classic") - soundpack = manager.current_soundpack - - hour_path = soundpack.get_sound_path("hour") - half_path = soundpack.get_sound_path("half") - quarter_path = soundpack.get_sound_path("quarter") - - assert hour_path.exists() - assert half_path.exists() - assert quarter_path.exists() - assert hour_path.name == "hour.wav" - assert half_path.name == "half.wav" - assert quarter_path.name == "quarter.wav" diff --git a/accessibletalkingclock/uv.lock b/accessibletalkingclock/uv.lock deleted file mode 100644 index 7518fc9..0000000 --- a/accessibletalkingclock/uv.lock +++ /dev/null @@ -1,3 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.12" diff --git a/scripts/generate_sounds.py b/scripts/generate_sounds.py index 0bbf71d..220dd49 100644 --- a/scripts/generate_sounds.py +++ b/scripts/generate_sounds.py @@ -79,8 +79,6 @@ def generate_chime( gap: Gap between notes. """ sample_rate = 44100 - total_duration = num_notes * note_duration + (num_notes - 1) * gap - num_samples = int(sample_rate * total_duration) samples = [] note_samples = int(sample_rate * note_duration) From 498c83a7940e76492c36b3cdf36be4942cd6e2f6 Mon Sep 17 00:00:00 2001 From: Orinks <38449772+Orinks@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:03:46 -0500 Subject: [PATCH 2/2] test: improve audio module test coverage to 80%+ (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring audio module coverage from 65% to 96%: - player.py: 66% → 94% - tts_engine.py: 63% → 97% Added comprehensive tests for: - BASS audio init/cleanup with sound_lib - Fallback playback via playsound3 - Volume control during active playback - Stream lifecycle (create, stop, free) - pyttsx3 engine init, speech, and error handling - Voice enumeration and selection - Rate property with engine sync - Time formatting edge cases (midnight, noon, quarter-to) - Error handling and graceful degradation All external audio dependencies (sound_lib, playsound3, pyttsx3) are mocked. Closes #8 --- tests/test_audio_player.py | 597 ++++++++++++++++++++++++++++++++----- tests/test_tts_engine.py | 532 +++++++++++++++++++++++++++------ 2 files changed, 977 insertions(+), 152 deletions(-) diff --git a/tests/test_audio_player.py b/tests/test_audio_player.py index c7a6a90..64d84f9 100644 --- a/tests/test_audio_player.py +++ b/tests/test_audio_player.py @@ -14,7 +14,7 @@ def test_init_default_volume(self): """AudioPlayer should initialize with default 50% volume.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer - + player = AudioPlayer() assert player.get_volume() == 50 @@ -22,7 +22,7 @@ def test_init_custom_volume(self): """AudioPlayer should accept custom initial volume.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer - + player = AudioPlayer(volume_percent=75) assert player.get_volume() == 75 @@ -30,7 +30,7 @@ def test_init_volume_clamped_high(self): """Volume above 100 should be clamped to 100.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer - + player = AudioPlayer(volume_percent=150) assert player.get_volume() == 100 @@ -38,19 +38,73 @@ def test_init_volume_clamped_low(self): """Volume below 0 should be clamped to 0.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer - + player = AudioPlayer(volume_percent=-50) assert player.get_volume() == 0 + def test_init_with_sound_lib_initializes_bass(self): + """AudioPlayer should init BASS when sound_lib is available.""" + import accessiclock.audio.player as player_module + + original_use = player_module._use_sound_lib + original_init = player_module._bass_initialized + try: + player_module._use_sound_lib = True + player_module._bass_initialized = False + + mock_output = MagicMock() + with ( + patch.dict( + "sys.modules", + {"sound_lib.output": mock_output, "sound_lib": MagicMock()}, + ), + patch( + "accessiclock.audio.player.output", mock_output, create=True + ), + ): + from accessiclock.audio.player import AudioPlayer + + player = AudioPlayer.__new__(AudioPlayer) + player._current_stream = None + player._volume = 50 + assert player is not None + + # Reset + player_module._bass_initialized = False + finally: + player_module._use_sound_lib = original_use + player_module._bass_initialized = original_init + + def test_init_bass_already_initialized(self): + """AudioPlayer should skip BASS init if already done.""" + import accessiclock.audio.player as player_module + + original_use = player_module._use_sound_lib + original_init = player_module._bass_initialized + try: + player_module._use_sound_lib = True + player_module._bass_initialized = True # Already init'd + + from accessiclock.audio.player import AudioPlayer + + player = AudioPlayer.__new__(AudioPlayer) + player._current_stream = None + player._volume = 50 + # No error since BASS already initialized + finally: + player_module._use_sound_lib = original_use + player_module._bass_initialized = original_init + class TestVolumeControl: """Test volume control methods.""" - @pytest.fixture + @pytest.fixture() def player(self): """Create an AudioPlayer with mocked backend.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer + return AudioPlayer(volume_percent=50) def test_set_volume(self, player): @@ -74,6 +128,55 @@ def test_volume_decimal_conversion(self, player): assert player._convert_volume_to_decimal(50) == 0.5 assert player._convert_volume_to_decimal(100) == 1.0 + def test_set_volume_updates_playing_stream(self): + """set_volume should update volume on currently playing stream.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = True + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + + mock_stream = MagicMock() + mock_stream.is_playing = True + player._current_stream = mock_stream + + player.set_volume(80) + assert player.get_volume() == 80 + assert mock_stream.volume == 0.8 + finally: + player_module._use_sound_lib = original_use + + def test_set_volume_no_update_when_not_playing(self): + """set_volume should not update stream volume if not playing.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = True + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + + # Use a non-Mock object to verify volume is not set + class FakeStream: + is_playing = False + volume = 0.5 # original value + + fake_stream = FakeStream() + player._current_stream = fake_stream + + player.set_volume(80) + assert player.get_volume() == 80 + # Volume should NOT be updated since stream is not playing + assert fake_stream.volume == 0.5 + finally: + player_module._use_sound_lib = original_use + class TestPlaySound: """Test sound playback methods.""" @@ -82,39 +185,156 @@ def test_play_nonexistent_file_raises(self): """Playing a nonexistent file should raise FileNotFoundError.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer - + player = AudioPlayer() with pytest.raises(FileNotFoundError): player.play_sound("/nonexistent/path/to/audio.wav") - def test_play_sound_with_fallback(self): + def test_play_sound_dispatches_to_sound_lib(self): + """play_sound should use sound_lib when available.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = True + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + player._play_with_sound_lib = MagicMock() + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + temp_path = f.name + try: + player.play_sound(temp_path) + player._play_with_sound_lib.assert_called_once() + finally: + Path(temp_path).unlink(missing_ok=True) + finally: + player_module._use_sound_lib = original_use + + def test_play_sound_dispatches_to_fallback(self): """play_sound should use fallback when sound_lib unavailable.""" - # Skip if playsound3 not available + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib try: - import playsound3 # noqa: F401 - playsound_available = True - except ImportError: - playsound_available = False - - if not playsound_available: - pytest.skip("playsound3 not available") - - with patch("accessiclock.audio.player._use_sound_lib", False): - from accessiclock.audio.player import AudioPlayer - - player = AudioPlayer() - - # Create a temporary file + player_module._use_sound_lib = False + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + player._play_with_fallback = MagicMock() + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: temp_path = f.name - try: - # Mock playsound in the module where it's used - import accessiclock.audio.player as player_module - with patch.object(player_module, "playsound", create=True): - player.play_sound(temp_path) + player.play_sound(temp_path) + player._play_with_fallback.assert_called_once() finally: Path(temp_path).unlink(missing_ok=True) + finally: + player_module._use_sound_lib = original_use + + def test_play_with_fallback_uses_playsound3(self): + """_play_with_fallback should use playsound3 in a thread.""" + from accessiclock.audio.player import AudioPlayer + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + mock_playsound = MagicMock() + mock_thread_class = MagicMock() + mock_thread_instance = MagicMock() + mock_thread_class.return_value = mock_thread_instance + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + temp_path = f.name + try: + with ( + patch( + "accessiclock.audio.player.playsound", + mock_playsound, + create=True, + ), + patch("threading.Thread", mock_thread_class), + ): + player._play_with_fallback(Path(temp_path)) + mock_thread_class.assert_called_once() + mock_thread_instance.start.assert_called_once() + finally: + Path(temp_path).unlink(missing_ok=True) + + def test_play_with_fallback_import_error(self): + """_play_with_fallback should raise ImportError when playsound3 missing.""" + from accessiclock.audio.player import AudioPlayer + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + temp_path = f.name + try: + with ( + patch( + "builtins.__import__", + side_effect=ImportError("No module named 'playsound3'"), + ), + pytest.raises(ImportError), + ): + player._play_with_fallback(Path(temp_path)) + finally: + Path(temp_path).unlink(missing_ok=True) + + def test_play_with_fallback_generic_error(self): + """_play_with_fallback should raise on generic errors.""" + from accessiclock.audio.player import AudioPlayer + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + temp_path = f.name + try: + mock_playsound = MagicMock() + with ( + patch("threading.Thread", side_effect=RuntimeError("thread error")), + patch.dict("sys.modules", {"playsound3": MagicMock(playsound=mock_playsound)}), + pytest.raises(RuntimeError, match="thread error"), + ): + player._play_with_fallback(Path(temp_path)) + finally: + Path(temp_path).unlink(missing_ok=True) + + def test_play_with_sound_lib_error(self): + """_play_with_sound_lib should raise on stream errors.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + mock_stream_module = MagicMock() + mock_stream_module.FileStream.side_effect = RuntimeError("stream error") + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + temp_path = f.name + try: + with ( + patch.object( + player_module, "stream", mock_stream_module, create=True + ), + pytest.raises(RuntimeError, match="stream error"), + ): + player._play_with_sound_lib(Path(temp_path)) + finally: + Path(temp_path).unlink(missing_ok=True) class TestIsPlaying: @@ -124,10 +344,111 @@ def test_is_playing_initially_false(self): """is_playing should return False when nothing is playing.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer - + player = AudioPlayer() assert player.is_playing() is False + def test_is_playing_exception_returns_false(self): + """is_playing should return False when stream raises exception.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = True + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + + mock_stream = MagicMock() + type(mock_stream).is_playing = property( + lambda self: (_ for _ in ()).throw(RuntimeError("error")) + ) + player._current_stream = mock_stream + + assert player.is_playing() is False + finally: + player_module._use_sound_lib = original_use + + def test_is_playing_no_sound_lib_returns_false(self): + """is_playing returns False when sound_lib is not used.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = False + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = MagicMock() # Even with a stream + + assert player.is_playing() is False + finally: + player_module._use_sound_lib = original_use + + +class TestStop: + """Test stop method.""" + + def test_stop_no_stream(self): + """stop should be safe when no stream exists.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = True + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + # Should not raise + player.stop() + finally: + player_module._use_sound_lib = original_use + + def test_stop_exception_clears_stream(self): + """stop should clear stream reference even on error.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = True + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + + mock_stream = MagicMock() + mock_stream.stop.side_effect = RuntimeError("stop error") + player._current_stream = mock_stream + + player.stop() + assert player._current_stream is None + finally: + player_module._use_sound_lib = original_use + + def test_stop_not_sound_lib(self): + """stop should be no-op when sound_lib not used.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = False + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = MagicMock() + + player.stop() + # Stream not touched since sound_lib not used + assert player._current_stream is not None + finally: + player_module._use_sound_lib = original_use + class TestCleanup: """Test cleanup method.""" @@ -136,7 +457,7 @@ def test_cleanup_no_error_when_nothing_playing(self): """cleanup should not raise when nothing is playing.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer - + player = AudioPlayer() # Should not raise player.cleanup() @@ -144,30 +465,119 @@ def test_cleanup_no_error_when_nothing_playing(self): def test_cleanup_stops_playback(self): """cleanup should stop any current playback.""" from accessiclock.audio.player import AudioPlayer - + # Create player instance directly with mocked stream player = AudioPlayer.__new__(AudioPlayer) player._volume = 50 mock_current = MagicMock() player._current_stream = mock_current - + # Mock cleanup to avoid BASS_Free issues import accessiclock.audio.player as player_module + original_use_sound_lib = player_module._use_sound_lib original_bass_init = player_module._bass_initialized - + try: player_module._use_sound_lib = True player_module._bass_initialized = False # Skip BASS_Free - + player.cleanup() - + mock_current.stop.assert_called_once() mock_current.free.assert_called_once() finally: player_module._use_sound_lib = original_use_sound_lib player_module._bass_initialized = original_bass_init + def test_cleanup_stream_error_suppressed(self): + """cleanup should suppress stream stop/free errors.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + original_init = player_module._bass_initialized + + try: + player_module._use_sound_lib = False + player_module._bass_initialized = False + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + + mock_stream = MagicMock() + mock_stream.stop.side_effect = RuntimeError("cleanup error") + player._current_stream = mock_stream + + # Should not raise + player.cleanup() + finally: + player_module._use_sound_lib = original_use + player_module._bass_initialized = original_init + + def test_cleanup_frees_bass(self): + """cleanup should call BASS_Free when bass was initialized.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + original_init = player_module._bass_initialized + original_bass_free = getattr(player_module, "BASS_Free", None) + + try: + player_module._use_sound_lib = True + player_module._bass_initialized = True + + mock_bass_free = MagicMock() + player_module.BASS_Free = mock_bass_free + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + player.cleanup() + + mock_bass_free.assert_called_once() + assert player_module._bass_initialized is False + finally: + player_module._use_sound_lib = original_use + player_module._bass_initialized = original_init + if original_bass_free is not None: + player_module.BASS_Free = original_bass_free + elif hasattr(player_module, "BASS_Free"): + delattr(player_module, "BASS_Free") + + def test_cleanup_bass_free_error_suppressed(self): + """cleanup should suppress BASS_Free errors.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + original_init = player_module._bass_initialized + original_bass_free = getattr(player_module, "BASS_Free", None) + + try: + player_module._use_sound_lib = True + player_module._bass_initialized = True + + mock_bass_free = MagicMock(side_effect=RuntimeError("bass error")) + player_module.BASS_Free = mock_bass_free + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + # Should not raise + player.cleanup() + mock_bass_free.assert_called_once() + finally: + player_module._use_sound_lib = original_use + player_module._bass_initialized = original_init + if original_bass_free is not None: + player_module.BASS_Free = original_bass_free + elif hasattr(player_module, "BASS_Free"): + delattr(player_module, "BASS_Free") + class TestSoundLibIntegration: """Test sound_lib integration (cross-platform).""" @@ -176,33 +586,37 @@ def test_sound_lib_play_creates_stream(self): """Playing with sound_lib should create a FileStream.""" import accessiclock.audio.player as player_module from accessiclock.audio.player import AudioPlayer - + # Create a mock stream module mock_stream_module = MagicMock() mock_file_stream = MagicMock() mock_stream_module.FileStream.return_value = mock_file_stream - + player = AudioPlayer.__new__(AudioPlayer) player._volume = 50 player._current_stream = None - + # Create temp file with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: temp_path = f.name - + try: # Patch stream in the module - with patch.object(player_module, "stream", mock_stream_module, create=True): + with patch.object( + player_module, "stream", mock_stream_module, create=True + ): player._play_with_sound_lib(Path(temp_path)) - + # Verify FileStream was created with correct path mock_stream_module.FileStream.assert_called_once() call_args = mock_stream_module.FileStream.call_args # Check if temp_path was passed as positional or keyword arg - passed_path = call_args.kwargs.get("file") or (call_args.args[0] if call_args.args else None) + passed_path = call_args.kwargs.get("file") or ( + call_args.args[0] if call_args.args else None + ) assert passed_path is not None assert str(Path(passed_path)) == str(Path(temp_path)) - + # Verify play was called mock_file_stream.play.assert_called_once() finally: @@ -212,22 +626,24 @@ def test_sound_lib_sets_volume_on_stream(self): """Playing should set volume on the stream.""" import accessiclock.audio.player as player_module from accessiclock.audio.player import AudioPlayer - + mock_stream_module = MagicMock() mock_file_stream = MagicMock() mock_stream_module.FileStream.return_value = mock_file_stream - + player = AudioPlayer.__new__(AudioPlayer) player._volume = 75 # 75% player._current_stream = None - + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: temp_path = f.name - + try: - with patch.object(player_module, "stream", mock_stream_module, create=True): + with patch.object( + player_module, "stream", mock_stream_module, create=True + ): player._play_with_sound_lib(Path(temp_path)) - + # Verify volume was set to 0.75 assert mock_file_stream.volume == 0.75 finally: @@ -237,26 +653,27 @@ def test_sound_lib_stops_previous_stream(self): """Playing a new sound should stop the previous stream.""" import accessiclock.audio.player as player_module from accessiclock.audio.player import AudioPlayer - + mock_stream_module = MagicMock() - mock_old_stream = MagicMock() mock_new_stream = MagicMock() mock_stream_module.FileStream.return_value = mock_new_stream - + player = AudioPlayer.__new__(AudioPlayer) player._volume = 50 - player._current_stream = mock_old_stream - + player._current_stream = MagicMock() # existing stream + # Mock stop method player.stop = MagicMock() - + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: temp_path = f.name - + try: - with patch.object(player_module, "stream", mock_stream_module, create=True): + with patch.object( + player_module, "stream", mock_stream_module, create=True + ): player._play_with_sound_lib(Path(temp_path)) - + # stop() should have been called (which stops/frees old stream) player.stop.assert_called_once() finally: @@ -266,24 +683,24 @@ def test_sound_lib_is_playing_checks_stream(self): """is_playing should check the stream's is_playing property.""" import accessiclock.audio.player as player_module from accessiclock.audio.player import AudioPlayer - + original_use_sound_lib = player_module._use_sound_lib try: player_module._use_sound_lib = True - + player = AudioPlayer.__new__(AudioPlayer) player._volume = 50 - + # No stream - not playing player._current_stream = None assert player.is_playing() is False - + # Stream playing mock_stream = MagicMock() mock_stream.is_playing = True player._current_stream = mock_stream assert player.is_playing() is True - + # Stream stopped mock_stream.is_playing = False assert player.is_playing() is False @@ -294,21 +711,67 @@ def test_sound_lib_stop_frees_stream(self): """stop should stop and free the current stream.""" import accessiclock.audio.player as player_module from accessiclock.audio.player import AudioPlayer - + original_use_sound_lib = player_module._use_sound_lib try: player_module._use_sound_lib = True - + player = AudioPlayer.__new__(AudioPlayer) player._volume = 50 - + mock_stream = MagicMock() player._current_stream = mock_stream - + player.stop() - + mock_stream.stop.assert_called_once() mock_stream.free.assert_called_once() assert player._current_stream is None finally: player_module._use_sound_lib = original_use_sound_lib + + def test_init_bass_via_sound_lib(self): + """AudioPlayer __init__ should initialize BASS output.""" + import accessiclock.audio.player as player_module + + original_use = player_module._use_sound_lib + original_init = player_module._bass_initialized + + try: + player_module._use_sound_lib = True + player_module._bass_initialized = False + + mock_output_module = MagicMock() + + with patch.dict("sys.modules", {"sound_lib.output": mock_output_module}): + from accessiclock.audio.player import AudioPlayer + + AudioPlayer() + assert player_module._bass_initialized is True + mock_output_module.Output.assert_called_once() + finally: + player_module._use_sound_lib = original_use + player_module._bass_initialized = original_init + + def test_init_bass_failure_raises(self): + """AudioPlayer __init__ should raise if BASS init fails.""" + import accessiclock.audio.player as player_module + + original_use = player_module._use_sound_lib + original_init = player_module._bass_initialized + + try: + player_module._use_sound_lib = True + player_module._bass_initialized = False + + mock_output_module = MagicMock() + mock_output_module.Output.side_effect = RuntimeError("BASS init failed") + + with patch.dict("sys.modules", {"sound_lib.output": mock_output_module}): + from accessiclock.audio.player import AudioPlayer + + with pytest.raises(RuntimeError, match="BASS init failed"): + AudioPlayer() + finally: + player_module._use_sound_lib = original_use + player_module._bass_initialized = original_init diff --git a/tests/test_tts_engine.py b/tests/test_tts_engine.py index 99547c0..8122bc0 100644 --- a/tests/test_tts_engine.py +++ b/tests/test_tts_engine.py @@ -1,12 +1,7 @@ -"""Tests for accessiclock.audio.tts_engine module. +"""Tests for accessiclock.audio.tts_engine module.""" -TDD: These tests are written before the implementation. -""" - -from datetime import time -from unittest.mock import MagicMock - -import pytest +from datetime import date, time +from unittest.mock import MagicMock, patch class TestTTSEngine: @@ -15,27 +10,115 @@ class TestTTSEngine: def test_init_default_engine(self): """Should initialize with SAPI5 as default engine on Windows.""" from accessiclock.audio.tts_engine import TTSEngine - + engine = TTSEngine() assert engine.engine_type in ("sapi5", "dummy") def test_init_with_custom_rate(self): """Should accept custom speech rate.""" from accessiclock.audio.tts_engine import TTSEngine - + engine = TTSEngine(rate=200) assert engine.rate == 200 def test_rate_clamped_to_valid_range(self): """Rate should be clamped to valid range.""" from accessiclock.audio.tts_engine import TTSEngine - + engine = TTSEngine(rate=500) # Too high assert engine.rate <= 300 - + engine2 = TTSEngine(rate=10) # Too low assert engine2.rate >= 50 + def test_init_force_dummy(self): + """force_dummy should override pyttsx3 availability.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + assert engine.engine_type == "dummy" + assert engine._engine is None + + def test_init_pyttsx3_available(self): + """Should use sapi5 engine when pyttsx3 is available.""" + mock_engine = MagicMock() + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + assert engine.engine_type == "sapi5" + assert engine._engine is mock_engine + mock_pyttsx3.init.assert_called_once() + mock_engine.setProperty.assert_called_once_with("rate", 150) + + def test_init_pyttsx3_init_failure(self): + """Should fall back to dummy if pyttsx3.init fails.""" + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.side_effect = RuntimeError("init failed") + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + assert engine.engine_type == "dummy" + + +class TestRateProperty: + """Test rate getter/setter.""" + + def test_rate_getter(self): + """rate property should return current rate.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True, rate=175) + assert engine.rate == 175 + + def test_rate_setter_dummy(self): + """rate setter on dummy should update value without engine.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + engine.rate = 250 + assert engine.rate == 250 + + def test_rate_setter_clamps(self): + """rate setter should clamp values.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + engine.rate = 999 + assert engine.rate == 300 + engine.rate = 1 + assert engine.rate == 50 + + def test_rate_setter_with_engine(self): + """rate setter should update pyttsx3 engine.""" + mock_engine = MagicMock() + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + mock_engine.reset_mock() + + engine.rate = 200 + assert engine.rate == 200 + mock_engine.setProperty.assert_called_once_with("rate", 200) + class TestTimeFormatting: """Test time-to-speech formatting.""" @@ -43,126 +126,391 @@ class TestTimeFormatting: def test_format_time_12h_simple(self): """Should format time in simple 12-hour format.""" from accessiclock.audio.tts_engine import TTSEngine - - engine = TTSEngine() - + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(15, 30), style="simple") assert "3" in result assert "30" in result - assert "PM" in result.upper() or "P.M." in result.upper() + assert "PM" in result + + def test_format_time_simple_on_hour(self): + """Simple style on the hour should omit minutes.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(15, 0), style="simple") + assert result == "3 PM" + + def test_format_time_simple_midnight(self): + """Midnight should be 12 AM.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(0, 0), style="simple") + assert "12" in result + assert "AM" in result + + def test_format_time_simple_noon(self): + """Noon should be 12 PM.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(12, 0), style="simple") + assert "12" in result + assert "PM" in result def test_format_time_natural(self): """Should format time in natural speech style.""" from accessiclock.audio.tts_engine import TTSEngine - - engine = TTSEngine() - + + engine = TTSEngine(force_dummy=True) + # Quarter past result = engine.format_time(time(14, 15), style="natural") - assert "quarter" in result.lower() or "15" in result - + assert "quarter past" in result.lower() + # Half past result = engine.format_time(time(14, 30), style="natural") - assert "half" in result.lower() or "30" in result - + assert "half past" in result.lower() + # On the hour result = engine.format_time(time(15, 0), style="natural") - assert "o'clock" in result.lower() or "00" in result or "3" in result + assert "o'clock" in result.lower() + + def test_format_time_natural_quarter_to(self): + """Natural style at :45 should say 'quarter to'.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(14, 45), style="natural") + assert "quarter to" in result.lower() + + def test_format_time_natural_irregular_minute(self): + """Natural style with irregular minutes should show time normally.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(14, 22), style="natural") + assert "2:22" in result + assert "PM" in result def test_format_time_precise(self): """Should format time with full precision.""" from accessiclock.audio.tts_engine import TTSEngine - - engine = TTSEngine() - + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(9, 5), style="precise") - assert "9" in result - assert "05" in result or "5" in result - assert "AM" in result.upper() or "A.M." in result.upper() + assert "The time is" in result + assert "9:05" in result + assert "AM" in result def test_format_time_with_date(self): """Should optionally include date.""" - from datetime import datetime - from accessiclock.audio.tts_engine import TTSEngine - - engine = TTSEngine() - + + engine = TTSEngine(force_dummy=True) + result = engine.format_time( time(12, 0), include_date=True, - date=datetime(2025, 1, 24).date() + date=date(2025, 1, 24), ) assert "January" in result or "24" in result or "Friday" in result + def test_format_time_without_date_flag(self): + """include_date=False should not include date.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(12, 0), include_date=False) + assert "January" not in result + assert "Monday" not in result + + def test_format_time_include_date_no_date_provided(self): + """include_date=True but no date should not include date.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(12, 0), include_date=True, date=None) + # Should still work, just no date prefix + assert "12" in result + class TestSpeech: """Test speech synthesis.""" - def test_speak_uses_engine(self): - """speak() should use the TTS engine when pyttsx3 available.""" - from accessiclock.audio.tts_engine import _PYTTSX3_AVAILABLE, TTSEngine - - if not _PYTTSX3_AVAILABLE: - # Can't test real engine without pyttsx3 - pytest.skip("pyttsx3 not available") - - # Test with real engine (mocked) - engine = TTSEngine() - if engine._engine: - engine._engine.say = MagicMock() - engine._engine.runAndWait = MagicMock() - + def test_speak_dummy_no_crash(self): + """speak() with dummy engine should not crash.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + engine.speak("Hello world") # Should not raise + + def test_speak_with_engine(self): + """speak() should use pyttsx3 engine.""" + mock_engine = MagicMock() + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + engine.speak("Hello world") + + mock_engine.say.assert_called_once_with("Hello world") + mock_engine.runAndWait.assert_called_once() + + def test_speak_engine_error_handled(self): + """speak() should handle engine errors gracefully.""" + mock_engine = MagicMock() + mock_engine.say.side_effect = RuntimeError("speech error") + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + # Should not raise engine.speak("Hello world") - - engine._engine.say.assert_called() - engine._engine.runAndWait.assert_called() def test_speak_time_combines_format_and_speak(self): """speak_time() should format and speak the time.""" from accessiclock.audio.tts_engine import TTSEngine - - engine = TTSEngine(force_dummy=True) # Use dummy to avoid pyttsx3 - engine.speak = MagicMock() # Mock the speak method - + + engine = TTSEngine(force_dummy=True) + engine.speak = MagicMock() + engine.speak_time(time(15, 0)) - + engine.speak.assert_called_once() call_arg = engine.speak.call_args[0][0] - assert "3" in call_arg or "15" in call_arg + assert "3" in call_arg + + def test_speak_time_with_style(self): + """speak_time() should pass style to format_time.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + engine.speak = MagicMock() + + engine.speak_time(time(14, 30), style="natural") + + call_arg = engine.speak.call_args[0][0] + assert "half past" in call_arg.lower() + + def test_speak_time_with_date(self): + """speak_time() should support include_date.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + engine.speak = MagicMock() + + engine.speak_time( + time(12, 0), + include_date=True, + current_date=date(2025, 6, 15), + ) + + call_arg = engine.speak.call_args[0][0] + assert "June" in call_arg or "15" in call_arg class TestVoiceSelection: """Test voice selection and listing.""" - def test_list_voices(self): - """Should list available voices.""" + def test_list_voices_dummy(self): + """Dummy engine should return empty list.""" from accessiclock.audio.tts_engine import TTSEngine - - engine = TTSEngine() + + engine = TTSEngine(force_dummy=True) voices = engine.list_voices() - - assert isinstance(voices, list) - # May be empty on systems without TTS + assert voices == [] + + def test_list_voices_with_engine(self): + """list_voices should return voice info from pyttsx3.""" + mock_engine = MagicMock() + mock_voice1 = MagicMock() + mock_voice1.id = "voice1_id" + mock_voice1.name = "Voice One" + mock_voice2 = MagicMock() + mock_voice2.id = "voice2_id" + mock_voice2.name = "Voice Two" + mock_engine.getProperty.return_value = [mock_voice1, mock_voice2] + + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + voices = engine.list_voices() + + assert len(voices) == 2 + assert voices[0] == {"id": "voice1_id", "name": "Voice One"} + assert voices[1] == {"id": "voice2_id", "name": "Voice Two"} + mock_engine.getProperty.assert_called_with("voices") + + def test_list_voices_error_returns_empty(self): + """list_voices should return empty list on error.""" + mock_engine = MagicMock() + mock_engine.getProperty.side_effect = RuntimeError("voices error") + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + voices = engine.list_voices() + assert voices == [] + + def test_set_voice_dummy_returns_false(self): + """set_voice on dummy engine should return False.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.set_voice("Some Voice") + assert result is False def test_set_voice_by_name(self): - """Should set voice by name when pyttsx3 available.""" - from accessiclock.audio.tts_engine import _PYTTSX3_AVAILABLE, TTSEngine - - if not _PYTTSX3_AVAILABLE: - # Test that dummy engine returns False - tts = TTSEngine(force_dummy=True) - result = tts.set_voice("Microsoft David") - assert result is False - return - - # Test with real engine - tts = TTSEngine() - voices = tts.list_voices() - if voices: - # Try to set the first available voice - result = tts.set_voice(voices[0]["name"]) + """set_voice should match by name.""" + mock_engine = MagicMock() + mock_voice = MagicMock() + mock_voice.id = "voice_id_1" + mock_voice.name = "Microsoft David" + mock_engine.getProperty.return_value = [mock_voice] + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + result = engine.set_voice("Microsoft David") + assert result is True + mock_engine.setProperty.assert_called_with("voice", "voice_id_1") + + def test_set_voice_by_id(self): + """set_voice should match by id.""" + mock_engine = MagicMock() + mock_voice = MagicMock() + mock_voice.id = "HKEY_VOICE_1" + mock_voice.name = "Microsoft David" + mock_engine.getProperty.return_value = [mock_voice] + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + result = engine.set_voice("HKEY_VOICE_1") assert result is True + mock_engine.setProperty.assert_called_with("voice", "HKEY_VOICE_1") + + def test_set_voice_not_found(self): + """set_voice should return False if voice not found.""" + mock_engine = MagicMock() + mock_voice = MagicMock() + mock_voice.id = "voice_id_1" + mock_voice.name = "Microsoft David" + mock_engine.getProperty.return_value = [mock_voice] + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + result = engine.set_voice("Nonexistent Voice") + assert result is False + + def test_set_voice_error_returns_false(self): + """set_voice should return False on error.""" + mock_engine = MagicMock() + mock_engine.getProperty.side_effect = RuntimeError("voice error") + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + result = engine.set_voice("Some Voice") + assert result is False + + +class TestCleanup: + """Test cleanup method.""" + + def test_cleanup_dummy_no_crash(self): + """Cleanup on dummy engine should not crash.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + engine.cleanup() # Should not raise + + def test_cleanup_with_engine(self): + """Cleanup should stop pyttsx3 engine.""" + mock_engine = MagicMock() + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + engine.cleanup() + mock_engine.stop.assert_called_once() + + def test_cleanup_engine_error_suppressed(self): + """Cleanup should suppress engine stop errors.""" + mock_engine = MagicMock() + mock_engine.stop.side_effect = RuntimeError("stop error") + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + # Should not raise + engine.cleanup() class TestDummyEngine: @@ -171,14 +519,28 @@ class TestDummyEngine: def test_dummy_engine_does_not_crash(self): """Dummy engine should not crash when TTS unavailable.""" from accessiclock.audio.tts_engine import TTSEngine - + # Force dummy mode engine = TTSEngine(force_dummy=True) - + # These should not raise engine.speak("Test") engine.speak_time(time(12, 0)) voices = engine.list_voices() - + assert engine.engine_type == "dummy" assert voices == [] + + def test_dummy_set_voice_returns_false(self): + """Dummy engine set_voice should always return False.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + assert engine.set_voice("any") is False + + def test_dummy_list_voices_empty(self): + """Dummy engine list_voices should return empty list.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + assert engine.list_voices() == []