Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions notificator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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`)
},
}
}
17 changes: 17 additions & 0 deletions notificator.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}