Add callbacks, crescendo sound and better configs

This commit is contained in:
2026-02-04 20:55:03 +02:00
parent cdcdf2c644
commit 621815ed0f
5 changed files with 235 additions and 33 deletions

View File

@@ -207,22 +207,36 @@ type Settings struct {
ServerPassword string // Password for authenticating to remote server ServerPassword string // Password for authenticating to remote server
APIPassword string // Password required for incoming API requests (host mode) APIPassword string // Password required for incoming API requests (host mode)
PollSeconds int // How often to poll server in client 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 { func DefaultSettings() Settings {
return Settings{ return Settings{
SnoozeMinutes: 5, SnoozeMinutes: 5,
TimeoutMinutes: 5, TimeoutMinutes: 5,
DefaultSound: "default", DefaultSound: "default",
BlinkOnMs: 1000, BlinkOnMs: 1000,
BlinkOffMs: 500, BlinkOffMs: 500,
ColorClock: "#00FF88", ColorClock: "#00FF88",
ColorAlarm: "#FF4444", ColorAlarm: "#FF4444",
ShowSeconds: true, ShowSeconds: true,
ServerURL: "", ServerURL: "",
ServerPassword: "", ServerPassword: "",
APIPassword: "", APIPassword: "",
PollSeconds: 5, 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.ServerPassword = s.GetSetting("server_password", cfg.ServerPassword)
cfg.APIPassword = s.GetSetting("api_password", cfg.APIPassword) cfg.APIPassword = s.GetSetting("api_password", cfg.APIPassword)
cfg.PollSeconds = s.getSettingInt("poll_seconds", cfg.PollSeconds) 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 return cfg
} }
func (s *Store) SaveSettings(cfg Settings) error { func (s *Store) SaveSettings(cfg Settings) error {
pairs := map[string]string{ pairs := map[string]string{
"snooze_minutes": fmt.Sprintf("%d", cfg.SnoozeMinutes), "snooze_minutes": fmt.Sprintf("%d", cfg.SnoozeMinutes),
"timeout_minutes": fmt.Sprintf("%d", cfg.TimeoutMinutes), "timeout_minutes": fmt.Sprintf("%d", cfg.TimeoutMinutes),
"default_sound": cfg.DefaultSound, "default_sound": cfg.DefaultSound,
"blink_on_ms": fmt.Sprintf("%d", cfg.BlinkOnMs), "blink_on_ms": fmt.Sprintf("%d", cfg.BlinkOnMs),
"blink_off_ms": fmt.Sprintf("%d", cfg.BlinkOffMs), "blink_off_ms": fmt.Sprintf("%d", cfg.BlinkOffMs),
"color_clock": cfg.ColorClock, "color_clock": cfg.ColorClock,
"color_alarm": cfg.ColorAlarm, "color_alarm": cfg.ColorAlarm,
"show_seconds": fmt.Sprintf("%t", cfg.ShowSeconds), "show_seconds": fmt.Sprintf("%t", cfg.ShowSeconds),
"server_url": cfg.ServerURL, "server_url": cfg.ServerURL,
"server_password": cfg.ServerPassword, "server_password": cfg.ServerPassword,
"api_password": cfg.APIPassword, "api_password": cfg.APIPassword,
"poll_seconds": fmt.Sprintf("%d", cfg.PollSeconds), "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 { for k, v := range pairs {
if err := s.SetSetting(k, v); err != nil { if err := s.SetSetting(k, v); err != nil {

View File

@@ -1,17 +1,28 @@
package player package player
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
"sync" "sync"
"time" "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. // Player handles alarm sound playback by shelling out to system audio tools.
type Player struct { type Player struct {
mu sync.Mutex mu sync.Mutex
stopCh chan struct{} stopCh chan struct{}
doneCh chan struct{} doneCh chan struct{}
crescStopCh chan struct{}
crescDoneCh chan struct{}
} }
func New() *Player { 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. // PlayLoop starts playing a sound file in a loop until Stop() is called.
func (p *Player) PlayLoop(path string) { 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.Stop() // kill any previous playback and wait for it to finish
p.mu.Lock() p.mu.Lock()
@@ -69,6 +85,19 @@ func (p *Player) PlayLoop(path string) {
doneCh := p.doneCh doneCh := p.doneCh
p.mu.Unlock() 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) resolved := resolveSound(path)
if resolved == "" { if resolved == "" {
go p.bellLoop(stopCh, doneCh) 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. // Stop stops any currently playing sound and waits for the playback goroutine to exit.
func (p *Player) Stop() { func (p *Player) Stop() {
p.mu.Lock() p.mu.Lock()
stopCh := p.stopCh stopCh := p.stopCh
doneCh := p.doneCh doneCh := p.doneCh
crescStopCh := p.crescStopCh
crescDoneCh := p.crescDoneCh
p.stopCh = nil p.stopCh = nil
p.doneCh = nil p.doneCh = nil
p.crescStopCh = nil
p.crescDoneCh = nil
p.mu.Unlock() p.mu.Unlock()
// Stop crescendo
if crescStopCh != nil {
close(crescStopCh)
}
if crescDoneCh != nil {
<-crescDoneCh
}
// Stop audio
if stopCh != nil { if stopCh != nil {
close(stopCh) close(stopCh)
} }

View File

@@ -24,6 +24,11 @@ const (
cfgServerPassword cfgServerPassword
cfgAPIPassword cfgAPIPassword
cfgPollSeconds cfgPollSeconds
cfgCrescendoEnabled
cfgCrescendoStartPct
cfgCrescendoEndPct
cfgCrescendoDurationS
cfgCallbackScript
cfgFieldCount cfgFieldCount
) )
@@ -51,6 +56,15 @@ func newConfigModel(cfg db.Settings) *configModel {
c.fields[cfgServerPassword] = cfg.ServerPassword c.fields[cfgServerPassword] = cfg.ServerPassword
c.fields[cfgAPIPassword] = cfg.APIPassword c.fields[cfgAPIPassword] = cfg.APIPassword
c.fields[cfgPollSeconds] = strconv.Itoa(cfg.PollSeconds) 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 return c
} }
@@ -90,11 +104,12 @@ func (c *configModel) HandleKey(msg tea.KeyMsg, m *Model) (tea.Model, tea.Cmd) {
case " ": case " ":
// Toggle boolean fields // Toggle boolean fields
if c.active == cfgShowSeconds { if c.active == cfgShowSeconds || c.active == cfgCrescendoEnabled {
if c.fields[cfgShowSeconds] == "true" { field := &c.fields[c.active]
c.fields[cfgShowSeconds] = "false" if *field == "true" {
*field = "false"
} else { } else {
c.fields[cfgShowSeconds] = "true" *field = "true"
} }
return *m, nil return *m, nil
} }
@@ -106,7 +121,7 @@ func (c *configModel) HandleKey(msg tea.KeyMsg, m *Model) (tea.Model, tea.Cmd) {
default: default:
if len(key) == 1 { if len(key) == 1 {
// Don't allow free typing on boolean fields // Don't allow free typing on boolean fields
if c.active == cfgShowSeconds { if c.active == cfgShowSeconds || c.active == cfgCrescendoEnabled {
return *m, nil return *m, nil
} }
c.fields[c.active] += key c.fields[c.active] += key
@@ -159,6 +174,24 @@ func (c *configModel) save(m *Model) (tea.Model, tea.Cmd) {
return *m, nil 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{ cfg := db.Settings{
SnoozeMinutes: snooze, SnoozeMinutes: snooze,
TimeoutMinutes: timeout, TimeoutMinutes: timeout,
@@ -177,6 +210,12 @@ func (c *configModel) save(m *Model) (tea.Model, tea.Cmd) {
cfg.APIPassword = strings.TrimSpace(c.fields[cfgAPIPassword]) cfg.APIPassword = strings.TrimSpace(c.fields[cfgAPIPassword])
cfg.PollSeconds = pollSeconds 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 == "" { if cfg.DefaultSound == "" {
cfg.DefaultSound = "default" cfg.DefaultSound = "default"
} }
@@ -208,6 +247,11 @@ func (c *configModel) View() string {
"Server pass:", "Server pass:",
"API password:", "API password:",
"Poll (sec):", "Poll (sec):",
"Enabled:",
"Start %:",
"End %:",
"Duration (s):",
"Script path:",
} }
hints := [cfgFieldCount]string{ hints := [cfgFieldCount]string{
@@ -223,12 +267,31 @@ func (c *configModel) View() string {
"password to auth with server", "password to auth with server",
"password for incoming API requests", "password for incoming API requests",
"1-60, client poll frequency", "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 <name>",
}
// Section headers: field index -> section name
sections := map[cfgField]string{
cfgSnoozeMinutes: "─── Alarm ───",
cfgBlinkOnMs: "─── Display ───",
cfgServerURL: "─── Network ───",
cfgCrescendoEnabled: "─── Crescendo (pactl) ───",
cfgCallbackScript: "─── Hooks ───",
} }
var lines []string var lines []string
lines = append(lines, TitleStyle.Render("Settings"), "") lines = append(lines, TitleStyle.Render("Settings"), "")
for i := cfgField(0); i < cfgFieldCount; i++ { 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]) labelStr := fmt.Sprintf("%15s", labels[i])
value := c.fields[i] value := c.fields[i]

View File

@@ -2,6 +2,7 @@ package ui
import ( import (
"fmt" "fmt"
"os/exec"
"strings" "strings"
"time" "time"
"woke/db" "woke/db"
@@ -200,6 +201,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case autoTimeoutMsg: case autoTimeoutMsg:
if m.firingAlarm != nil { if m.firingAlarm != nil {
m.player.Stop() m.player.Stop()
m.runCallback("timeout", m.firingAlarm.Name)
m.statusMsg = fmt.Sprintf("Alarm auto-dismissed after %d minutes", m.settings.TimeoutMinutes) m.statusMsg = fmt.Sprintf("Alarm auto-dismissed after %d minutes", m.settings.TimeoutMinutes)
m.firingAlarm = nil m.firingAlarm = nil
m.snoozeCount = 0 m.snoozeCount = 0
@@ -232,12 +234,14 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "enter", " ", "d": case "enter", " ", "d":
m.player.Stop() m.player.Stop()
alarm := m.firingAlarm alarm := m.firingAlarm
m.runCallback("dismiss", alarm.Name)
m.firingAlarm = nil m.firingAlarm = nil
m.snoozeCount = 0 m.snoozeCount = 0
m.statusMsg = fmt.Sprintf("Alarm '%s' dismissed", alarm.Name) m.statusMsg = fmt.Sprintf("Alarm '%s' dismissed", alarm.Name)
case "s": case "s":
m.player.Stop() m.player.Stop()
alarm := *m.firingAlarm alarm := *m.firingAlarm
m.runCallback("snooze", alarm.Name)
dur := m.snoozeDuration() dur := m.snoozeDuration()
m.firingAlarm = nil m.firingAlarm = nil
m.snoozeUntil = time.Now().Add(dur) m.snoozeUntil = time.Now().Add(dur)
@@ -342,7 +346,30 @@ func (m *Model) startFiring(alarm *db.Alarm) {
if sound == "default" || sound == "" { if sound == "default" || sound == "" {
sound = m.settings.DefaultSound 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 { func snoozeCmd(alarm db.Alarm, after time.Duration) tea.Cmd {

BIN
woke

Binary file not shown.