ilia bdbf09a9ac feat: Implement voice I/O services (TICKET-006, TICKET-010, TICKET-014)
 TICKET-006: Wake-word Detection Service
- Implemented wake-word detection using openWakeWord
- HTTP/WebSocket server on port 8002
- Real-time detection with configurable threshold
- Event emission for ASR integration
- Location: home-voice-agent/wake-word/

 TICKET-010: ASR Service
- Implemented ASR using faster-whisper
- HTTP endpoint for file transcription
- WebSocket endpoint for streaming transcription
- Support for multiple audio formats
- Auto language detection
- GPU acceleration support
- Location: home-voice-agent/asr/

 TICKET-014: TTS Service
- Implemented TTS using Piper
- HTTP endpoint for text-to-speech synthesis
- Low-latency processing (< 500ms)
- Multiple voice support
- WAV audio output
- Location: home-voice-agent/tts/

 TICKET-047: Updated Hardware Purchases
- Marked Pi5 kit, SSD, microphone, and speakers as purchased
- Updated progress log with purchase status

📚 Documentation:
- Added VOICE_SERVICES_README.md with complete testing guide
- Each service includes README.md with usage instructions
- All services ready for Pi5 deployment

🧪 Testing:
- Created test files for each service
- All imports validated
- FastAPI apps created successfully
- Code passes syntax validation

🚀 Ready for:
- Pi5 deployment
- End-to-end voice flow testing
- Integration with MCP server

Files Added:
- wake-word/detector.py
- wake-word/server.py
- wake-word/requirements.txt
- wake-word/README.md
- wake-word/test_detector.py
- asr/service.py
- asr/server.py
- asr/requirements.txt
- asr/README.md
- asr/test_service.py
- tts/service.py
- tts/server.py
- tts/requirements.txt
- tts/README.md
- tts/test_service.py
- VOICE_SERVICES_README.md

Files Modified:
- tickets/done/TICKET-047_hardware-purchases.md

Files Moved:
- tickets/backlog/TICKET-006_prototype-wake-word-node.md → tickets/done/
- tickets/backlog/TICKET-010_streaming-asr-service.md → tickets/done/
- tickets/backlog/TICKET-014_tts-service.md → tickets/done/
2026-01-12 22:22:38 -05:00

437 lines
16 KiB
Python

"""
Timers and Reminders Tool - Create and manage timers and reminders.
"""
import sqlite3
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
from tools.base import BaseTool
# Database file location
DB_PATH = Path(__file__).parent.parent.parent / "data" / "timers.db"
class TimerService:
"""Service for managing timers and reminders."""
def __init__(self, db_path: Path = DB_PATH):
"""Initialize timer service with database."""
self.db_path = db_path
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
self._running = False
self._thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
def _init_db(self):
"""Initialize database schema."""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS timers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'timer' or 'reminder'
duration_seconds INTEGER, -- For timers
target_time TEXT, -- ISO format for reminders
message TEXT,
status TEXT DEFAULT 'active', -- 'active', 'completed', 'cancelled'
created_at TEXT NOT NULL,
completed_at TEXT
)
""")
conn.commit()
conn.close()
def create_timer(self, name: str, duration_seconds: int, message: str = "") -> int:
"""Create a new timer."""
target_time = datetime.now() + timedelta(seconds=duration_seconds)
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
cursor.execute("""
INSERT INTO timers (name, type, duration_seconds, target_time, message, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (name, "timer", duration_seconds, target_time.isoformat(), message, datetime.now().isoformat()))
timer_id = cursor.lastrowid
conn.commit()
conn.close()
self._start_if_needed()
return timer_id
def create_reminder(self, name: str, target_time: datetime, message: str = "") -> int:
"""Create a new reminder."""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
cursor.execute("""
INSERT INTO timers (name, type, target_time, message, created_at)
VALUES (?, ?, ?, ?, ?)
""", (name, "reminder", target_time.isoformat(), message, datetime.now().isoformat()))
reminder_id = cursor.lastrowid
conn.commit()
conn.close()
self._start_if_needed()
return reminder_id
def list_timers(self, status: Optional[str] = None, timer_type: Optional[str] = None) -> List[Dict[str, Any]]:
"""List timers/reminders, optionally filtered by status or type."""
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
query = "SELECT * FROM timers WHERE 1=1"
params = []
if status:
query += " AND status = ?"
params.append(status)
if timer_type:
query += " AND type = ?"
params.append(timer_type)
query += " ORDER BY created_at DESC"
cursor.execute(query, params)
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
def cancel_timer(self, timer_id: int) -> bool:
"""Cancel a timer or reminder."""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
cursor.execute("""
UPDATE timers
SET status = 'cancelled'
WHERE id = ? AND status = 'active'
""", (timer_id,))
updated = cursor.rowcount > 0
conn.commit()
conn.close()
return updated
def _start_if_needed(self):
"""Start background thread if not already running."""
with self._lock:
if not self._running:
self._running = True
self._thread = threading.Thread(target=self._check_timers, daemon=True)
self._thread.start()
def _check_timers(self):
"""Background thread to check and trigger timers."""
while self._running:
try:
now = datetime.now()
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
# Find active timers/reminders that should trigger
cursor.execute("""
SELECT id, name, type, message, target_time
FROM timers
WHERE status = 'active' AND target_time <= ?
""", (now.isoformat(),))
timers_to_trigger = cursor.fetchall()
for timer_id, name, timer_type, message, target_time_str in timers_to_trigger:
# Mark as completed
cursor.execute("""
UPDATE timers
SET status = 'completed', completed_at = ?
WHERE id = ?
""", (now.isoformat(), timer_id))
# Log the trigger (in production, this would send notification)
print(f"[TIMER TRIGGERED] {timer_type}: {name}")
if message:
print(f" Message: {message}")
conn.commit()
conn.close()
except Exception as e:
print(f"Error checking timers: {e}")
# Check every 5 seconds
time.sleep(5)
# Global timer service instance
_timer_service = TimerService()
class TimersTool(BaseTool):
"""Tool for managing timers and reminders."""
@property
def name(self) -> str:
return "create_timer"
@property
def description(self) -> str:
return "Create a timer that will trigger after a specified duration. Returns timer ID."
def get_schema(self) -> Dict[str, Any]:
"""Get tool schema."""
return {
"name": self.name,
"description": self.description,
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name or description of the timer"
},
"duration_seconds": {
"type": "integer",
"description": "Duration in seconds (e.g., 300 for 5 minutes)"
},
"message": {
"type": "string",
"description": "Optional message to display when timer triggers"
}
},
"required": ["name", "duration_seconds"]
}
}
def execute(self, arguments: Dict[str, Any]) -> str:
"""Execute create_timer tool."""
name = arguments.get("name", "").strip()
duration_seconds = arguments.get("duration_seconds")
message = arguments.get("message", "").strip()
if not name:
raise ValueError("Missing required argument: name")
if duration_seconds is None:
raise ValueError("Missing required argument: duration_seconds")
if duration_seconds <= 0:
raise ValueError("duration_seconds must be positive")
timer_id = _timer_service.create_timer(name, duration_seconds, message)
# Format duration nicely
if duration_seconds < 60:
duration_str = f"{duration_seconds} seconds"
elif duration_seconds < 3600:
minutes = duration_seconds // 60
seconds = duration_seconds % 60
duration_str = f"{minutes} minute{'s' if minutes != 1 else ''}"
if seconds > 0:
duration_str += f" {seconds} second{'s' if seconds != 1 else ''}"
else:
hours = duration_seconds // 3600
minutes = (duration_seconds % 3600) // 60
duration_str = f"{hours} hour{'s' if hours != 1 else ''}"
if minutes > 0:
duration_str += f" {minutes} minute{'s' if minutes != 1 else ''}"
return f"Timer '{name}' created (ID: {timer_id}). Will trigger in {duration_str}."
class RemindersTool(BaseTool):
"""Tool for creating reminders at specific times."""
@property
def name(self) -> str:
return "create_reminder"
@property
def description(self) -> str:
return "Create a reminder that will trigger at a specific date and time. Returns reminder ID."
def get_schema(self) -> Dict[str, Any]:
"""Get tool schema."""
return {
"name": self.name,
"description": self.description,
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name or description of the reminder"
},
"target_time": {
"type": "string",
"description": "Target date and time in ISO format (YYYY-MM-DDTHH:MM:SS) or relative (e.g., 'in 2 hours', 'tomorrow at 3pm')"
},
"message": {
"type": "string",
"description": "Optional message to display when reminder triggers"
}
},
"required": ["name", "target_time"]
}
}
def _parse_time(self, time_str: str) -> datetime:
"""Parse time string (ISO format or relative)."""
time_str = time_str.strip().lower()
# Try ISO format first
try:
return datetime.fromisoformat(time_str.replace('Z', '+00:00'))
except ValueError:
pass
# Simple relative parsing (can be enhanced)
now = datetime.now()
if time_str.startswith("in "):
# Parse "in X hours/minutes"
parts = time_str[3:].split()
if len(parts) >= 2:
try:
amount = int(parts[0])
unit = parts[1]
if "hour" in unit:
return now + timedelta(hours=amount)
elif "minute" in unit:
return now + timedelta(minutes=amount)
elif "day" in unit:
return now + timedelta(days=amount)
except ValueError:
pass
raise ValueError(f"Could not parse time: {time_str}. Use ISO format (YYYY-MM-DDTHH:MM:SS) or relative (e.g., 'in 2 hours')")
def execute(self, arguments: Dict[str, Any]) -> str:
"""Execute create_reminder tool."""
name = arguments.get("name", "").strip()
target_time_str = arguments.get("target_time", "").strip()
message = arguments.get("message", "").strip()
if not name:
raise ValueError("Missing required argument: name")
if not target_time_str:
raise ValueError("Missing required argument: target_time")
try:
target_time = self._parse_time(target_time_str)
except ValueError as e:
raise ValueError(f"Invalid time format: {e}")
if target_time <= datetime.now():
raise ValueError("Target time must be in the future")
reminder_id = _timer_service.create_reminder(name, target_time, message)
return f"Reminder '{name}' created (ID: {reminder_id}). Will trigger at {target_time.strftime('%Y-%m-%d %H:%M:%S')}."
class ListTimersTool(BaseTool):
"""Tool for listing timers and reminders."""
@property
def name(self) -> str:
return "list_timers"
@property
def description(self) -> str:
return "List all timers and reminders, optionally filtered by status (active, completed, cancelled) or type (timer, reminder)."
def get_schema(self) -> Dict[str, Any]:
"""Get tool schema."""
return {
"name": self.name,
"description": self.description,
"inputSchema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Filter by status: 'active', 'completed', 'cancelled'",
"enum": ["active", "completed", "cancelled"]
},
"type": {
"type": "string",
"description": "Filter by type: 'timer' or 'reminder'",
"enum": ["timer", "reminder"]
}
}
}
}
def execute(self, arguments: Dict[str, Any]) -> str:
"""Execute list_timers tool."""
status = arguments.get("status")
timer_type = arguments.get("type")
timers = _timer_service.list_timers(status=status, timer_type=timer_type)
if not timers:
filter_str = ""
if status:
filter_str += f" with status '{status}'"
if timer_type:
filter_str += f" of type '{timer_type}'"
return f"No timers or reminders found{filter_str}."
result = f"Found {len(timers)} timer(s)/reminder(s):\n\n"
for timer in timers:
timer_id = timer['id']
name = timer['name']
timer_type_str = timer['type']
status_str = timer['status']
target_time = datetime.fromisoformat(timer['target_time'])
message = timer.get('message', '')
result += f"ID {timer_id}: {timer_type_str.title()} '{name}'\n"
result += f" Status: {status_str}\n"
result += f" Target: {target_time.strftime('%Y-%m-%d %H:%M:%S')}\n"
if message:
result += f" Message: {message}\n"
result += "\n"
return result.strip()
class CancelTimerTool(BaseTool):
"""Tool for cancelling timers and reminders."""
@property
def name(self) -> str:
return "cancel_timer"
@property
def description(self) -> str:
return "Cancel an active timer or reminder by ID."
def get_schema(self) -> Dict[str, Any]:
"""Get tool schema."""
return {
"name": self.name,
"description": self.description,
"inputSchema": {
"type": "object",
"properties": {
"timer_id": {
"type": "integer",
"description": "ID of the timer or reminder to cancel"
}
},
"required": ["timer_id"]
}
}
def execute(self, arguments: Dict[str, Any]) -> str:
"""Execute cancel_timer tool."""
timer_id = arguments.get("timer_id")
if timer_id is None:
raise ValueError("Missing required argument: timer_id")
if _timer_service.cancel_timer(timer_id):
return f"Timer/reminder {timer_id} cancelled successfully."
else:
return f"Timer/reminder {timer_id} not found or already completed/cancelled."