// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package sound import ( "context" "errors" "math" "time" "github.com/jfreymuth/pulse" "git.stormux.org/storm/fenrir/internal/presentation" ) var errNotificationDrain = errors.New("notification playback needs draining") type notificationPlayback struct { ctx context.Context drained chan struct{} err error // published by closing drained } // Play_wait runs on the speech worker, never the reader's input loop. A // completion means the output device drained, rather than just received samples. // Missing, silent or disconnected sound leaves speech available immediately. func (s *Service) Play_wait(ctx context.Context, cue presentation.Cue) error { if s == nil || s.closed.Load() || s.finishing.Load() || math.Float32frombits(s.volume.Load()) == 0 { return nil } item := s.clips[cue] if item == nil || ctx.Err() != nil { return ctx.Err() } // Allow an in-flight startup cue to finish, plus this cue and a drain margin. sampleCount := len(item.samples) if startup := s.clips[presentation.Ready]; startup != nil { sampleCount += len(startup.samples) } ctx, cancel := context.WithTimeout(ctx, time.Second+time.Duration(sampleCount)*time.Second/(sampleRate*2)) defer cancel() request := ¬ificationPlayback{ctx: ctx, drained: make(chan struct{})} queued := &clip{samples: item.samples, notification: request} if !s.notification.CompareAndSwap(nil, queued) { return errors.New("notification sound already pending") } defer s.notification.CompareAndSwap(queued, nil) select { case <-request.drained: return request.err case <-s.done: return nil case <-ctx.Done(): return ctx.Err() } } func (s *Service) drain_notification(request *notificationPlayback, drain func(context.Context) error) error { ctx, cancel := context.WithTimeout(context.Background(), pcmTimeout) defer cancel() request.err = drain(ctx) close(request.drained) return request.err } // Pulse's protocol reader sends buffer requests through an unbuffered channel. // A callback blocked for the whole drain would also block its drain reply. Yield // empty reads at a paced interval so requests are consumed without feeding any // more samples (including silence) until the server acknowledges the drain. func (s *Service) read_pulse_samples(samples []float32, drainRequests chan<- *notificationPlayback) (int, error) { if s.draining != nil { timer := time.NewTimer(audioPeriod) defer timer.Stop() select { case <-s.draining.drained: s.draining = nil case <-s.done: return 0, pulse.EndOfData case <-timer.C: return 0, nil } } count, err := s.read_samples(samples) if err != errNotificationDrain { return count, err } select { case drainRequests <- s.draining: return 0, nil case <-s.done: return 0, pulse.EndOfData } }