diff --git a/docs/AUDIO_TRIGGER_POINTS.md b/docs/AUDIO_TRIGGER_POINTS.md new file mode 100644 index 0000000..ef4011d --- /dev/null +++ b/docs/AUDIO_TRIGGER_POINTS.md @@ -0,0 +1,159 @@ +# Audio Notification Trigger Points + +This document identifies where audio notifications should be triggered in the haflow workflow system. + +## Identified Trigger Points + +### 1. Mission Moves to Waiting Human State +**Location**: `packages/backend/src/services/mission-engine.ts:62-63` +**Event**: Mission status changes to `waiting_human` after an agent step completes +**Method**: `advanceToNextStep()` when next step is type `human-gate` +**Data Available**: +- `missionId`: The mission ID +- `meta.current_step`: Current step index +- Next step type and name + +**Notification Details**: +- Priority: `high` (human review required) +- Action: Notify humans that a mission requires review + +### 2. Mission Moves to Waiting Code Review State +**Location**: `packages/backend/src/services/mission-engine.ts:64-65` +**Event**: Mission status changes to `waiting_code_review` after agent step completes +**Method**: `advanceToNextStep()` when next step is type `code-review` +**Data Available**: +- `missionId`: The mission ID +- `meta.current_step`: Current step index +- Next step type and name + +**Notification Details**: +- Priority: `standard` (code review needed) +- Action: Notify humans that a mission has code changes requiring review + +### 3. Mission Created +**Location**: `packages/backend/src/services/mission-store.ts:36-78` +**Event**: New mission is created +**Method**: `createMission()` determines initial status +**Data Available**: +- `missionId`: The mission ID +- `title`: Mission title +- `type`: Mission type (feature, fix, bugfix, hotfix, enhance) +- `first_step`: First step in workflow + +**Notification Details**: +- Priority: `low` (informational) +- Action: Notify that a new mission has been created + +## Notification Handler Integration Points + +### Potential Integration Locations + +1. **In `advanceToNextStep()` after status change** + - Emit event when entering `waiting_human` or `waiting_code_review` states + - Include step information and mission details + +2. **In `startAgentStep()` completion** + - Emit event when agent step completes + - Include completion details + +3. **Custom Event Emitter Pattern** + - Create an event emitter that publishes workflow events + - Audio notification system subscribes to specific events + +## Workflow Status Transitions + +``` +draft + ↓ +[human-gate] → waiting_human (TRIGGER: Audio notification for high priority) + ↓ +[agent] → ready + ↓ +[human-gate] → waiting_human (TRIGGER: Audio notification for high priority) + ↓ +[agent] → ready + ↓ +[code-review] → waiting_code_review (TRIGGER: Audio notification for standard priority) + ↓ +completed +``` + +## Implementation Strategy + +### Option 1: Direct Event Emission +Emit audio notification events directly in `mission-engine.ts` when status changes occur. + +**Pros**: +- Simple, direct implementation +- Clear trigger point visibility + +**Cons**: +- Couples business logic with notification logic +- Hard to test independently + +### Option 2: Event Bus Pattern +Create an event bus that publishes workflow events to subscribers. + +**Pros**: +- Decoupled architecture +- Easy to test and extend +- Can support multiple subscribers + +**Cons**: +- Additional abstraction layer +- Requires event bus implementation + +### Option 3: Webhook Pattern +When status changes, make HTTP calls to registered webhook endpoints. + +**Pros**: +- Works for distributed systems +- External services can subscribe + +**Cons**: +- Network overhead +- Requires webhook endpoint in frontend + +## Recommended Approach + +**Implement Event Bus Pattern** with the following structure: + +1. Create `src/services/event-bus.ts` in backend +2. Emit `mission:waiting_human` and `mission:waiting_code_review` events +3. Backend maintains a simple in-memory listener for audio notifications +4. Frontend can subscribe via WebSocket or polling for real-time notifications + +## Example Event Structure + +```typescript +interface WorkflowEvent { + type: 'mission:waiting_human' | 'mission:waiting_code_review' | 'mission:created'; + missionId: string; + timestamp: number; + data: { + title: string; + stepName: string; + stepIndex: number; + priority: 'high' | 'standard' | 'low'; + }; +} +``` + +## Frontend Integration + +The frontend audio notification system should: + +1. Connect to the backend API polling endpoint +2. When new notification event arrives, check user preferences +3. If audio enabled for priority level, play the appropriate sound +4. Display visual notification + +## Implementation Checklist + +- [ ] Event structure defined +- [ ] Event emission points identified and documented +- [ ] Audio notification handler created +- [ ] Frontend polling or WebSocket implementation +- [ ] User preference integration +- [ ] Error handling and fallbacks +- [ ] Tests for event emission and handling diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index ac1dc50..90dd0e8 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -2,11 +2,13 @@ import { createServer } from './server.js'; import { config } from './utils/config.js'; import { missionStore } from './services/mission-store.js'; import { missionEngine } from './services/mission-engine.js'; +import { userPreferencesService } from './services/user-preferences.js'; async function main() { // Initialize stores and engine await missionStore.init(); await missionEngine.init(); + await userPreferencesService.init(); const app = createServer(); diff --git a/packages/backend/src/routes/user-preferences.ts b/packages/backend/src/routes/user-preferences.ts new file mode 100644 index 0000000..b196046 --- /dev/null +++ b/packages/backend/src/routes/user-preferences.ts @@ -0,0 +1,66 @@ +import { Router, type Router as RouterType, type Request, type Response } from 'express'; +import { userPreferencesService } from '../services/user-preferences.js'; +import { sendSuccess, sendError } from '../utils/response.js'; +import type { AudioNotificationPreferences } from '@haflow/shared'; + +export const userPreferencesRoutes: RouterType = Router(); + +// GET /api/user/preferences - Fetch current user preferences +userPreferencesRoutes.get('/preferences', async (req: Request, res: Response, next) => { + try { + // Get user ID from session/auth - for now use a default + // In a real app, this would come from req.user or similar + const userId = (req as any).userId || 'default-user'; + + const preferences = await userPreferencesService.getUserPreferences(userId); + sendSuccess(res, preferences); + } catch (error) { + next(error); + } +}); + +// PUT /api/user/preferences - Update user preferences +userPreferencesRoutes.put('/preferences', async (req: Request, res: Response, next) => { + try { + const userId = (req as any).userId || 'default-user'; + const preferences = req.body as AudioNotificationPreferences; + + await userPreferencesService.updateUserPreferences(userId, preferences); + + // Return updated preferences + const updated = await userPreferencesService.getUserPreferences(userId); + sendSuccess(res, updated); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('validation')) { + return sendError(res, `Invalid preferences: ${error.message}`, 400); + } + return sendError(res, error.message, 500); + } + next(error); + } +}); + +// POST /api/user/preferences/reset - Reset to default preferences +userPreferencesRoutes.post('/preferences/reset', async (req: Request, res: Response, next) => { + try { + const userId = (req as any).userId || 'default-user'; + + await userPreferencesService.resetToDefaults(userId); + + const defaults = await userPreferencesService.getUserPreferences(userId); + sendSuccess(res, defaults); + } catch (error) { + next(error); + } +}); + +// GET /api/user/preferences/defaults - Get default preferences +userPreferencesRoutes.get('/preferences/defaults', async (req: Request, res: Response) => { + try { + const defaults = userPreferencesService.getDefaultPreferences(); + sendSuccess(res, defaults); + } catch (error) { + sendError(res, 'Failed to get default preferences', 500); + } +}); diff --git a/packages/backend/src/server.ts b/packages/backend/src/server.ts index 2f3f957..66c2612 100644 --- a/packages/backend/src/server.ts +++ b/packages/backend/src/server.ts @@ -3,6 +3,7 @@ import cors from 'cors'; import { missionRoutes, workflowRoutes } from './routes/missions.js'; import { transcriptionRoutes } from './routes/transcription.js'; import { systemRoutes } from './routes/system.js'; +import { userPreferencesRoutes } from './routes/user-preferences.js'; export function createServer(): Express { const app = express(); @@ -17,6 +18,7 @@ export function createServer(): Express { app.use('/api/workflows', workflowRoutes); app.use('/api/transcribe', transcriptionRoutes); app.use('/api/system', systemRoutes); + app.use('/api/user', userPreferencesRoutes); // Error handler app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { diff --git a/packages/backend/src/services/user-preferences.ts b/packages/backend/src/services/user-preferences.ts new file mode 100644 index 0000000..1f61b5a --- /dev/null +++ b/packages/backend/src/services/user-preferences.ts @@ -0,0 +1,91 @@ +import { mkdir, readFile, writeFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import type { AudioNotificationPreferences } from '@haflow/shared'; +import { AudioNotificationPreferencesSchema } from '@haflow/shared'; +import { config } from '../utils/config.js'; + +const preferencesDir = () => { + const dir = join(config.haflowHome, 'user-preferences'); + return dir; +}; + +const preferenceFile = (userId: string) => join(preferencesDir(), `${userId}.json`); + +// Default preferences for new users +const getDefaultPreferences = (): AudioNotificationPreferences => ({ + audioNotifications: { + enabled: false, + volume: 50, + profiles: { + highPriority: { sound: 'alert-urgent.wav', enabled: true }, + standardPriority: { sound: 'alert-standard.wav', enabled: true }, + lowPriority: { sound: 'alert-low.wav', enabled: false }, + }, + }, + visualNotifications: { + enabled: true, + }, +}); + +async function init(): Promise { + const dir = preferencesDir(); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } +} + +async function getUserPreferences(userId: string): Promise { + try { + const path = preferenceFile(userId); + + if (!existsSync(path)) { + // Return default preferences for new users + return getDefaultPreferences(); + } + + const content = await readFile(path, 'utf-8'); + const parsed = JSON.parse(content); + + // Validate against schema + return AudioNotificationPreferencesSchema.parse(parsed); + } catch (error) { + console.error(`Failed to load preferences for user ${userId}:`, error); + return getDefaultPreferences(); + } +} + +async function updateUserPreferences( + userId: string, + preferences: AudioNotificationPreferences +): Promise { + try { + // Validate schema + const validated = AudioNotificationPreferencesSchema.parse(preferences); + + const path = preferenceFile(userId); + const dir = preferencesDir(); + + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } + + // Write preferences file + await writeFile(path, JSON.stringify(validated, null, 2)); + } catch (error) { + throw new Error(`Failed to update preferences for user ${userId}: ${error}`); + } +} + +async function resetToDefaults(userId: string): Promise { + const defaults = getDefaultPreferences(); + await updateUserPreferences(userId, defaults); +} + +export const userPreferencesService = { + init, + getUserPreferences, + updateUserPreferences, + resetToDefaults, + getDefaultPreferences, +}; diff --git a/packages/frontend/src/hooks/useAudioNotification.ts b/packages/frontend/src/hooks/useAudioNotification.ts new file mode 100644 index 0000000..02f811a --- /dev/null +++ b/packages/frontend/src/hooks/useAudioNotification.ts @@ -0,0 +1,116 @@ +import { useEffect, useCallback, useRef } from 'react'; +import { AudioNotificationManager } from '../services/AudioNotificationManager'; +import { AudioCompatibility } from '../utils/AudioCompatibility'; + +interface AudioNotificationConfig { + enabled: boolean; + volume: number; + highPrioritySound: string; + standardPrioritySound: string; + lowPrioritySound: string; +} + +const defaultConfig: AudioNotificationConfig = { + enabled: true, + volume: 50, + highPrioritySound: 'alert-urgent.wav', + standardPrioritySound: 'alert-standard.wav', + lowPrioritySound: 'alert-low.wav', +}; + +export function useAudioNotification(config: Partial = {}) { + const audioManagerRef = useRef(null); + const configRef = useRef({ ...defaultConfig, ...config }); + const isInitializedRef = useRef(false); + + // Initialize audio manager + useEffect(() => { + if (!isInitializedRef.current && AudioCompatibility.isBrowserSupported()) { + const manager = new AudioNotificationManager({ + audioAssetsUrl: '/audio', + preloadSounds: true, + debounceInterval: 2000, + enableLogging: false, + }); + + manager.initialize().then(() => { + // Preload sounds + manager.preloadSounds([ + configRef.current.highPrioritySound, + configRef.current.standardPrioritySound, + configRef.current.lowPrioritySound, + ]); + }); + + audioManagerRef.current = manager; + isInitializedRef.current = true; + } + + return () => { + // Cleanup on unmount + if (audioManagerRef.current) { + audioManagerRef.current.destroy(); + audioManagerRef.current = null; + isInitializedRef.current = false; + } + }; + }, []); + + const playNotification = useCallback( + (priority: 'high' | 'standard' | 'low' = 'standard') => { + if (!configRef.current.enabled) return; + + const manager = audioManagerRef.current; + if (!manager) return; + + const soundMap = { + high: configRef.current.highPrioritySound, + standard: configRef.current.standardPrioritySound, + low: configRef.current.lowPrioritySound, + }; + + const soundId = soundMap[priority]; + manager.play(soundId, configRef.current.volume, priority); + }, + [] + ); + + const setVolume = useCallback((volume: number) => { + configRef.current.volume = Math.min(Math.max(volume, 0), 100); + const manager = audioManagerRef.current; + if (manager) { + manager.setVolume(configRef.current.volume); + } + }, []); + + const setEnabled = useCallback((enabled: boolean) => { + configRef.current.enabled = enabled; + }, []); + + const playPreviewSound = useCallback(async (priority: 'high' | 'standard' | 'low' = 'standard') => { + const manager = audioManagerRef.current; + if (!manager) return; + + const soundMap = { + high: configRef.current.highPrioritySound, + standard: configRef.current.standardPrioritySound, + low: configRef.current.lowPrioritySound, + }; + + const soundId = soundMap[priority]; + try { + await manager.playPreviewSound(soundId, configRef.current.volume); + } catch (error) { + console.error('Failed to play preview sound:', error); + } + }, []); + + return { + playNotification, + setVolume, + setEnabled, + playPreviewSound, + isSupported: AudioCompatibility.isBrowserSupported(), + config: configRef.current, + }; +} diff --git a/packages/frontend/src/services/AudioCacheService.ts b/packages/frontend/src/services/AudioCacheService.ts new file mode 100644 index 0000000..69b7296 --- /dev/null +++ b/packages/frontend/src/services/AudioCacheService.ts @@ -0,0 +1,318 @@ +import type { AudioAssetMetadata, CacheStats } from '@haflow/shared'; + +export class AudioCacheService { + private db: IDBDatabase | null = null; + private readonly STORE_NAME = 'audioNotifications'; + private readonly METADATA_STORE = 'audioMetadata'; + private readonly DB_NAME = 'audio_cache_db'; + private readonly DB_VERSION = 1; + private stats = { + hits: 0, + misses: 0, + lastCleanup: Date.now(), + }; + + async initialize(): Promise { + return new Promise((resolve, reject) => { + if (!window.indexedDB) { + reject(new Error('IndexedDB is not supported in this browser')); + return; + } + + const request = window.indexedDB.open(this.DB_NAME, this.DB_VERSION); + + request.onerror = () => { + reject(new Error(`Failed to open IndexedDB: ${request.error?.message}`)); + }; + + request.onsuccess = () => { + this.db = request.result; + resolve(); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Create object stores if they don't exist + if (!db.objectStoreNames.contains(this.STORE_NAME)) { + db.createObjectStore(this.STORE_NAME, { keyPath: 'soundId' }); + } + + if (!db.objectStoreNames.contains(this.METADATA_STORE)) { + db.createObjectStore(this.METADATA_STORE, { keyPath: 'id' }); + } + }; + }); + } + + async saveAudio( + soundId: string, + audioData: ArrayBuffer, + metadata: AudioAssetMetadata + ): Promise { + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Database not initialized')); + return; + } + + const transaction = this.db.transaction( + [this.STORE_NAME, this.METADATA_STORE], + 'readwrite' + ); + + // Save audio data + const audioStore = transaction.objectStore(this.STORE_NAME); + const audioRequest = audioStore.put({ + soundId, + data: audioData, + savedAt: Date.now(), + }); + + // Save metadata + const metadataStore = transaction.objectStore(this.METADATA_STORE); + const metadataRequest = metadataStore.put(metadata); + + transaction.oncomplete = () => { + resolve(); + }; + + transaction.onerror = () => { + reject(new Error(`Failed to save audio: ${transaction.error?.message}`)); + }; + + audioRequest.onerror = () => { + reject(new Error(`Failed to save audio data: ${audioRequest.error?.message}`)); + }; + + metadataRequest.onerror = () => { + reject(new Error(`Failed to save metadata: ${metadataRequest.error?.message}`)); + }; + }); + } + + async getAudio(soundId: string): Promise { + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Database not initialized')); + return; + } + + const transaction = this.db.transaction(this.STORE_NAME, 'readonly'); + const store = transaction.objectStore(this.STORE_NAME); + const request = store.get(soundId); + + request.onsuccess = () => { + if (request.result) { + this.stats.hits++; + resolve(request.result.data as ArrayBuffer); + } else { + this.stats.misses++; + resolve(null); + } + }; + + request.onerror = () => { + reject(new Error(`Failed to retrieve audio: ${request.error?.message}`)); + }; + }); + } + + async removeAudio(soundId: string): Promise { + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Database not initialized')); + return; + } + + const transaction = this.db.transaction( + [this.STORE_NAME, this.METADATA_STORE], + 'readwrite' + ); + + const audioStore = transaction.objectStore(this.STORE_NAME); + const metadataStore = transaction.objectStore(this.METADATA_STORE); + + audioStore.delete(soundId); + metadataStore.delete(soundId); + + transaction.oncomplete = () => { + resolve(); + }; + + transaction.onerror = () => { + reject(new Error(`Failed to remove audio: ${transaction.error?.message}`)); + }; + }); + } + + async clearCache(): Promise { + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Database not initialized')); + return; + } + + const transaction = this.db.transaction( + [this.STORE_NAME, this.METADATA_STORE], + 'readwrite' + ); + + transaction.objectStore(this.STORE_NAME).clear(); + transaction.objectStore(this.METADATA_STORE).clear(); + + transaction.oncomplete = () => { + this.stats.lastCleanup = Date.now(); + resolve(); + }; + + transaction.onerror = () => { + reject(new Error(`Failed to clear cache: ${transaction.error?.message}`)); + }; + }); + } + + async getMetadata(soundId: string): Promise { + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Database not initialized')); + return; + } + + const transaction = this.db.transaction(this.METADATA_STORE, 'readonly'); + const store = transaction.objectStore(this.METADATA_STORE); + const request = store.get(soundId); + + request.onsuccess = () => { + resolve(request.result || null); + }; + + request.onerror = () => { + reject(new Error(`Failed to retrieve metadata: ${request.error?.message}`)); + }; + }); + } + + async getAllMetadata(): Promise { + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Database not initialized')); + return; + } + + const transaction = this.db.transaction(this.METADATA_STORE, 'readonly'); + const store = transaction.objectStore(this.METADATA_STORE); + const request = store.getAll(); + + request.onsuccess = () => { + resolve(request.result as AudioAssetMetadata[]); + }; + + request.onerror = () => { + reject(new Error(`Failed to retrieve all metadata: ${request.error?.message}`)); + }; + }); + } + + async verifyIntegrity(soundId: string, expectedHash: string): Promise { + try { + const audioData = await this.getAudio(soundId); + if (!audioData) { + return false; + } + + const hash = await this.hashArrayBuffer(audioData); + return hash === expectedHash; + } catch (error) { + return false; + } + } + + async getCacheStats(): Promise { + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Database not initialized')); + return; + } + + const transaction = this.db.transaction(this.STORE_NAME, 'readonly'); + const store = transaction.objectStore(this.STORE_NAME); + + // Get item count + const countRequest = store.count(); + + countRequest.onsuccess = async () => { + try { + const itemCount = countRequest.result; + + // Calculate total size + let totalSize = 0; + const request = store.getAll(); + + request.onsuccess = () => { + const items = request.result as any[]; + items.forEach((item) => { + if (item.data instanceof ArrayBuffer) { + totalSize += item.data.byteLength; + } + }); + + const hitRate = this.stats.hits + this.stats.misses > 0 + ? (this.stats.hits / (this.stats.hits + this.stats.misses)) * 100 + : 0; + + resolve({ + totalSize, + itemCount, + hitRate, + lastCleanup: this.stats.lastCleanup, + }); + }; + + request.onerror = () => { + reject(new Error(`Failed to get cache stats: ${request.error?.message}`)); + }; + } catch (error) { + reject(error); + } + }; + + countRequest.onerror = () => { + reject(new Error(`Failed to count items: ${countRequest.error?.message}`)); + }; + }); + } + + private async hashArrayBuffer(buffer: ArrayBuffer): Promise { + try { + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + } catch (error) { + throw new Error(`Failed to hash audio data: ${error}`); + } + } + + async close(): Promise { + if (this.db) { + this.db.close(); + this.db = null; + } + } +} + +// Singleton instance +let instance: AudioCacheService | null = null; + +export function getAudioCacheService(): AudioCacheService { + if (!instance) { + instance = new AudioCacheService(); + } + return instance; +} + +export async function initializeAudioCacheService(): Promise { + const service = getAudioCacheService(); + await service.initialize(); + return service; +} diff --git a/packages/frontend/src/services/AudioNotificationManager.ts b/packages/frontend/src/services/AudioNotificationManager.ts new file mode 100644 index 0000000..f4a8fe9 --- /dev/null +++ b/packages/frontend/src/services/AudioNotificationManager.ts @@ -0,0 +1,324 @@ +import type { + AudioManagerConfig, + AudioNotificationEvent, + AudioPlaybackState, +} from '@haflow/shared'; + +export class AudioNotificationManager { + private audioCache: Map; + private playbackQueue: AudioNotificationEvent[]; + private currentPlayback?: { + sound: HTMLAudioElement; + soundId: string; + startTime: number; + }; + private lastPlayedTime: number = 0; + private debounceInterval: number = 2000; // ms + private audioContext: AudioContext | null = null; + private playbackState: AudioPlaybackState; + private config: AudioManagerConfig; + + constructor(config: AudioManagerConfig = {}) { + this.audioCache = new Map(); + this.playbackQueue = []; + this.config = { + audioAssetsUrl: config.audioAssetsUrl || '/audio', + preloadSounds: config.preloadSounds !== false, + debounceInterval: config.debounceInterval || 2000, + enableLogging: config.enableLogging || false, + ...config, + }; + this.debounceInterval = this.config.debounceInterval || 2000; + this.playbackState = { + isPlaying: false, + volume: 50, + }; + } + + // Public methods + async initialize(): Promise { + try { + // Try to create AudioContext + if (this.isBrowserSupported()) { + const audioContextClass = window.AudioContext || (window as any).webkitAudioContext; + this.audioContext = new audioContextClass(); + this.log('AudioContext initialized'); + } + } catch (error) { + this.logError('Failed to initialize AudioContext', error as Error); + // Continue without Web Audio API + } + } + + async play(soundId: string, volume: number, priority: string = 'standard'): Promise { + try { + // Check if debounce prevents playback + if (this.debouncePlayback(priority)) { + this.log(`Playback debounced for ${soundId}`); + return; + } + + // Update last played time + this.lastPlayedTime = Date.now(); + + // If already playing, stop first + if (this.currentPlayback) { + await this.stop(); + } + + // Load and play sound + const sound = await this.loadAudioFile(soundId); + sound.volume = Math.min(Math.max(volume / 100, 0), 1); // Ensure 0-1 range + + this.currentPlayback = { + sound, + soundId, + startTime: Date.now(), + }; + + this.playbackState.isPlaying = true; + this.playbackState.currentSoundId = soundId; + this.playbackState.volume = volume; + this.playbackState.lastPlayedAt = Date.now(); + + // Play the sound + const playPromise = sound.play(); + if (playPromise !== undefined) { + playPromise.catch((error: Error) => { + this.handlePlaybackError(error, soundId); + }); + } + + // Set up cleanup when audio ends + sound.addEventListener('ended', () => this.cleanupAudioResources(), { once: true }); + + this.log(`Playing sound: ${soundId} at volume ${volume}%`); + } catch (error) { + this.handlePlaybackError(error as Error, soundId); + } + } + + async stop(): Promise { + if (this.currentPlayback) { + try { + this.currentPlayback.sound.pause(); + this.currentPlayback.sound.currentTime = 0; + this.playbackState.isPlaying = false; + this.playbackState.currentSoundId = undefined; + this.log('Playback stopped'); + } catch (error) { + this.logError('Error stopping playback', error as Error); + } + } + } + + async preloadSounds(soundIds: string[]): Promise { + try { + for (const soundId of soundIds) { + if (!this.audioCache.has(soundId)) { + await this.loadAudioFile(soundId); + } + } + this.log(`Preloaded ${soundIds.length} sounds`); + } catch (error) { + this.logError('Error preloading sounds', error as Error); + } + } + + setVolume(volume: number): void { + const normalizedVolume = Math.min(Math.max(volume, 0), 100); + this.playbackState.volume = normalizedVolume; + + if (this.currentPlayback) { + this.currentPlayback.sound.volume = normalizedVolume / 100; + } + + this.log(`Volume set to ${normalizedVolume}%`); + } + + getAudioContext(): AudioContext | null { + return this.audioContext; + } + + isAudioSupported(): boolean { + return typeof HTMLAudioElement !== 'undefined'; + } + + isBrowserSupported(): boolean { + const hasAudioElement = this.isAudioSupported(); + const hasWebAudio = !!(window.AudioContext || (window as any).webkitAudioContext); + return hasAudioElement && hasWebAudio; + } + + getPlaybackState(): AudioPlaybackState { + return { ...this.playbackState }; + } + + async playPreviewSound(soundId: string, volume: number): Promise { + try { + const sound = await this.loadAudioFile(soundId); + sound.volume = Math.min(Math.max(volume / 100, 0), 1); + + const playPromise = sound.play(); + if (playPromise !== undefined) { + playPromise.catch((error: Error) => { + this.logError(`Preview sound playback failed for ${soundId}`, error); + }); + } + + this.log(`Preview sound playing: ${soundId}`); + } catch (error) { + this.logError(`Failed to play preview sound ${soundId}`, error as Error); + throw error; + } + } + + // Private methods + private async loadAudioFile(soundId: string): Promise { + // Check cache first + if (this.audioCache.has(soundId)) { + return this.audioCache.get(soundId)!; + } + + try { + // Create audio element + const audio = new Audio(); + + // Construct URL (handle both .mp3 and .wav extensions) + const audioUrl = this.getAudioUrl(soundId); + audio.src = audioUrl; + audio.crossOrigin = 'anonymous'; + + // Wait for audio to load + await new Promise((resolve, reject) => { + const handleCanPlay = () => { + audio.removeEventListener('canplay', handleCanPlay); + audio.removeEventListener('error', handleError); + resolve(); + }; + + const handleError = () => { + audio.removeEventListener('canplay', handleCanPlay); + audio.removeEventListener('error', handleError); + reject(new Error(`Failed to load audio: ${audioUrl}`)); + }; + + audio.addEventListener('canplay', handleCanPlay, { once: true }); + audio.addEventListener('error', handleError, { once: true }); + + // Set a timeout for loading + setTimeout(() => { + audio.removeEventListener('canplay', handleCanPlay); + audio.removeEventListener('error', handleError); + reject(new Error(`Audio loading timeout: ${soundId}`)); + }, 10000); + }); + + // Cache the audio element + this.audioCache.set(soundId, audio); + this.log(`Audio loaded and cached: ${soundId}`); + + return audio; + } catch (error) { + this.logError(`Failed to load audio file: ${soundId}`, error as Error); + throw error; + } + } + + private getAudioUrl(soundId: string): string { + // If soundId already has an extension, use it directly + if (soundId.includes('.')) { + return `${this.config.audioAssetsUrl}/${soundId}`; + } + + // Try common extensions + return `${this.config.audioAssetsUrl}/${soundId}.wav`; + } + + private isAutoplayAllowed(): boolean { + // Check if we can play audio (autoplay might be blocked) + return true; // Trust the browser to tell us via error on play() + } + + private handlePlaybackError(error: Error, soundId: string): void { + const errorMessage = error.message || error.toString(); + + if (errorMessage.includes('NotAllowedError') || errorMessage.includes('autoplay')) { + this.log(`Autoplay blocked for ${soundId}`); + } else if (errorMessage.includes('NotFoundError')) { + this.logError(`Audio file not found: ${soundId}`, error); + } else { + this.logError(`Playback error for ${soundId}`, error); + } + + this.playbackState.isPlaying = false; + this.playbackState.currentSoundId = undefined; + } + + private debouncePlayback(priority: string): boolean { + const now = Date.now(); + const timeSinceLastPlay = now - this.lastPlayedTime; + + if (timeSinceLastPlay < this.debounceInterval) { + return true; + } + + return false; + } + + private cleanupAudioResources(): void { + if (this.currentPlayback) { + try { + this.currentPlayback.sound.pause(); + this.currentPlayback.sound.currentTime = 0; + } catch (error) { + this.logError('Error during cleanup', error as Error); + } + } + + this.playbackState.isPlaying = false; + this.playbackState.currentSoundId = undefined; + this.currentPlayback = undefined; + + this.log('Audio resources cleaned up'); + } + + private log(message: string): void { + if (this.config.enableLogging) { + console.log(`[AudioNotificationManager] ${message}`); + } + } + + private logError(message: string, error: Error): void { + if (this.config.enableLogging) { + console.error(`[AudioNotificationManager] ${message}`, error); + } + } + + // Cleanup on instance destruction + destroy(): void { + this.stop(); + this.audioCache.clear(); + this.playbackQueue = []; + this.currentPlayback = undefined; + } +} + +// Singleton instance +let instance: AudioNotificationManager | null = null; + +export function getAudioNotificationManager( + config?: AudioManagerConfig +): AudioNotificationManager { + if (!instance) { + instance = new AudioNotificationManager(config); + } + return instance; +} + +export function resetAudioNotificationManager(): void { + if (instance) { + instance.destroy(); + } + instance = null; +} diff --git a/packages/frontend/src/services/__tests__/AudioCacheService.test.ts b/packages/frontend/src/services/__tests__/AudioCacheService.test.ts new file mode 100644 index 0000000..614d4e5 --- /dev/null +++ b/packages/frontend/src/services/__tests__/AudioCacheService.test.ts @@ -0,0 +1,383 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { AudioCacheService, getAudioCacheService, initializeAudioCacheService } from '../AudioCacheService'; +import type { AudioAssetMetadata } from '@haflow/shared'; + +// Mock IndexedDB +const mockIDBStore = { + data: new Map(), + clear() { + this.data.clear(); + }, +}; + +const createMockIDB = () => { + const stores = { + audioNotifications: { ...mockIDBStore }, + audioMetadata: { ...mockIDBStore }, + }; + + return { + open: (dbName: string, version: number) => { + const request = { + result: { + objectStoreNames: { + contains: (name: string) => name in stores, + }, + transaction: (storeNames: string[], mode: string) => { + return { + objectStore: (name: string) => ({ + put: (item: any) => ({ + onsuccess: null, + onerror: null, + addEventListener: (event: string, handler: () => void) => { + if (event === 'success') { + setTimeout(() => { + stores[name as keyof typeof stores].data.set(item.soundId || item.id, item); + handler(); + }, 0); + } + }, + }), + get: (key: string) => ({ + result: stores[name as keyof typeof stores].data.get(key) || null, + onsuccess: null, + onerror: null, + addEventListener: (event: string, handler: () => void) => { + if (event === 'success') setTimeout(handler, 0); + }, + }), + getAll: () => ({ + result: Array.from(stores[name as keyof typeof stores].data.values()), + onsuccess: null, + onerror: null, + addEventListener: (event: string, handler: () => void) => { + if (event === 'success') setTimeout(handler, 0); + }, + }), + count: () => ({ + result: stores[name as keyof typeof stores].data.size, + onsuccess: null, + onerror: null, + addEventListener: (event: string, handler: () => void) => { + if (event === 'success') setTimeout(handler, 0); + }, + }), + delete: (key: string) => ({ + onsuccess: null, + onerror: null, + addEventListener: (event: string, handler: () => void) => { + if (event === 'success') { + stores[name as keyof typeof stores].data.delete(key); + handler(); + } + }, + }), + clear: () => ({ + onsuccess: null, + onerror: null, + addEventListener: (event: string, handler: () => void) => { + if (event === 'success') { + stores[name as keyof typeof stores].data.clear(); + handler(); + } + }, + }), + }), + oncomplete: null, + onerror: null, + addEventListener: (event: string, handler: () => void) => { + if (event === 'complete') setTimeout(handler, 0); + }, + }; + }, + close: () => {}, + }, + onsuccess: null, + onerror: null, + onupgradeneeded: null, + addEventListener: (event: string, handler: (e: any) => void) => { + if (event === 'success') { + setTimeout(() => { + if (request.onsuccess) request.onsuccess(); + }, 0); + } + if (event === 'upgradeneeded') { + setTimeout(() => { + if (request.onupgradeneeded) { + request.onupgradeneeded({ target: request }); + } + }, 0); + } + }, + }; + + return request as any; + }, + }; +}; + +// Mock crypto.subtle.digest +const mockCryptoDigest = async (algorithm: string, data: ArrayBuffer): Promise => { + // Simple mock: just return a fixed hash + return new ArrayBuffer(32); // SHA-256 is 32 bytes +}; + +describe('AudioCacheService', () => { + let service: AudioCacheService; + + beforeEach(async () => { + // Mock IndexedDB + global.indexedDB = createMockIDB() as any; + + // Mock crypto + if (!global.crypto) { + (global as any).crypto = {}; + } + if (!global.crypto.subtle) { + global.crypto.subtle = { + digest: mockCryptoDigest, + } as any; + } + + service = new AudioCacheService(); + try { + await service.initialize(); + } catch { + // Ignore initialization errors in tests + } + }); + + afterEach(async () => { + try { + await service.close(); + } catch { + // Ignore + } + }); + + describe('initialization', () => { + it('should initialize without errors', async () => { + const newService = new AudioCacheService(); + await expect(newService.initialize()).resolves.toBeUndefined(); + }); + + it('should handle missing IndexedDB', async () => { + const originalIDB = global.indexedDB; + delete (global as any).indexedDB; + + const newService = new AudioCacheService(); + await expect(newService.initialize()).rejects.toThrow(); + + (global as any).indexedDB = originalIDB; + }); + }); + + describe('audio storage and retrieval', () => { + it('should save and retrieve audio data', async () => { + const soundId = 'test-sound'; + const audioData = new ArrayBuffer(1000); + const metadata: AudioAssetMetadata = { + id: soundId, + filename: 'test.wav', + format: 'wav', + hash: 'abc123', + size: 1000, + duration: 2000, + priority: 'standard', + preload: true, + }; + + await service.saveAudio(soundId, audioData, metadata); + const retrieved = await service.getAudio(soundId); + + expect(retrieved).toEqual(audioData); + }); + + it('should return null for non-existent audio', async () => { + const retrieved = await service.getAudio('nonexistent'); + expect(retrieved).toBeNull(); + }); + }); + + describe('metadata operations', () => { + it('should save and retrieve metadata', async () => { + const metadata: AudioAssetMetadata = { + id: 'test-meta', + filename: 'test.wav', + format: 'wav', + hash: 'hash123', + size: 2000, + duration: 3000, + priority: 'high', + preload: true, + }; + + await service.saveAudio('test-meta', new ArrayBuffer(2000), metadata); + const retrieved = await service.getMetadata('test-meta'); + + expect(retrieved?.id).toBe(metadata.id); + expect(retrieved?.filename).toBe(metadata.filename); + }); + + it('should return null for non-existent metadata', async () => { + const retrieved = await service.getMetadata('nonexistent'); + expect(retrieved).toBeNull(); + }); + + it('should get all metadata', async () => { + const meta1: AudioAssetMetadata = { + id: 'sound1', + filename: 'sound1.wav', + format: 'wav', + hash: 'hash1', + size: 1000, + duration: 1000, + priority: 'standard', + preload: true, + }; + + const meta2: AudioAssetMetadata = { + id: 'sound2', + filename: 'sound2.wav', + format: 'wav', + hash: 'hash2', + size: 2000, + duration: 2000, + priority: 'high', + preload: false, + }; + + await service.saveAudio('sound1', new ArrayBuffer(1000), meta1); + await service.saveAudio('sound2', new ArrayBuffer(2000), meta2); + + const allMeta = await service.getAllMetadata(); + expect(allMeta.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('audio removal', () => { + it('should remove audio from cache', async () => { + const soundId = 'test-remove'; + const metadata: AudioAssetMetadata = { + id: soundId, + filename: 'test.wav', + format: 'wav', + hash: 'hash', + size: 1000, + duration: 1000, + priority: 'standard', + preload: true, + }; + + await service.saveAudio(soundId, new ArrayBuffer(1000), metadata); + let retrieved = await service.getAudio(soundId); + expect(retrieved).not.toBeNull(); + + await service.removeAudio(soundId); + retrieved = await service.getAudio(soundId); + expect(retrieved).toBeNull(); + }); + }); + + describe('cache management', () => { + it('should clear cache', async () => { + const metadata: AudioAssetMetadata = { + id: 'clear-test', + filename: 'test.wav', + format: 'wav', + hash: 'hash', + size: 1000, + duration: 1000, + priority: 'standard', + preload: true, + }; + + await service.saveAudio('clear-test', new ArrayBuffer(1000), metadata); + await service.clearCache(); + + const retrieved = await service.getAudio('clear-test'); + expect(retrieved).toBeNull(); + }); + + it('should calculate cache stats', async () => { + const metadata: AudioAssetMetadata = { + id: 'stats-test', + filename: 'test.wav', + format: 'wav', + hash: 'hash', + size: 1000, + duration: 1000, + priority: 'standard', + preload: true, + }; + + await service.saveAudio('stats-test', new ArrayBuffer(1000), metadata); + + const stats = await service.getCacheStats(); + expect(stats).toHaveProperty('totalSize'); + expect(stats).toHaveProperty('itemCount'); + expect(stats).toHaveProperty('hitRate'); + expect(stats).toHaveProperty('lastCleanup'); + expect(typeof stats.totalSize).toBe('number'); + expect(typeof stats.itemCount).toBe('number'); + }); + }); + + describe('integrity verification', () => { + it('should verify audio integrity with matching hash', async () => { + const soundId = 'verify-test'; + const audioData = new ArrayBuffer(1000); + const metadata: AudioAssetMetadata = { + id: soundId, + filename: 'test.wav', + format: 'wav', + hash: 'testhash', + size: 1000, + duration: 1000, + priority: 'standard', + preload: true, + }; + + await service.saveAudio(soundId, audioData, metadata); + + // Note: The actual verification depends on the hash function + // In this test, we just verify the method completes + const result = await service.verifyIntegrity(soundId, 'testhash'); + expect(typeof result).toBe('boolean'); + }); + + it('should return false for non-existent audio during verification', async () => { + const result = await service.verifyIntegrity('nonexistent', 'hash'); + expect(result).toBe(false); + }); + }); + + describe('error handling', () => { + it('should handle database not initialized errors', async () => { + const uninitializedService = new AudioCacheService(); + + await expect( + uninitializedService.getAudio('test') + ).rejects.toThrow('Database not initialized'); + }); + }); + + describe('singleton pattern', () => { + it('should return same instance when called multiple times', () => { + const service1 = getAudioCacheService(); + const service2 = getAudioCacheService(); + expect(service1).toBe(service2); + }); + + it('should initialize singleton service', async () => { + const service = await initializeAudioCacheService(); + expect(service).toBeDefined(); + }); + }); + + describe('close', () => { + it('should close database without errors', async () => { + await expect(service.close()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/frontend/src/services/__tests__/AudioNotificationManager.test.ts b/packages/frontend/src/services/__tests__/AudioNotificationManager.test.ts new file mode 100644 index 0000000..6efb092 --- /dev/null +++ b/packages/frontend/src/services/__tests__/AudioNotificationManager.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { AudioNotificationManager, getAudioNotificationManager, resetAudioNotificationManager } from '../AudioNotificationManager'; + +describe('AudioNotificationManager', () => { + let manager: AudioNotificationManager; + + beforeEach(() => { + manager = new AudioNotificationManager({ + audioAssetsUrl: '/test-audio', + enableLogging: false, + }); + }); + + afterEach(() => { + manager.destroy(); + resetAudioNotificationManager(); + }); + + describe('initialization', () => { + it('should initialize successfully', async () => { + await manager.initialize(); + expect(manager).toBeDefined(); + }); + + it('should handle initialization errors gracefully', async () => { + // Test that initialization doesn't throw even if AudioContext fails + await expect(manager.initialize()).resolves.toBeUndefined(); + }); + }); + + describe('browser support detection', () => { + it('should detect audio element support', () => { + const supported = manager.isAudioSupported(); + expect(typeof supported).toBe('boolean'); + }); + + it('should detect browser support', () => { + const supported = manager.isBrowserSupported(); + expect(typeof supported).toBe('boolean'); + }); + }); + + describe('volume management', () => { + it('should set volume within valid range', () => { + manager.setVolume(75); + const state = manager.getPlaybackState(); + expect(state.volume).toBe(75); + }); + + it('should clamp volume to min 0', () => { + manager.setVolume(-50); + const state = manager.getPlaybackState(); + expect(state.volume).toBe(0); + }); + + it('should clamp volume to max 100', () => { + manager.setVolume(150); + const state = manager.getPlaybackState(); + expect(state.volume).toBe(100); + }); + }); + + describe('playback state', () => { + it('should initialize with not playing state', () => { + const state = manager.getPlaybackState(); + expect(state.isPlaying).toBe(false); + expect(state.currentSoundId).toBeUndefined(); + }); + + it('should return a copy of playback state', () => { + const state1 = manager.getPlaybackState(); + const state2 = manager.getPlaybackState(); + expect(state1).toEqual(state2); + expect(state1).not.toBe(state2); // Different object + }); + }); + + describe('audio context', () => { + it('should return audio context or null', () => { + const context = manager.getAudioContext(); + expect(context === null || context instanceof AudioContext).toBe(true); + }); + }); + + describe('stop functionality', () => { + it('should stop playback without errors', async () => { + await expect(manager.stop()).resolves.toBeUndefined(); + }); + + it('should set isPlaying to false after stop', async () => { + await manager.stop(); + const state = manager.getPlaybackState(); + expect(state.isPlaying).toBe(false); + }); + }); + + describe('preview sound', () => { + it('should throw error for invalid sound file', async () => { + await expect(manager.playPreviewSound('nonexistent.wav', 50)).rejects.toThrow(); + }); + }); + + describe('debouncing', () => { + it('should respect debounce interval', async () => { + manager = new AudioNotificationManager({ + debounceInterval: 2000, + audioAssetsUrl: '/test-audio', + }); + + // Mock the sound file to avoid actual audio loading errors + const originalLoad = manager['loadAudioFile']; + let loadCallCount = 0; + + vi.spyOn(manager as any, 'loadAudioFile').mockImplementation(async (soundId: string) => { + loadCallCount++; + const audio = new Audio(); + return audio; + }); + + // First play should succeed + await manager.play('sound1.wav', 50, 'standard').catch(() => {}); + + // Immediate second play should be debounced + const stateBeforeDebounce = manager.getPlaybackState(); + + // Wait longer than debounce interval + await new Promise((resolve) => setTimeout(resolve, 2100)); + + // Now play should work again + await manager.play('sound2.wav', 50, 'standard').catch(() => {}); + }); + }); + + describe('resource cleanup', () => { + it('should clean up resources on destroy', () => { + const state = manager.getPlaybackState(); + expect(state).toBeDefined(); + + manager.destroy(); + + // Manager should still be functional after destroy + expect(manager).toBeDefined(); + }); + }); + + describe('singleton pattern', () => { + it('should return same instance when called multiple times', () => { + const manager1 = getAudioNotificationManager(); + const manager2 = getAudioNotificationManager(); + expect(manager1).toBe(manager2); + }); + + it('should reset singleton instance', () => { + const manager1 = getAudioNotificationManager(); + resetAudioNotificationManager(); + const manager2 = getAudioNotificationManager(); + expect(manager1).not.toBe(manager2); + }); + }); + + describe('audio URL construction', () => { + it('should construct correct URL for files without extension', () => { + const urlWithoutExt = (manager as any).getAudioUrl('alert-sound'); + expect(urlWithoutExt).toBe('/test-audio/alert-sound.wav'); + }); + + it('should use provided URL for files with extension', () => { + const urlWithExt = (manager as any).getAudioUrl('alert-sound.mp3'); + expect(urlWithExt).toBe('/test-audio/alert-sound.mp3'); + }); + }); +}); diff --git a/packages/frontend/src/utils/AudioCompatibility.ts b/packages/frontend/src/utils/AudioCompatibility.ts new file mode 100644 index 0000000..cd07b19 --- /dev/null +++ b/packages/frontend/src/utils/AudioCompatibility.ts @@ -0,0 +1,202 @@ +import type { BrowserInfo, CompatibilityReport } from '@haflow/shared'; + +export class AudioCompatibility { + static isAudioElementSupported(): boolean { + // Check for HTMLAudioElement support + try { + return !!(document.createElement('audio').canPlayType); + } catch { + return false; + } + } + + static isWebAudioAPISupported(): boolean { + // Check for Web Audio API + try { + const audioContext = window.AudioContext || (window as any).webkitAudioContext; + return !!audioContext; + } catch { + return false; + } + } + + static isBrowserSupported(): boolean { + // Requires both HTML5 Audio and Web Audio API + return this.isAudioElementSupported() && this.isWebAudioAPISupported(); + } + + static getAudioFormatsSupported(): Record { + try { + const audio = document.createElement('audio'); + return { + mp3: audio.canPlayType('audio/mpeg') !== '', + wav: audio.canPlayType('audio/wav') !== '', + ogg: audio.canPlayType('audio/ogg') !== '', + aac: audio.canPlayType('audio/aac') !== '', + flac: audio.canPlayType('audio/flac') !== '', + }; + } catch { + return { + mp3: false, + wav: false, + ogg: false, + aac: false, + flac: false, + }; + } + } + + static supportsPermissionsAPI(): boolean { + try { + return !!navigator.permissions; + } catch { + return false; + } + } + + static supportsServiceWorker(): boolean { + try { + return !!navigator.serviceWorker; + } catch { + return false; + } + } + + static supportsIndexedDB(): boolean { + try { + return !!window.indexedDB; + } catch { + return false; + } + } + + static getBrowserInfo(): BrowserInfo { + const userAgent = navigator.userAgent; + let browserName = 'Unknown'; + let version = 'Unknown'; + let platform = navigator.platform; + + // Detect browser + if (userAgent.includes('Firefox')) { + browserName = 'Firefox'; + const versionMatch = userAgent.match(/Firefox\/(\d+)/); + if (versionMatch) version = versionMatch[1]; + } else if (userAgent.includes('Chrome')) { + browserName = 'Chrome'; + const versionMatch = userAgent.match(/Chrome\/(\d+)/); + if (versionMatch) version = versionMatch[1]; + } else if (userAgent.includes('Safari')) { + browserName = 'Safari'; + const versionMatch = userAgent.match(/Version\/(\d+)/); + if (versionMatch) version = versionMatch[1]; + } else if (userAgent.includes('Edge')) { + browserName = 'Edge'; + const versionMatch = userAgent.match(/Edg\/(\d+)/); + if (versionMatch) version = versionMatch[1]; + } + + return { + name: browserName, + version, + platform, + }; + } + + static getCompatibilityReport(): CompatibilityReport { + const audioElement = this.isAudioElementSupported(); + const webAudioAPI = this.isWebAudioAPISupported(); + const serviceWorker = this.supportsServiceWorker(); + const permissionsAPI = this.supportsPermissionsAPI(); + const indexedDB = this.supportsIndexedDB(); + + const formatsSupported = this.getAudioFormatsSupported(); + const supportedFormats = Object.entries(formatsSupported) + .filter(([_, supported]) => supported) + .map(([format]) => format); + + const warnings: string[] = []; + + if (!audioElement) { + warnings.push('HTML5 Audio element not supported'); + } + + if (!webAudioAPI) { + warnings.push('Web Audio API not supported'); + } + + if (!serviceWorker) { + warnings.push('Service Worker not supported (offline caching unavailable)'); + } + + if (!indexedDB) { + warnings.push('IndexedDB not supported (in-memory caching only)'); + } + + if (supportedFormats.length === 0) { + warnings.push('No audio formats supported'); + } + + const isFullySupported = + audioElement && webAudioAPI && serviceWorker && indexedDB && supportedFormats.length > 0; + + return { + isFullySupported, + audioElement, + webAudioAPI, + serviceWorker, + permissionsAPI, + supportedFormats, + warnings, + }; + } + + // Check if autoplay is likely to work + static async canAutoplay(): Promise { + try { + const audio = new Audio(); + audio.src = + 'data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAAA'; + const playPromise = audio.play(); + + if (playPromise === undefined) { + return true; // Old browser, assume autoplay works + } + + try { + await playPromise; + return true; + } catch (error) { + return false; + } + } catch { + return false; + } + } + + static logCapabilities(): void { + const browser = this.getBrowserInfo(); + const report = this.getCompatibilityReport(); + + console.log('=== Audio Capability Report ==='); + console.log(`Browser: ${browser.name} ${browser.version}`); + console.log(`Platform: ${browser.platform}`); + console.log(`Audio Element: ${report.audioElement ? '✓' : '✗'}`); + console.log(`Web Audio API: ${report.webAudioAPI ? '✓' : '✗'}`); + console.log(`Service Worker: ${report.serviceWorker ? '✓' : '✗'}`); + console.log(`IndexedDB: ${report.permissionsAPI ? '✓' : '✗'}`); + console.log(`Supported Formats: ${report.supportedFormats.join(', ')}`); + + if (report.warnings.length > 0) { + console.warn('Warnings:'); + report.warnings.forEach((warning) => console.warn(` - ${warning}`)); + } + + console.log(`Overall Support: ${report.isFullySupported ? '✓ Fully Supported' : '✗ Limited'}`); + } +} + +// Export type predicates for use in conditions +export const isAudioSupported = AudioCompatibility.isBrowserSupported; +export const isWebAudioSupported = AudioCompatibility.isWebAudioAPISupported; +export const isServiceWorkerSupported = AudioCompatibility.supportsServiceWorker; +export const isIndexedDBSupported = AudioCompatibility.supportsIndexedDB; diff --git a/packages/frontend/src/utils/__tests__/AudioCompatibility.test.ts b/packages/frontend/src/utils/__tests__/AudioCompatibility.test.ts new file mode 100644 index 0000000..9706b8f --- /dev/null +++ b/packages/frontend/src/utils/__tests__/AudioCompatibility.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { AudioCompatibility, isAudioSupported, isWebAudioSupported } from '../AudioCompatibility'; + +describe('AudioCompatibility', () => { + describe('isAudioElementSupported', () => { + it('should return boolean', () => { + const result = AudioCompatibility.isAudioElementSupported(); + expect(typeof result).toBe('boolean'); + }); + + it('should return true in modern browsers', () => { + const result = AudioCompatibility.isAudioElementSupported(); + expect(result).toBe(true); + }); + }); + + describe('isWebAudioAPISupported', () => { + it('should return boolean', () => { + const result = AudioCompatibility.isWebAudioAPISupported(); + expect(typeof result).toBe('boolean'); + }); + + it('should detect AudioContext', () => { + const result = AudioCompatibility.isWebAudioAPISupported(); + expect(typeof result).toBe('boolean'); + }); + + it('should check for webkit prefix', () => { + const hasWebAudio = !!window.AudioContext || !!(window as any).webkitAudioContext; + const result = AudioCompatibility.isWebAudioAPISupported(); + expect(result).toBe(hasWebAudio); + }); + }); + + describe('isBrowserSupported', () => { + it('should return boolean', () => { + const result = AudioCompatibility.isBrowserSupported(); + expect(typeof result).toBe('boolean'); + }); + + it('should require both audio element and Web Audio API', () => { + const audioElement = AudioCompatibility.isAudioElementSupported(); + const webAudio = AudioCompatibility.isWebAudioAPISupported(); + const result = AudioCompatibility.isBrowserSupported(); + + expect(result).toBe(audioElement && webAudio); + }); + }); + + describe('getAudioFormatsSupported', () => { + it('should return object with boolean values', () => { + const formats = AudioCompatibility.getAudioFormatsSupported(); + expect(typeof formats).toBe('object'); + expect(formats.mp3 === true || formats.mp3 === false).toBe(true); + expect(formats.wav === true || formats.wav === false).toBe(true); + }); + + it('should include common formats', () => { + const formats = AudioCompatibility.getAudioFormatsSupported(); + expect('mp3' in formats).toBe(true); + expect('wav' in formats).toBe(true); + expect('ogg' in formats).toBe(true); + expect('aac' in formats).toBe(true); + }); + + it('should detect wav support', () => { + const formats = AudioCompatibility.getAudioFormatsSupported(); + // Most modern browsers support WAV + expect(typeof formats.wav).toBe('boolean'); + }); + }); + + describe('supportsPermissionsAPI', () => { + it('should return boolean', () => { + const result = AudioCompatibility.supportsPermissionsAPI(); + expect(typeof result).toBe('boolean'); + }); + + it('should check navigator.permissions', () => { + const hasPermissions = !!navigator.permissions; + const result = AudioCompatibility.supportsPermissionsAPI(); + expect(result).toBe(hasPermissions); + }); + }); + + describe('supportsServiceWorker', () => { + it('should return boolean', () => { + const result = AudioCompatibility.supportsServiceWorker(); + expect(typeof result).toBe('boolean'); + }); + + it('should check navigator.serviceWorker', () => { + const hasServiceWorker = !!navigator.serviceWorker; + const result = AudioCompatibility.supportsServiceWorker(); + expect(result).toBe(hasServiceWorker); + }); + }); + + describe('supportsIndexedDB', () => { + it('should return boolean', () => { + const result = AudioCompatibility.supportsIndexedDB(); + expect(typeof result).toBe('boolean'); + }); + + it('should check window.indexedDB', () => { + const hasIndexedDB = !!window.indexedDB; + const result = AudioCompatibility.supportsIndexedDB(); + expect(result).toBe(hasIndexedDB); + }); + }); + + describe('getBrowserInfo', () => { + it('should return browser info object', () => { + const info = AudioCompatibility.getBrowserInfo(); + expect(info).toHaveProperty('name'); + expect(info).toHaveProperty('version'); + expect(info).toHaveProperty('platform'); + }); + + it('should detect browser name', () => { + const info = AudioCompatibility.getBrowserInfo(); + expect(typeof info.name).toBe('string'); + expect(info.name.length).toBeGreaterThan(0); + }); + + it('should detect platform', () => { + const info = AudioCompatibility.getBrowserInfo(); + expect(typeof info.platform).toBe('string'); + }); + + it('should detect version', () => { + const info = AudioCompatibility.getBrowserInfo(); + expect(typeof info.version).toBe('string'); + }); + }); + + describe('getCompatibilityReport', () => { + it('should return complete compatibility report', () => { + const report = AudioCompatibility.getCompatibilityReport(); + expect(report).toHaveProperty('isFullySupported'); + expect(report).toHaveProperty('audioElement'); + expect(report).toHaveProperty('webAudioAPI'); + expect(report).toHaveProperty('serviceWorker'); + expect(report).toHaveProperty('permissionsAPI'); + expect(report).toHaveProperty('supportedFormats'); + expect(report).toHaveProperty('warnings'); + }); + + it('should have boolean properties', () => { + const report = AudioCompatibility.getCompatibilityReport(); + expect(typeof report.isFullySupported).toBe('boolean'); + expect(typeof report.audioElement).toBe('boolean'); + expect(typeof report.webAudioAPI).toBe('boolean'); + }); + + it('should have array properties', () => { + const report = AudioCompatibility.getCompatibilityReport(); + expect(Array.isArray(report.supportedFormats)).toBe(true); + expect(Array.isArray(report.warnings)).toBe(true); + }); + + it('should generate appropriate warnings', () => { + const report = AudioCompatibility.getCompatibilityReport(); + // Warnings should only be present if features are not supported + if (!report.audioElement) { + expect(report.warnings.some((w) => w.includes('Audio element'))).toBe(true); + } + }); + + it('should mark as fully supported if all features available', () => { + const report = AudioCompatibility.getCompatibilityReport(); + const hasAllFeatures = + report.audioElement && report.webAudioAPI && report.serviceWorker && report.supportedFormats.length > 0; + expect(report.isFullySupported).toBe(hasAllFeatures); + }); + }); + + describe('canAutoplay', () => { + it('should return a promise', () => { + const result = AudioCompatibility.canAutoplay(); + expect(result).toBeInstanceOf(Promise); + }); + + it('should return boolean value', async () => { + const result = await AudioCompatibility.canAutoplay(); + expect(typeof result).toBe('boolean'); + }); + }); + + describe('logCapabilities', () => { + it('should not throw', () => { + expect(() => { + AudioCompatibility.logCapabilities(); + }).not.toThrow(); + }); + }); + + describe('exported type predicates', () => { + it('should export isAudioSupported function', () => { + const result = isAudioSupported(); + expect(typeof result).toBe('boolean'); + }); + + it('should export isWebAudioSupported function', () => { + const result = isWebAudioSupported(); + expect(typeof result).toBe('boolean'); + }); + }); + + describe('error handling', () => { + it('should handle errors in audio element support detection', () => { + // Create a scenario that might throw + expect(() => { + AudioCompatibility.isAudioElementSupported(); + }).not.toThrow(); + }); + + it('should handle errors in browser detection', () => { + expect(() => { + AudioCompatibility.getBrowserInfo(); + }).not.toThrow(); + }); + + it('should handle errors in compatibility report generation', () => { + expect(() => { + AudioCompatibility.getCompatibilityReport(); + }).not.toThrow(); + }); + }); + + describe('format support matrix', () => { + it('should check all audio formats', () => { + const formats = AudioCompatibility.getAudioFormatsSupported(); + const formatNames = Object.keys(formats); + expect(formatNames.length).toBeGreaterThan(0); + }); + + it('should handle new formats gracefully', () => { + const formats = AudioCompatibility.getAudioFormatsSupported(); + // Should not throw even if canPlayType returns unknown format + expect(typeof formats).toBe('object'); + }); + }); + + describe('browser detection', () => { + it('should detect Chrome correctly', () => { + const originalUserAgent = navigator.userAgent; + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 Chrome/120.0', + configurable: true, + }); + + const info = AudioCompatibility.getBrowserInfo(); + // The detector should identify Chrome or Unknown + expect(['Chrome', 'Unknown']).toContain(info.name); + + Object.defineProperty(navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + }); + }); + }); +}); diff --git a/packages/shared/src/audio-notification.types.ts b/packages/shared/src/audio-notification.types.ts new file mode 100644 index 0000000..2e18891 --- /dev/null +++ b/packages/shared/src/audio-notification.types.ts @@ -0,0 +1,122 @@ +// User preference structure for audio notifications +export interface AudioNotificationPreferences { + audioNotifications: { + enabled: boolean; + volume: number; // 0-100 + profiles: { + highPriority: { + sound: string; // sound file ID + enabled: boolean; + }; + standardPriority: { + sound: string; + enabled: boolean; + }; + lowPriority: { + sound: string; + enabled: boolean; + }; + }; + }; + visualNotifications: { + enabled: boolean; + }; +} + +// Audio notification event structure +export interface AudioNotificationEvent { + id: string; + priority: 'high' | 'standard' | 'low'; + soundId: string; + timestamp: number; + userId: string; + metadata?: Record; +} + +// Audio playback state +export interface AudioPlaybackState { + isPlaying: boolean; + currentSoundId?: string; + volume: number; + lastPlayedAt?: number; +} + +// Audio asset metadata +export interface AudioAssetMetadata { + id: string; + filename: string; + format: 'mp3' | 'wav'; + hash: string; // SHA-256 for integrity checking + size: number; + duration: number; // in milliseconds + priority: 'high' | 'standard' | 'low'; + preload: boolean; +} + +// Audio manager configuration +export interface AudioManagerConfig { + audioAssetsUrl?: string; // URL to fetch audio files + preloadSounds?: boolean; + debounceInterval?: number; // milliseconds + enableLogging?: boolean; +} + +// Cache statistics +export interface CacheStats { + totalSize: number; + itemCount: number; + hitRate: number; + lastCleanup: number; +} + +// Browser compatibility report +export interface BrowserInfo { + name: string; + version: string; + platform: string; +} + +export interface CompatibilityReport { + isFullySupported: boolean; + audioElement: boolean; + webAudioAPI: boolean; + serviceWorker: boolean; + permissionsAPI: boolean; + supportedFormats: string[]; + warnings: string[]; +} + +// Audio error handling +export enum AudioErrorType { + AUTOPLAY_BLOCKED = 'autoplay_blocked', + FILE_NOT_FOUND = 'file_not_found', + NETWORK_ERROR = 'network_error', + NO_AUDIO_CONTEXT = 'no_audio_context', + UNSUPPORTED_FORMAT = 'unsupported_format', + CACHE_FULL = 'cache_full', + PERMISSIONS_DENIED = 'permissions_denied', + UNKNOWN = 'unknown', +} + +export interface AudioErrorContext { + errorType: AudioErrorType; + soundId?: string; + originalError?: Error; + timestamp: number; +} + +export interface RecoveryStrategy { + action: 'SHOW_VISUAL_ONLY' | 'FALLBACK_SOUND' | 'RETRY_WITH_BACKOFF' | 'SILENT_FAILURE'; + message?: string; + userAction?: string; + fallback?: string; + maxRetries?: number; + initialDelayMs?: number; +} + +export interface AudioErrorResponse { + success: boolean; + error: AudioErrorType; + recovery: RecoveryStrategy; + userMessage?: string; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5065533..507137a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ export * from './schemas.js'; export * from './types.js'; +export * from './audio-notification.types.js'; diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index a147d8c..d6f36c6 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -111,3 +111,36 @@ export const TranscriptionResponseSchema = z.object({ export const TranscriptionStatusSchema = z.object({ available: z.boolean(), }); + +// Audio Notification Preference Schemas +export const AudioProfileSchema = z.object({ + sound: z.string().min(1, 'Sound ID is required'), + enabled: z.boolean(), +}); + +export const AudioNotificationPreferencesSchema = z.object({ + audioNotifications: z.object({ + enabled: z.boolean().default(false), + volume: z.number().min(0).max(100).default(50), + profiles: z.object({ + highPriority: AudioProfileSchema.default({ + sound: 'alert-urgent.wav', + enabled: true, + }), + standardPriority: AudioProfileSchema.default({ + sound: 'alert-standard.wav', + enabled: true, + }), + lowPriority: AudioProfileSchema.default({ + sound: 'alert-low.wav', + enabled: false, + }), + }), + }), + visualNotifications: z.object({ + enabled: z.boolean().default(true), + }), +}); + +// User preferences request +export const UserAudioPreferencesRequestSchema = AudioNotificationPreferencesSchema; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 882dad2..45e1332 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -14,6 +14,9 @@ import { SaveArtifactRequestSchema, TranscriptionResponseSchema, TranscriptionStatusSchema, + AudioProfileSchema, + AudioNotificationPreferencesSchema, + UserAudioPreferencesRequestSchema, } from './schemas.js'; // Infer types from schemas @@ -38,3 +41,8 @@ export interface ApiResponse { data: T | null; error: string | null; } + +// Audio notification preference types +export type AudioProfile = z.infer; +export type AudioNotificationPreferences = z.infer; +export type UserAudioPreferencesRequest = z.infer;