diff --git a/notificator.js b/notificator.js index 5d029cc..dc42ca1 100644 --- a/notificator.js +++ b/notificator.js @@ -54,6 +54,14 @@ function pickSoundFile(projectPath, seed) { return soundFiles[index] } +function getProjectName(projectPath) { + if (!projectPath) return 'unknown project' + // Extract the last directory name and make it human-readable + const name = projectPath.split('/').filter(Boolean).pop() || 'unknown project' + // Replace hyphens, underscores, dots with spaces for natural speech + return name.replace(/[-_.]/g, ' ') +} + let currentSessionID = null export const NotificationPlugin = async ({ project, client, $, directory, worktree }) => { @@ -63,17 +71,42 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr const desktopNotificationEnabled = desktopNotificationConfig.enabled !== false const soundConfig = config.playSound || {} const soundEnabled = soundConfig.enabled !== false + const ttsConfig = config.textToSpeech || {} + const ttsEnabled = ttsConfig.enabled !== false + + const projectPath = worktree || directory + const projectName = ttsConfig.projectName || getProjectName(projectPath) + const ttsVoice = ttsConfig.voice || 'Samantha' + const ttsRate = ttsConfig.rate || 200 // Determine sound file: explicit file takes priority, then fileSeed, then directory-based with session ID let soundFile if (soundConfig.file) { soundFile = soundConfig.file } else if (soundConfig.fileSeed !== undefined) { - soundFile = pickSoundFile(worktree || directory, soundConfig.fileSeed) + soundFile = pickSoundFile(projectPath, soundConfig.fileSeed) } else if (currentSessionID !== null) { - soundFile = pickSoundFile(worktree || directory, currentSessionID) + soundFile = pickSoundFile(projectPath, currentSessionID) } else { - soundFile = pickSoundFile(worktree || directory, hashString(worktree || directory)) + soundFile = pickSoundFile(projectPath, hashString(projectPath)) + } + + const speakNotification = async (message) => { + if (!enabled || !ttsEnabled) return + + const platform = process.platform + const text = message.replace(/"/g, '\\"') + + try { + if (platform === "darwin") { + await $`say -v ${ttsVoice} -r ${String(ttsRate)} "${text}"`.quiet() + } else if (platform === "linux") { + // espeak is commonly available on Linux + await $`espeak -s ${String(ttsRate)} "${text}"`.quiet() + } + } catch (err) { + // Silently fail - TTS is not critical + } } const playNotificationSound = async () => { @@ -120,12 +153,14 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr if (event.type === "session.idle" && event.sessionID === currentSessionID) { await sendNotification("OpenCode", "Generation completed") await playNotificationSound() + await speakNotification(`${projectName} is done`) } }, "permission.ask": async (input, output) => { const message = `Permission request: ${input.type}` await sendNotification("OpenCode", message) await playNotificationSound() + await speakNotification(`${projectName} needs permission`) }, } } diff --git a/notificator.jsonc b/notificator.jsonc index 96f4de3..0f4afb5 100644 --- a/notificator.jsonc +++ b/notificator.jsonc @@ -24,5 +24,22 @@ // This way each project gets its own unique ding without manual config. // The seed can be any value - change it to get a different sound assignment. "fileSeed": 0 + }, + + // Text-to-speech settings + "textToSpeech": { + // Enable/disable text-to-speech announcements + "enabled": true, + + // Custom project name to announce (optional) + // If not set, the project directory name is used automatically + // e.g. "My API Server" will say "My API Server is done" + // "projectName": "My Project", + + // macOS voice to use (run `say -v ?` to list available voices) + "voice": "Samantha", + + // Speech rate in words per minute (default: 200) + "rate": 200 } }