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

683 lines
27 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Atlas Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
color: #333;
}
.header {
background: #2c3e50;
color: white;
padding: 1rem 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 1.5rem;
}
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 2rem;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.status-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.status-card h3 {
font-size: 0.9rem;
color: #666;
margin-bottom: 0.5rem;
}
.status-card .value {
font-size: 2rem;
font-weight: bold;
color: #2c3e50;
}
.section {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 2rem;
}
.section h2 {
margin-bottom: 1rem;
color: #2c3e50;
}
.conversation-list {
list-style: none;
}
.conversation-item {
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.conversation-item:hover {
background: #f9f9f9;
}
.conversation-item:last-child {
border-bottom: none;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
}
.badge-family {
background: #3498db;
color: white;
}
.badge-work {
background: #e74c3c;
color: white;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
.error {
background: #fee;
color: #c33;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
}
.admin-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
border-bottom: 2px solid #eee;
}
.admin-tab {
padding: 0.75rem 1.5rem;
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: #666;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.admin-tab.active {
color: #2c3e50;
border-bottom-color: #2c3e50;
font-weight: bold;
}
.admin-tab-content {
display: none;
}
.admin-tab-content.active {
display: block;
}
.kill-switch {
display: flex;
gap: 1rem;
margin: 1rem 0;
flex-wrap: wrap;
}
.kill-button {
padding: 0.75rem 1.5rem;
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: background 0.2s;
}
.kill-button:hover {
background: #c0392b;
}
.kill-button:disabled {
background: #95a5a6;
cursor: not-allowed;
}
.log-entry {
padding: 1rem;
margin: 0.5rem 0;
background: #f9f9f9;
border-left: 3px solid #3498db;
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
}
.log-entry.error {
border-left-color: #e74c3c;
}
.log-filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.log-filters input,
.log-filters select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.token-item,
.device-item {
padding: 1rem;
margin: 0.5rem 0;
background: #f9f9f9;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.revoke-button {
padding: 0.5rem 1rem;
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="header">
<h1>🤖 Atlas Dashboard</h1>
</div>
<div class="container">
<!-- Status Overview -->
<div class="status-grid" id="statusGrid">
<div class="status-card">
<h3>System Status</h3>
<div class="value" id="systemStatus">Loading...</div>
</div>
<div class="status-card">
<h3>Conversations</h3>
<div class="value" id="conversationCount">-</div>
</div>
<div class="status-card">
<h3>Active Timers</h3>
<div class="value" id="timerCount">-</div>
</div>
<div class="status-card">
<h3>Pending Tasks</h3>
<div class="value" id="taskCount">-</div>
</div>
</div>
<!-- Recent Conversations -->
<div class="section">
<h2>Recent Conversations</h2>
<div id="conversationsList" class="loading">Loading conversations...</div>
</div>
<!-- Active Timers -->
<div class="section">
<h2>Active Timers & Reminders</h2>
<div id="timersList" class="loading">Loading timers...</div>
</div>
<!-- Tasks -->
<div class="section">
<h2>Tasks</h2>
<div id="tasksList" class="loading">Loading tasks...</div>
</div>
<!-- Admin Panel -->
<div class="section">
<h2>🔧 Admin Panel</h2>
<div class="admin-tabs">
<button class="admin-tab active" onclick="switchAdminTab('logs')">Log Browser</button>
<button class="admin-tab" onclick="switchAdminTab('kill-switches')">Kill Switches</button>
<button class="admin-tab" onclick="switchAdminTab('access')">Access Control</button>
</div>
<!-- Log Browser Tab -->
<div id="admin-logs" class="admin-tab-content active">
<div class="log-filters">
<input type="text" id="logSearch" placeholder="Search logs..." onkeyup="loadLogs()">
<select id="logLevel" onchange="loadLogs()">
<option value="">All Levels</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
<select id="logAgent" onchange="loadLogs()">
<option value="">All Agents</option>
<option value="family">Family</option>
<option value="work">Work</option>
</select>
<input type="number" id="logLimit" value="50" min="10" max="500" onchange="loadLogs()" placeholder="Limit">
</div>
<div id="logsList" class="loading">Loading logs...</div>
</div>
<!-- Kill Switches Tab -->
<div id="admin-kill-switches" class="admin-tab-content">
<h3>Service Control</h3>
<p style="color: #666; margin-bottom: 1rem;">⚠️ Use with caution. These actions will stop services immediately.</p>
<div class="kill-switch">
<button class="kill-button" onclick="killService('mcp_server')">Stop MCP Server</button>
<button class="kill-button" onclick="killService('family_agent')">Stop Family Agent</button>
<button class="kill-button" onclick="killService('work_agent')">Stop Work Agent</button>
<button class="kill-button" onclick="killService('all')" style="background: #c0392b;">Stop All Services</button>
</div>
<div id="killStatus" style="margin-top: 1rem;"></div>
</div>
<!-- Access Control Tab -->
<div id="admin-access" class="admin-tab-content">
<h3>Revoked Tokens</h3>
<div id="revokedTokensList" class="loading">Loading revoked tokens...</div>
<h3 style="margin-top: 2rem;">Devices</h3>
<div id="devicesList" class="loading">Loading devices...</div>
</div>
</div>
</div>
<script>
const API_BASE = 'http://localhost:8000/api/dashboard';
const ADMIN_API_BASE = 'http://localhost:8000/api/admin';
async function fetchJSON(url) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.error('Fetch error:', error);
throw error;
}
}
async function loadStatus() {
try {
const status = await fetchJSON(`${API_BASE}/status`);
document.getElementById('systemStatus').textContent = status.status;
document.getElementById('conversationCount').textContent = status.counts.conversations;
document.getElementById('timerCount').textContent = status.counts.active_timers;
document.getElementById('taskCount').textContent = status.counts.pending_tasks;
} catch (error) {
document.getElementById('statusGrid').innerHTML =
`<div class="error">Error loading status: ${error.message}</div>`;
}
}
async function loadConversations() {
try {
const data = await fetchJSON(`${API_BASE}/conversations?limit=10`);
const list = document.getElementById('conversationsList');
if (data.conversations.length === 0) {
list.innerHTML = '<p>No conversations yet.</p>';
return;
}
list.innerHTML = '<ul class="conversation-list">' +
data.conversations.map(conv => `
<li class="conversation-item">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<span class="badge badge-${conv.agent_type}">${conv.agent_type}</span>
<span style="margin-left: 1rem;">${conv.session_id.substring(0, 8)}...</span>
</div>
<div style="color: #666; font-size: 0.9rem;">
${new Date(conv.last_activity).toLocaleString()}
</div>
</div>
</li>
`).join('') + '</ul>';
} catch (error) {
document.getElementById('conversationsList').innerHTML =
`<div class="error">Error loading conversations: ${error.message}</div>`;
}
}
async function loadTimers() {
try {
const data = await fetchJSON(`${API_BASE}/timers`);
const list = document.getElementById('timersList');
const allItems = [...data.timers, ...data.reminders];
if (allItems.length === 0) {
list.innerHTML = '<p>No active timers or reminders.</p>';
return;
}
list.innerHTML = '<ul class="conversation-list">' +
allItems.map(item => `
<li class="conversation-item">
<div>
<strong>${item.name}</strong>
<div style="color: #666; font-size: 0.9rem; margin-top: 0.25rem;">
Started: ${new Date(item.started_at).toLocaleString()}
</div>
</div>
</li>
`).join('') + '</ul>';
} catch (error) {
document.getElementById('timersList').innerHTML =
`<div class="error">Error loading timers: ${error.message}</div>`;
}
}
async function loadTasks() {
try {
const data = await fetchJSON(`${API_BASE}/tasks`);
const list = document.getElementById('tasksList');
if (data.tasks.length === 0) {
list.innerHTML = '<p>No tasks.</p>';
return;
}
list.innerHTML = '<ul class="conversation-list">' +
data.tasks.slice(0, 10).map(task => `
<li class="conversation-item">
<div>
<strong>${task.title}</strong>
<span class="badge" style="background: #95a5a6; color: white; margin-left: 0.5rem;">
${task.status}
</span>
<div style="color: #666; font-size: 0.9rem; margin-top: 0.25rem;">
${task.description.substring(0, 100)}${task.description.length > 100 ? '...' : ''}
</div>
</div>
</li>
`).join('') + '</ul>';
} catch (error) {
document.getElementById('tasksList').innerHTML =
`<div class="error">Error loading tasks: ${error.message}</div>`;
}
}
// Admin Panel Functions
function switchAdminTab(tab) {
// Hide all tabs
document.querySelectorAll('.admin-tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.admin-tab').forEach(el => el.classList.remove('active'));
// Show selected tab
document.getElementById(`admin-${tab}`).classList.add('active');
event.target.classList.add('active');
// Load tab data
if (tab === 'logs') {
loadLogs();
} else if (tab === 'access') {
loadRevokedTokens();
loadDevices();
}
}
async function loadLogs() {
try {
const search = document.getElementById('logSearch').value;
const level = document.getElementById('logLevel').value;
const agent = document.getElementById('logAgent').value;
const limit = document.getElementById('logLimit').value || 50;
const params = new URLSearchParams({ limit });
if (search) params.append('search', search);
if (level) params.append('level', level);
if (agent) params.append('agent_type', agent);
const data = await fetchJSON(`${ADMIN_API_BASE}/logs/enhanced?${params}`);
const list = document.getElementById('logsList');
if (data.logs.length === 0) {
list.innerHTML = '<p>No logs found.</p>';
return;
}
list.innerHTML = data.logs.map(log => {
const levelClass = log.level === 'ERROR' ? 'error' : '';
const isError = log.level === 'ERROR' || log.type === 'error';
// Format log entry based on type
let logContent = '';
if (isError) {
// Error log - highlight error message
logContent = `
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
<div>
<strong>${log.timestamp || 'Unknown'}</strong>
<span style="margin-left: 0.5rem; padding: 0.25rem 0.5rem; background: #e74c3c; color: white; border-radius: 4px; font-size: 0.75rem;">
${log.level || 'ERROR'}
</span>
${log.agent_type ? `<span style="margin-left: 0.5rem; padding: 0.25rem 0.5rem; background: #3498db; color: white; border-radius: 4px; font-size: 0.75rem;">${log.agent_type}</span>` : ''}
</div>
</div>
<div style="color: #e74c3c; font-weight: bold; margin: 0.5rem 0;">
${log.error || log.message || 'Error occurred'}
</div>
${log.url ? `<div style="color: #666; font-size: 0.9rem;">URL: ${log.url}</div>` : ''}
${log.request_id ? `<div style="color: #666; font-size: 0.9rem;">Request ID: ${log.request_id}</div>` : ''}
<details style="margin-top: 0.5rem;">
<summary style="cursor: pointer; color: #666; font-size: 0.85rem;">View full details</summary>
<pre style="margin-top: 0.5rem; white-space: pre-wrap; font-size: 0.8rem;">${JSON.stringify(log, null, 2)}</pre>
</details>
`;
} else {
// Info log - show key metrics
const toolsCalled = log.tools_called && log.tools_called.length > 0
? log.tools_called.join(', ')
: 'None';
logContent = `
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
<div>
<strong>${log.timestamp || 'Unknown'}</strong>
<span style="margin-left: 0.5rem; padding: 0.25rem 0.5rem; background: #3498db; color: white; border-radius: 4px; font-size: 0.75rem;">
${log.level || 'INFO'}
</span>
${log.agent_type ? `<span style="margin-left: 0.5rem; padding: 0.25rem 0.5rem; background: #95a5a6; color: white; border-radius: 4px; font-size: 0.75rem;">${log.agent_type}</span>` : ''}
</div>
</div>
<div style="margin: 0.5rem 0;">
<div style="font-weight: bold; margin-bottom: 0.5rem;">💬 ${log.prompt || log.message || 'Request'}</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.5rem; font-size: 0.85rem; color: #666;">
${log.latency_ms ? `<div>⏱️ Latency: ${log.latency_ms}ms</div>` : ''}
${log.tokens_in ? `<div>📥 Tokens In: ${log.tokens_in}</div>` : ''}
${log.tokens_out ? `<div>📤 Tokens Out: ${log.tokens_out}</div>` : ''}
${log.model ? `<div>🤖 Model: ${log.model}</div>` : ''}
${log.tools_called && log.tools_called.length > 0 ? `<div>🔧 Tools: ${toolsCalled}</div>` : ''}
</div>
</div>
<details style="margin-top: 0.5rem;">
<summary style="cursor: pointer; color: #666; font-size: 0.85rem;">View full details</summary>
<pre style="margin-top: 0.5rem; white-space: pre-wrap; font-size: 0.8rem;">${JSON.stringify(log, null, 2)}</pre>
</details>
`;
}
return `
<div class="log-entry ${levelClass}">
${logContent}
</div>
`;
}).join('');
} catch (error) {
document.getElementById('logsList').innerHTML =
`<div class="error">Error loading logs: ${error.message}</div>`;
}
}
async function killService(service) {
if (!confirm(`Are you sure you want to stop ${service}?`)) {
return;
}
try {
const response = await fetch(`${ADMIN_API_BASE}/kill-switch/${service}`, {
method: 'POST'
});
const data = await response.json();
document.getElementById('killStatus').innerHTML =
`<div style="padding: 1rem; background: ${data.success ? '#d4edda' : '#f8d7da'}; border-radius: 4px;">
${data.message || data.detail || 'Action completed'}
</div>`;
} catch (error) {
document.getElementById('killStatus').innerHTML =
`<div class="error">Error: ${error.message}</div>`;
}
}
async function loadRevokedTokens() {
try {
const data = await fetchJSON(`${ADMIN_API_BASE}/tokens/revoked`);
const list = document.getElementById('revokedTokensList');
if (data.tokens.length === 0) {
list.innerHTML = '<p>No revoked tokens.</p>';
return;
}
list.innerHTML = data.tokens.map(token => `
<div class="token-item">
<div>
<strong>${token.token_id}</strong>
<div style="color: #666; font-size: 0.9rem;">
Revoked: ${token.revoked_at} | Reason: ${token.reason || 'None'}
</div>
</div>
</div>
`).join('');
} catch (error) {
document.getElementById('revokedTokensList').innerHTML =
`<div class="error">Error loading tokens: ${error.message}</div>`;
}
}
async function loadDevices() {
try {
const data = await fetchJSON(`${ADMIN_API_BASE}/devices`);
const list = document.getElementById('devicesList');
if (data.devices.length === 0) {
list.innerHTML = '<p>No devices registered.</p>';
return;
}
list.innerHTML = data.devices.map(device => `
<div class="device-item">
<div>
<strong>${device.name || device.device_id}</strong>
<div style="color: #666; font-size: 0.9rem;">
Status: ${device.status} | Last seen: ${device.last_seen || 'Never'}
</div>
</div>
${device.status === 'active' ?
`<button class="revoke-button" onclick="revokeDevice('${device.device_id}')">Revoke</button>` :
'<span style="color: #e74c3c;">Revoked</span>'
}
</div>
`).join('');
} catch (error) {
document.getElementById('devicesList').innerHTML =
`<div class="error">Error loading devices: ${error.message}</div>`;
}
}
async function revokeDevice(deviceId) {
if (!confirm(`Are you sure you want to revoke access for device ${deviceId}?`)) {
return;
}
try {
const response = await fetch(`${ADMIN_API_BASE}/devices/${deviceId}/revoke`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
loadDevices();
} else {
alert(data.message || 'Failed to revoke device');
}
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Load all data on page load
async function init() {
await Promise.all([
loadStatus(),
loadConversations(),
loadTimers(),
loadTasks()
]);
// Refresh every 30 seconds
setInterval(async () => {
await Promise.all([
loadStatus(),
loadConversations(),
loadTimers(),
loadTasks()
]);
}, 30000);
}
init();
</script>
</body>
</html>