package player import ( "os" "os/exec" "sync" "time" ) // Player handles alarm sound playback by shelling out to system audio tools. type Player struct { mu sync.Mutex stopCh chan struct{} doneCh 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.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() 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") } } } // 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 p.stopCh = nil p.doneCh = nil p.mu.Unlock() if stopCh != nil { close(stopCh) } if doneCh != nil { <-doneCh // wait for goroutine to actually finish } }