Files

95 lines
2.8 KiB
Go

// 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 := &notificationPlayback{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
}
}