diff --git a/backend/src/app_state.rs b/backend/src/app_state.rs
index 63b1d75d..7972b8b3 100644
--- a/backend/src/app_state.rs
+++ b/backend/src/app_state.rs
@@ -151,4 +151,9 @@ impl AppState {
let config = self.config.read().await;
config.push_notifications
}
+
+ pub async fn get_sound_file(&self) -> crate::models::config::SoundFile {
+ let config = self.config.read().await;
+ config.sound_file.clone()
+ }
}
diff --git a/backend/src/bin/generate_types.rs b/backend/src/bin/generate_types.rs
index e8f271cb..a8ac3c93 100644
--- a/backend/src/bin/generate_types.rs
+++ b/backend/src/bin/generate_types.rs
@@ -31,6 +31,26 @@ export const EDITOR_LABELS: Record = {
"intellij": "IntelliJ IDEA",
"zed": "Zed",
"custom": "Custom"
+};
+
+export const SOUND_FILES: SoundFile[] = [
+ "abstract-sound1",
+ "abstract-sound2",
+ "abstract-sound3",
+ "abstract-sound4",
+ "cow-mooing",
+ "phone-vibration",
+ "rooster"
+];
+
+export const SOUND_LABELS: Record = {
+ "abstract-sound1": "Gentle Chime",
+ "abstract-sound2": "Soft Bell",
+ "abstract-sound3": "Digital Tone",
+ "abstract-sound4": "Subtle Alert",
+ "cow-mooing": "Cow Mooing",
+ "phone-vibration": "Phone Vibration",
+ "rooster": "Rooster Call"
};"#
.to_string()
}
@@ -53,6 +73,9 @@ fn main() {
vibe_kanban::models::config::EditorConfig::decl(),
vibe_kanban::models::config::EditorType::decl(),
vibe_kanban::models::config::EditorConstants::decl(),
+ vibe_kanban::models::config::SoundFile::decl(),
+ vibe_kanban::models::config::SoundConstants::decl(),
+ vibe_kanban::routes::config::ConfigConstants::decl(),
vibe_kanban::executor::ExecutorConfig::decl(),
vibe_kanban::executor::ExecutorConstants::decl(),
vibe_kanban::models::project::CreateProject::decl(),
diff --git a/backend/src/execution_monitor.rs b/backend/src/execution_monitor.rs
index 54d2b87b..0321caf7 100644
--- a/backend/src/execution_monitor.rs
+++ b/backend/src/execution_monitor.rs
@@ -69,21 +69,23 @@ async fn commit_execution_changes(
}
/// Play a system sound notification
-async fn play_sound_notification() {
+async fn play_sound_notification(sound_file: &crate::models::config::SoundFile) {
// Use platform-specific sound notification
if cfg!(target_os = "macos") {
+ let sound_path = sound_file.to_path();
let _ = tokio::process::Command::new("afplay")
- .arg("/System/Library/Sounds/Glass.aiff")
+ .arg(sound_path)
.spawn();
} else if cfg!(target_os = "linux") {
// Try different Linux notification sounds
+ let sound_path = sound_file.to_path();
if let Ok(_) = tokio::process::Command::new("paplay")
- .arg("/usr/share/sounds/alsa/Front_Left.wav")
+ .arg(&sound_path)
.spawn()
{
// Success with paplay
} else if let Ok(_) = tokio::process::Command::new("aplay")
- .arg("/usr/share/sounds/alsa/Front_Left.wav")
+ .arg(&sound_path)
.spawn()
{
// Success with aplay
@@ -429,7 +431,8 @@ async fn handle_coding_agent_completion(
// Play sound notification if enabled
if app_state.get_sound_alerts_enabled().await {
- play_sound_notification().await;
+ let sound_file = app_state.get_sound_file().await;
+ play_sound_notification(&sound_file).await;
}
// Send push notification if enabled
diff --git a/backend/src/main.rs b/backend/src/main.rs
index 72ba38b2..17a9f2bf 100644
--- a/backend/src/main.rs
+++ b/backend/src/main.rs
@@ -83,6 +83,40 @@ async fn serve_file(path: &str) -> impl IntoResponse {
}
}
+async fn serve_sound_file(axum::extract::Path(filename): axum::extract::Path) -> impl IntoResponse {
+ use tokio::fs;
+ use std::path::Path;
+
+ // Validate filename contains only expected sound files
+ let valid_sounds = ["abstract-sound1.mp3", "abstract-sound2.mp3", "abstract-sound3.mp3",
+ "abstract-sound4.mp3", "cow-mooing.mp3", "phone-vibration.mp3", "rooster.mp3"];
+
+ if !valid_sounds.contains(&filename.as_str()) {
+ return Response::builder()
+ .status(StatusCode::NOT_FOUND)
+ .body(Body::from("Sound file not found"))
+ .unwrap();
+ }
+
+ let sound_path = Path::new("backend/sounds").join(&filename);
+
+ match fs::read(&sound_path).await {
+ Ok(content) => {
+ Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, HeaderValue::from_static("audio/mpeg"))
+ .body(Body::from(content))
+ .unwrap()
+ }
+ Err(_) => {
+ Response::builder()
+ .status(StatusCode::NOT_FOUND)
+ .body(Body::from("Sound file not found"))
+ .unwrap()
+ }
+ }
+}
+
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt().init();
@@ -130,7 +164,8 @@ async fn main() -> anyhow::Result<()> {
.merge(tasks::tasks_router())
.merge(task_attempts::task_attempts_router())
.merge(filesystem::filesystem_router())
- .merge(config::config_router()),
+ .merge(config::config_router())
+ .route("/sounds/:filename", get(serve_sound_file)),
)
.layer(Extension(pool.clone()))
.layer(Extension(config_arc));
diff --git a/backend/src/models/config.rs b/backend/src/models/config.rs
index a72454d3..52bcacf8 100644
--- a/backend/src/models/config.rs
+++ b/backend/src/models/config.rs
@@ -11,6 +11,7 @@ pub struct Config {
pub disclaimer_acknowledged: bool,
pub onboarding_acknowledged: bool,
pub sound_alerts: bool,
+ pub sound_file: SoundFile,
pub push_notifications: bool,
pub editor: EditorConfig,
}
@@ -43,6 +44,19 @@ pub enum EditorType {
Custom,
}
+#[derive(Debug, Clone, Serialize, Deserialize, TS)]
+#[ts(export)]
+#[serde(rename_all = "kebab-case")]
+pub enum SoundFile {
+ AbstractSound1,
+ AbstractSound2,
+ AbstractSound3,
+ AbstractSound4,
+ CowMooing,
+ PhoneVibration,
+ Rooster,
+}
+
// Constants for frontend
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
@@ -51,6 +65,13 @@ pub struct EditorConstants {
pub editor_labels: Vec,
}
+#[derive(Debug, Clone, Serialize, Deserialize, TS)]
+#[ts(export)]
+pub struct SoundConstants {
+ pub sound_files: Vec,
+ pub sound_labels: Vec,
+}
+
impl EditorConstants {
pub fn new() -> Self {
Self {
@@ -74,6 +95,31 @@ impl EditorConstants {
}
}
+impl SoundConstants {
+ pub fn new() -> Self {
+ Self {
+ sound_files: vec![
+ SoundFile::AbstractSound1,
+ SoundFile::AbstractSound2,
+ SoundFile::AbstractSound3,
+ SoundFile::AbstractSound4,
+ SoundFile::CowMooing,
+ SoundFile::PhoneVibration,
+ SoundFile::Rooster,
+ ],
+ sound_labels: vec![
+ "Gentle Chime".to_string(),
+ "Soft Bell".to_string(),
+ "Digital Tone".to_string(),
+ "Subtle Alert".to_string(),
+ "Cow Mooing".to_string(),
+ "Phone Vibration".to_string(),
+ "Rooster Call".to_string(),
+ ],
+ }
+ }
+}
+
impl Default for Config {
fn default() -> Self {
Self {
@@ -82,6 +128,7 @@ impl Default for Config {
disclaimer_acknowledged: false,
onboarding_acknowledged: false,
sound_alerts: true,
+ sound_file: SoundFile::AbstractSound4,
push_notifications: true,
editor: EditorConfig::default(),
}
@@ -116,6 +163,24 @@ impl EditorConfig {
}
}
+impl SoundFile {
+ pub fn to_filename(&self) -> &'static str {
+ match self {
+ SoundFile::AbstractSound1 => "abstract-sound1.mp3",
+ SoundFile::AbstractSound2 => "abstract-sound2.mp3",
+ SoundFile::AbstractSound3 => "abstract-sound3.mp3",
+ SoundFile::AbstractSound4 => "abstract-sound4.mp3",
+ SoundFile::CowMooing => "cow-mooing.mp3",
+ SoundFile::PhoneVibration => "phone-vibration.mp3",
+ SoundFile::Rooster => "rooster.mp3",
+ }
+ }
+
+ pub fn to_path(&self) -> PathBuf {
+ PathBuf::from("backend/sounds").join(self.to_filename())
+ }
+}
+
impl Config {
pub fn load(config_path: &PathBuf) -> anyhow::Result {
if config_path.exists() {
diff --git a/backend/src/routes/config.rs b/backend/src/routes/config.rs
index 513db4f0..e891ca49 100644
--- a/backend/src/routes/config.rs
+++ b/backend/src/routes/config.rs
@@ -7,13 +7,16 @@ use axum::{
use std::sync::Arc;
use tokio::sync::RwLock;
-use crate::models::{config::Config, ApiResponse};
+use crate::models::{config::{Config, EditorConstants, SoundConstants}, ApiResponse};
use crate::utils;
+use serde::{Deserialize, Serialize};
+use ts_rs::TS;
pub fn config_router() -> Router {
Router::new()
.route("/config", get(get_config))
.route("/config", post(update_config))
+ .route("/config/constants", get(get_config_constants))
}
async fn get_config(
@@ -51,3 +54,23 @@ async fn update_config(
}),
}
}
+
+#[derive(Debug, Serialize, Deserialize, TS)]
+#[ts(export)]
+pub struct ConfigConstants {
+ pub editor: EditorConstants,
+ pub sound: SoundConstants,
+}
+
+async fn get_config_constants() -> ResponseJson> {
+ let constants = ConfigConstants {
+ editor: EditorConstants::new(),
+ sound: SoundConstants::new(),
+ };
+
+ ResponseJson(ApiResponse {
+ success: true,
+ data: Some(constants),
+ message: Some("Config constants retrieved successfully".to_string()),
+ })
+}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index bf92c66f..76ec5b90 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -6,9 +6,9 @@ import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
-import { Loader2 } from "lucide-react";
-import type { ThemeMode, EditorType } from "shared/types";
-import { EXECUTOR_TYPES, EDITOR_TYPES, EXECUTOR_LABELS, EDITOR_LABELS } from "shared/types";
+import { Loader2, Volume2 } from "lucide-react";
+import type { ThemeMode, EditorType, SoundFile } from "shared/types";
+import { EXECUTOR_TYPES, EDITOR_TYPES, EXECUTOR_LABELS, EDITOR_LABELS, SOUND_FILES, SOUND_LABELS } from "shared/types";
import { useTheme } from "@/components/theme-provider";
import { useConfig } from "@/components/config-provider";
@@ -19,6 +19,15 @@ export function Settings() {
const [success, setSuccess] = useState(false);
const { setTheme } = useTheme();
+ const playSound = async (soundFile: SoundFile) => {
+ const audio = new Audio(`/api/sounds/${soundFile}.mp3`);
+ try {
+ await audio.play();
+ } catch (err) {
+ console.error("Failed to play sound:", err);
+ }
+ };
+
const handleSave = async () => {
if (!config) return;
@@ -245,6 +254,40 @@ export function Settings() {
+
+ {config.sound_alerts && (
+
+
+
+
+
+
+
+ Choose the sound to play when tasks complete. Click the volume button to preview.
+
+
+ )}
= { success: boolean, data: T | null, message: string | null, };
-export type Config = { theme: ThemeMode, executor: ExecutorConfig, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, sound_alerts: boolean, push_notifications: boolean, editor: EditorConfig, };
+export type Config = { theme: ThemeMode, executor: ExecutorConfig, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, sound_alerts: boolean, sound_file: SoundFile, push_notifications: boolean, editor: EditorConfig, };
export type ThemeMode = "light" | "dark" | "system";
@@ -14,6 +14,12 @@ export type EditorType = "vscode" | "cursor" | "windsurf" | "intellij" | "zed" |
export type EditorConstants = { editor_types: Array, editor_labels: Array, };
+export type SoundFile = "abstract-sound1" | "abstract-sound2" | "abstract-sound3" | "abstract-sound4" | "cow-mooing" | "phone-vibration" | "rooster";
+
+export type SoundConstants = { sound_files: Array, sound_labels: Array, };
+
+export type ConfigConstants = { editor: EditorConstants, sound: SoundConstants, };
+
export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "amp" };
export type ExecutorConstants = { executor_types: Array, executor_labels: Array, };
@@ -111,4 +117,24 @@ export const EDITOR_LABELS: Record = {
"intellij": "IntelliJ IDEA",
"zed": "Zed",
"custom": "Custom"
+};
+
+export const SOUND_FILES: SoundFile[] = [
+ "abstract-sound1",
+ "abstract-sound2",
+ "abstract-sound3",
+ "abstract-sound4",
+ "cow-mooing",
+ "phone-vibration",
+ "rooster"
+];
+
+export const SOUND_LABELS: Record = {
+ "abstract-sound1": "Gentle Chime",
+ "abstract-sound2": "Soft Bell",
+ "abstract-sound3": "Digital Tone",
+ "abstract-sound4": "Subtle Alert",
+ "cow-mooing": "Cow Mooing",
+ "phone-vibration": "Phone Vibration",
+ "rooster": "Rooster Call"
};
\ No newline at end of file