diff --git a/db/db.go b/db/db.go index 7be50c6..7175842 100644 --- a/db/db.go +++ b/db/db.go @@ -207,22 +207,36 @@ type Settings struct { ServerPassword string // Password for authenticating to remote server APIPassword string // Password required for incoming API requests (host mode) PollSeconds int // How often to poll server in client mode + + // Crescendo volume control (PulseAudio) + CrescendoEnabled bool // Whether to gradually raise volume + CrescendoStartPct int // Starting volume percentage (0-100) + CrescendoEndPct int // Ending volume percentage (0-100) + CrescendoDurationS int // Seconds to ramp from start to end + + // Callback script + CallbackScript string // Path to script called on alarm events (start, dismiss, snooze) } func DefaultSettings() Settings { return Settings{ - SnoozeMinutes: 5, - TimeoutMinutes: 5, - DefaultSound: "default", - BlinkOnMs: 1000, - BlinkOffMs: 500, - ColorClock: "#00FF88", - ColorAlarm: "#FF4444", - ShowSeconds: true, - ServerURL: "", - ServerPassword: "", - APIPassword: "", - PollSeconds: 5, + SnoozeMinutes: 5, + TimeoutMinutes: 5, + DefaultSound: "default", + BlinkOnMs: 1000, + BlinkOffMs: 500, + ColorClock: "#00FF88", + ColorAlarm: "#FF4444", + ShowSeconds: true, + ServerURL: "", + ServerPassword: "", + APIPassword: "", + PollSeconds: 5, + CrescendoEnabled: false, + CrescendoStartPct: 20, + CrescendoEndPct: 100, + CrescendoDurationS: 60, + CallbackScript: "", } } @@ -240,23 +254,33 @@ func (s *Store) LoadSettings() Settings { cfg.ServerPassword = s.GetSetting("server_password", cfg.ServerPassword) cfg.APIPassword = s.GetSetting("api_password", cfg.APIPassword) cfg.PollSeconds = s.getSettingInt("poll_seconds", cfg.PollSeconds) + cfg.CrescendoEnabled = s.GetSetting("crescendo_enabled", "false") == "true" + cfg.CrescendoStartPct = s.getSettingInt("crescendo_start_pct", cfg.CrescendoStartPct) + cfg.CrescendoEndPct = s.getSettingInt("crescendo_end_pct", cfg.CrescendoEndPct) + cfg.CrescendoDurationS = s.getSettingInt("crescendo_duration_s", cfg.CrescendoDurationS) + cfg.CallbackScript = s.GetSetting("callback_script", cfg.CallbackScript) return cfg } func (s *Store) SaveSettings(cfg Settings) error { pairs := map[string]string{ - "snooze_minutes": fmt.Sprintf("%d", cfg.SnoozeMinutes), - "timeout_minutes": fmt.Sprintf("%d", cfg.TimeoutMinutes), - "default_sound": cfg.DefaultSound, - "blink_on_ms": fmt.Sprintf("%d", cfg.BlinkOnMs), - "blink_off_ms": fmt.Sprintf("%d", cfg.BlinkOffMs), - "color_clock": cfg.ColorClock, - "color_alarm": cfg.ColorAlarm, - "show_seconds": fmt.Sprintf("%t", cfg.ShowSeconds), - "server_url": cfg.ServerURL, - "server_password": cfg.ServerPassword, - "api_password": cfg.APIPassword, - "poll_seconds": fmt.Sprintf("%d", cfg.PollSeconds), + "snooze_minutes": fmt.Sprintf("%d", cfg.SnoozeMinutes), + "timeout_minutes": fmt.Sprintf("%d", cfg.TimeoutMinutes), + "default_sound": cfg.DefaultSound, + "blink_on_ms": fmt.Sprintf("%d", cfg.BlinkOnMs), + "blink_off_ms": fmt.Sprintf("%d", cfg.BlinkOffMs), + "color_clock": cfg.ColorClock, + "color_alarm": cfg.ColorAlarm, + "show_seconds": fmt.Sprintf("%t", cfg.ShowSeconds), + "server_url": cfg.ServerURL, + "server_password": cfg.ServerPassword, + "api_password": cfg.APIPassword, + "poll_seconds": fmt.Sprintf("%d", cfg.PollSeconds), + "crescendo_enabled": fmt.Sprintf("%t", cfg.CrescendoEnabled), + "crescendo_start_pct": fmt.Sprintf("%d", cfg.CrescendoStartPct), + "crescendo_end_pct": fmt.Sprintf("%d", cfg.CrescendoEndPct), + "crescendo_duration_s": fmt.Sprintf("%d", cfg.CrescendoDurationS), + "callback_script": cfg.CallbackScript, } for k, v := range pairs { if err := s.SetSetting(k, v); err != nil { diff --git a/player/player.go b/player/player.go index 7445fcc..54e2372 100644 --- a/player/player.go +++ b/player/player.go @@ -1,17 +1,28 @@ package player import ( + "fmt" "os" "os/exec" "sync" "time" ) +// CrescendoConfig holds volume ramp settings. +type CrescendoConfig struct { + Enabled bool + StartPct int // 0-100 + EndPct int // 0-100 + DurationS int // seconds to ramp +} + // Player handles alarm sound playback by shelling out to system audio tools. type Player struct { - mu sync.Mutex - stopCh chan struct{} - doneCh chan struct{} + mu sync.Mutex + stopCh chan struct{} + doneCh chan struct{} + crescStopCh chan struct{} + crescDoneCh chan struct{} } func New() *Player { @@ -60,6 +71,11 @@ func findPlayer() (string, func(string) []string) { // PlayLoop starts playing a sound file in a loop until Stop() is called. func (p *Player) PlayLoop(path string) { + p.PlayLoopWithCrescendo(path, CrescendoConfig{}) +} + +// PlayLoopWithCrescendo starts playing with optional volume ramp. +func (p *Player) PlayLoopWithCrescendo(path string, cresc CrescendoConfig) { p.Stop() // kill any previous playback and wait for it to finish p.mu.Lock() @@ -69,6 +85,19 @@ func (p *Player) PlayLoop(path string) { doneCh := p.doneCh p.mu.Unlock() + // Start crescendo if enabled and pactl is available + if cresc.Enabled && cresc.DurationS > 0 { + if _, err := exec.LookPath("pactl"); err == nil { + p.mu.Lock() + p.crescStopCh = make(chan struct{}) + p.crescDoneCh = make(chan struct{}) + crescStopCh := p.crescStopCh + crescDoneCh := p.crescDoneCh + p.mu.Unlock() + go p.crescendoLoop(cresc, crescStopCh, crescDoneCh) + } + } + resolved := resolveSound(path) if resolved == "" { go p.bellLoop(stopCh, doneCh) @@ -146,15 +175,74 @@ func (p *Player) bellLoop(stopCh, doneCh chan struct{}) { } } +// crescendoLoop gradually raises PulseAudio volume from StartPct to EndPct over DurationS seconds. +func (p *Player) crescendoLoop(cfg CrescendoConfig, stopCh, doneCh chan struct{}) { + defer close(doneCh) + + startPct := cfg.StartPct + endPct := cfg.EndPct + if startPct < 0 { + startPct = 0 + } + if endPct > 150 { // allow some overdrive but not ridiculous + endPct = 150 + } + if startPct >= endPct { + return + } + + // Set initial volume + setVolume(startPct) + + steps := cfg.DurationS + if steps < 1 { + steps = 1 + } + stepSize := float64(endPct-startPct) / float64(steps) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + currentPct := float64(startPct) + for i := 0; i < steps; i++ { + select { + case <-stopCh: + return + case <-ticker.C: + currentPct += stepSize + setVolume(int(currentPct)) + } + } + // Ensure we hit the target + setVolume(endPct) +} + +// setVolume sets the default PulseAudio sink volume to the given percentage. +func setVolume(pct int) { + _ = exec.Command("pactl", "set-sink-volume", "@DEFAULT_SINK@", fmt.Sprintf("%d%%", pct)).Run() +} + // Stop stops any currently playing sound and waits for the playback goroutine to exit. func (p *Player) Stop() { p.mu.Lock() stopCh := p.stopCh doneCh := p.doneCh + crescStopCh := p.crescStopCh + crescDoneCh := p.crescDoneCh p.stopCh = nil p.doneCh = nil + p.crescStopCh = nil + p.crescDoneCh = nil p.mu.Unlock() + // Stop crescendo + if crescStopCh != nil { + close(crescStopCh) + } + if crescDoneCh != nil { + <-crescDoneCh + } + + // Stop audio if stopCh != nil { close(stopCh) } diff --git a/ui/config.go b/ui/config.go index 3a11691..dd8ed3c 100644 --- a/ui/config.go +++ b/ui/config.go @@ -24,6 +24,11 @@ const ( cfgServerPassword cfgAPIPassword cfgPollSeconds + cfgCrescendoEnabled + cfgCrescendoStartPct + cfgCrescendoEndPct + cfgCrescendoDurationS + cfgCallbackScript cfgFieldCount ) @@ -51,6 +56,15 @@ func newConfigModel(cfg db.Settings) *configModel { c.fields[cfgServerPassword] = cfg.ServerPassword c.fields[cfgAPIPassword] = cfg.APIPassword c.fields[cfgPollSeconds] = strconv.Itoa(cfg.PollSeconds) + if cfg.CrescendoEnabled { + c.fields[cfgCrescendoEnabled] = "true" + } else { + c.fields[cfgCrescendoEnabled] = "false" + } + c.fields[cfgCrescendoStartPct] = strconv.Itoa(cfg.CrescendoStartPct) + c.fields[cfgCrescendoEndPct] = strconv.Itoa(cfg.CrescendoEndPct) + c.fields[cfgCrescendoDurationS] = strconv.Itoa(cfg.CrescendoDurationS) + c.fields[cfgCallbackScript] = cfg.CallbackScript return c } @@ -90,11 +104,12 @@ func (c *configModel) HandleKey(msg tea.KeyMsg, m *Model) (tea.Model, tea.Cmd) { case " ": // Toggle boolean fields - if c.active == cfgShowSeconds { - if c.fields[cfgShowSeconds] == "true" { - c.fields[cfgShowSeconds] = "false" + if c.active == cfgShowSeconds || c.active == cfgCrescendoEnabled { + field := &c.fields[c.active] + if *field == "true" { + *field = "false" } else { - c.fields[cfgShowSeconds] = "true" + *field = "true" } return *m, nil } @@ -106,7 +121,7 @@ func (c *configModel) HandleKey(msg tea.KeyMsg, m *Model) (tea.Model, tea.Cmd) { default: if len(key) == 1 { // Don't allow free typing on boolean fields - if c.active == cfgShowSeconds { + if c.active == cfgShowSeconds || c.active == cfgCrescendoEnabled { return *m, nil } c.fields[c.active] += key @@ -159,6 +174,24 @@ func (c *configModel) save(m *Model) (tea.Model, tea.Cmd) { return *m, nil } + crescStartPct, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgCrescendoStartPct])) + if err != nil || crescStartPct < 0 || crescStartPct > 100 { + c.err = "Crescendo start must be 0-100%" + return *m, nil + } + + crescEndPct, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgCrescendoEndPct])) + if err != nil || crescEndPct < 0 || crescEndPct > 150 { + c.err = "Crescendo end must be 0-150%" + return *m, nil + } + + crescDuration, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgCrescendoDurationS])) + if err != nil || crescDuration < 1 || crescDuration > 300 { + c.err = "Crescendo duration must be 1-300 seconds" + return *m, nil + } + cfg := db.Settings{ SnoozeMinutes: snooze, TimeoutMinutes: timeout, @@ -177,6 +210,12 @@ func (c *configModel) save(m *Model) (tea.Model, tea.Cmd) { cfg.APIPassword = strings.TrimSpace(c.fields[cfgAPIPassword]) cfg.PollSeconds = pollSeconds + cfg.CrescendoEnabled = strings.TrimSpace(c.fields[cfgCrescendoEnabled]) == "true" + cfg.CrescendoStartPct = crescStartPct + cfg.CrescendoEndPct = crescEndPct + cfg.CrescendoDurationS = crescDuration + cfg.CallbackScript = strings.TrimSpace(c.fields[cfgCallbackScript]) + if cfg.DefaultSound == "" { cfg.DefaultSound = "default" } @@ -208,6 +247,11 @@ func (c *configModel) View() string { "Server pass:", "API password:", "Poll (sec):", + "Enabled:", + "Start %:", + "End %:", + "Duration (s):", + "Script path:", } hints := [cfgFieldCount]string{ @@ -223,12 +267,31 @@ func (c *configModel) View() string { "password to auth with server", "password for incoming API requests", "1-60, client poll frequency", + "space to toggle", + "0-100, starting volume", + "0-150, ending volume (>100 = overdrive)", + "1-300, ramp duration in seconds", + "called with: start|dismiss|snooze|timeout ", + } + + // Section headers: field index -> section name + sections := map[cfgField]string{ + cfgSnoozeMinutes: "─── Alarm ───", + cfgBlinkOnMs: "─── Display ───", + cfgServerURL: "─── Network ───", + cfgCrescendoEnabled: "─── Crescendo (pactl) ───", + cfgCallbackScript: "─── Hooks ───", } var lines []string lines = append(lines, TitleStyle.Render("Settings"), "") for i := cfgField(0); i < cfgFieldCount; i++ { + // Insert section header if this field starts a new section + if section, ok := sections[i]; ok { + lines = append(lines, "", DividerStyle.Render(section)) + } + labelStr := fmt.Sprintf("%15s", labels[i]) value := c.fields[i] diff --git a/ui/model.go b/ui/model.go index 4ae38a8..008da00 100644 --- a/ui/model.go +++ b/ui/model.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "os/exec" "strings" "time" "woke/db" @@ -200,6 +201,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case autoTimeoutMsg: if m.firingAlarm != nil { m.player.Stop() + m.runCallback("timeout", m.firingAlarm.Name) m.statusMsg = fmt.Sprintf("Alarm auto-dismissed after %d minutes", m.settings.TimeoutMinutes) m.firingAlarm = nil m.snoozeCount = 0 @@ -232,12 +234,14 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "enter", " ", "d": m.player.Stop() alarm := m.firingAlarm + m.runCallback("dismiss", alarm.Name) m.firingAlarm = nil m.snoozeCount = 0 m.statusMsg = fmt.Sprintf("Alarm '%s' dismissed", alarm.Name) case "s": m.player.Stop() alarm := *m.firingAlarm + m.runCallback("snooze", alarm.Name) dur := m.snoozeDuration() m.firingAlarm = nil m.snoozeUntil = time.Now().Add(dur) @@ -342,7 +346,30 @@ func (m *Model) startFiring(alarm *db.Alarm) { if sound == "default" || sound == "" { sound = m.settings.DefaultSound } - m.player.PlayLoop(sound) + + // Use crescendo if enabled + cresc := player.CrescendoConfig{ + Enabled: m.settings.CrescendoEnabled, + StartPct: m.settings.CrescendoStartPct, + EndPct: m.settings.CrescendoEndPct, + DurationS: m.settings.CrescendoDurationS, + } + m.player.PlayLoopWithCrescendo(sound, cresc) + + // Fire callback + m.runCallback("start", alarm.Name) +} + +// runCallback executes the configured callback script with the event type and alarm name. +func (m *Model) runCallback(event, alarmName string) { + script := m.settings.CallbackScript + if script == "" { + return + } + // Run in background, don't block + go func() { + _ = exec.Command(script, event, alarmName).Run() + }() } func snoozeCmd(alarm db.Alarm, after time.Duration) tea.Cmd { diff --git a/woke b/woke index d553ea4..6c4299c 100755 Binary files a/woke and b/woke differ