95 lines
2.8 KiB
Go
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 := ¬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
|
|
}
|
|
}
|