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{} crescStopCh chan struct{} crescDoneCh chan struct{} } func New() *Player { return &Player{} } // resolveSound finds the actual file path for a sound. Returns "" if no file found. func resolveSound(path string) string { if path != "default" && path != "" { if _, err := os.Stat(path); err == nil { return path } return "" } candidates := []string{ "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga", "/usr/share/sounds/gnome/default/alerts/bark.ogg", "/usr/share/sounds/sound-icons/trumpet-12.wav", } for _, c := range candidates { if _, err := os.Stat(c); err == nil { return c } } return "" } // findPlayer returns the name and arg builder for the first available audio player. func findPlayer() (string, func(string) []string) { players := []struct { name string args func(string) []string }{ {"paplay", func(f string) []string { return []string{f} }}, {"aplay", func(f string) []string { return []string{f} }}, {"mpv", func(f string) []string { return []string{"--no-video", "--no-terminal", f} }}, {"ffplay", func(f string) []string { return []string{"-nodisp", "-autoexit", f} }}, } for _, pl := range players { if _, err := exec.LookPath(pl.name); err == nil { return pl.name, pl.args } } return "", nil } // 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() p.stopCh = make(chan struct{}) p.doneCh = make(chan struct{}) stopCh := p.stopCh 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) return } name, argsFn := findPlayer() if name == "" { os.Stdout.WriteString("\a") close(doneCh) return } go p.audioLoop(name, argsFn, resolved, stopCh, doneCh) } // audioLoop runs the audio player in a loop. The goroutine fully owns the // process — no other goroutine touches cmd. Coordination is only via channels. func (p *Player) audioLoop(name string, argsFn func(string) []string, path string, stopCh, doneCh chan struct{}) { defer close(doneCh) for { // Check if we should stop before starting a new process select { case <-stopCh: return default: } cmd := exec.Command(name, argsFn(path)...) cmd.Stdout = nil cmd.Stderr = nil if err := cmd.Start(); err != nil { return } // Wait for either the process to finish or a stop signal processDone := make(chan error, 1) go func() { processDone <- cmd.Wait() }() select { case <-stopCh: // Kill the process and wait for it to exit _ = cmd.Process.Kill() <-processDone return case <-processDone: // Process finished naturally, loop again after a brief pause } // Pause between loops select { case <-stopCh: return case <-time.After(300 * time.Millisecond): } } } func (p *Player) bellLoop(stopCh, doneCh chan struct{}) { defer close(doneCh) ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() os.Stdout.WriteString("\a") for { select { case <-stopCh: return case <-ticker.C: os.Stdout.WriteString("\a") } } } // 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) } if doneCh != nil { <-doneCh // wait for goroutine to actually finish } }