219 lines
5.9 KiB
Go
219 lines
5.9 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package sound
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/fenrir/internal/audio"
|
|
"git.stormux.org/storm/fenrir/internal/presentation"
|
|
)
|
|
|
|
type soundBackend interface {
|
|
Play(presentation.Cue) bool
|
|
Play_wait(context.Context, presentation.Cue) error
|
|
Play_progress(float64) bool
|
|
Play_indentation(int) bool
|
|
Discard_progress()
|
|
Clear_progress()
|
|
Set_volume(float64)
|
|
Errors() <-chan error
|
|
Finish(context.Context) error
|
|
Close()
|
|
}
|
|
|
|
type soundConnection struct{ backend soundBackend }
|
|
|
|
var errSoundAttemptStalled = errors.New("sound driver open did not return before its deadline")
|
|
|
|
// Player keeps connection work away from cue admission and keyboard handling.
|
|
// Missing output rejects cues so the caller can present their speech equivalent.
|
|
type Player struct {
|
|
connection atomic.Pointer[soundConnection]
|
|
volume atomic.Uint64
|
|
cancel context.CancelFunc
|
|
done chan struct{}
|
|
errors chan error
|
|
once sync.Once
|
|
}
|
|
|
|
func Open_recovering(ctx context.Context, roots []string, options Options) *Player {
|
|
return new_player(ctx, options.Volume, func() (soundBackend, error) {
|
|
service, err := Open(roots, options)
|
|
if service == nil {
|
|
return nil, err
|
|
}
|
|
if err != nil {
|
|
slog.Warn("sound unavailable or incomplete", "error", err, "at", time.Now())
|
|
}
|
|
return service, nil
|
|
})
|
|
}
|
|
|
|
func new_player(ctx context.Context, volume float64, open func() (soundBackend, error)) *Player {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
p := &Player{cancel: cancel, done: make(chan struct{}), errors: make(chan error, 1)}
|
|
p.volume.Store(math.Float64bits(volume))
|
|
// Even a driver whose connection or stream-start call ignores cancellation
|
|
// cannot hold startup forever. Never retry an attempt that is still stuck.
|
|
backend, err := sound_attempt(ctx, open)
|
|
if err == nil {
|
|
p.connection.Store(&soundConnection{backend})
|
|
backend.Set_volume(volume)
|
|
} else {
|
|
slog.Warn("sound unavailable or incomplete", "reconnecting", true, "error", err, "at", time.Now())
|
|
}
|
|
go p.run(ctx, open, err)
|
|
return p
|
|
}
|
|
|
|
// A timed-out native/library call is quarantined once. A late result is closed
|
|
// by its own worker; no further opener goroutines are started for this player.
|
|
func sound_attempt(ctx context.Context, open func() (soundBackend, error)) (soundBackend, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
type result struct {
|
|
backend soundBackend
|
|
err error
|
|
}
|
|
results := make(chan result)
|
|
go func() {
|
|
backend, err := open()
|
|
select {
|
|
case results <- result{backend, err}:
|
|
case <-ctx.Done():
|
|
if backend != nil {
|
|
backend.Close()
|
|
}
|
|
}
|
|
}()
|
|
select {
|
|
case result := <-results:
|
|
return result.backend, result.err
|
|
case <-ctx.Done():
|
|
return nil, errors.Join(errSoundAttemptStalled, ctx.Err())
|
|
}
|
|
}
|
|
|
|
func (p *Player) run(ctx context.Context, open func() (soundBackend, error), initialError error) {
|
|
defer close(p.done)
|
|
problem := initialError
|
|
for {
|
|
connection := p.connection.Load()
|
|
if connection == nil {
|
|
if errors.Is(problem, errSoundAttemptStalled) || errors.Is(problem, context.Canceled) {
|
|
if ctx.Err() == nil {
|
|
p.errors <- fmt.Errorf("sound initialization timed out: %w", problem)
|
|
}
|
|
return
|
|
}
|
|
retryContext, cancel := context.WithTimeout(ctx, audio.RecoveryTimeout)
|
|
var backend soundBackend
|
|
err := audio.Retry(retryContext, func(ctx context.Context) error {
|
|
var err error
|
|
backend, err = sound_attempt(ctx, open)
|
|
return err
|
|
}, func(err error) bool {
|
|
return !errors.Is(err, errSoundAttemptStalled) && !errors.Is(err, context.Canceled)
|
|
})
|
|
cancel()
|
|
if err != nil {
|
|
if ctx.Err() == nil {
|
|
p.errors <- fmt.Errorf("sound recovery failed: %w", err)
|
|
}
|
|
return
|
|
}
|
|
connection = &soundConnection{backend}
|
|
p.connection.Store(connection)
|
|
backend.Set_volume(math.Float64frombits(p.volume.Load()))
|
|
slog.Info("sound connection restored", "at", time.Now())
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case problem = <-connection.backend.Errors():
|
|
p.connection.Store(nil)
|
|
connection.backend.Close()
|
|
slog.Warn("sound connection lost; reconnecting", "error", problem, "at", time.Now())
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Player) Errors() <-chan error { return p.errors }
|
|
|
|
func (p *Player) Available() bool { return p.connection.Load() != nil }
|
|
|
|
func (p *Player) Play(cue presentation.Cue) bool {
|
|
if connection := p.connection.Load(); connection != nil {
|
|
return connection.backend.Play(cue)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p *Player) Play_wait(ctx context.Context, cue presentation.Cue) error {
|
|
if connection := p.connection.Load(); connection != nil {
|
|
return connection.backend.Play_wait(ctx, cue)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Player) Play_progress(value float64) bool {
|
|
if connection := p.connection.Load(); connection != nil {
|
|
return connection.backend.Play_progress(value)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p *Player) Play_indentation(level int) bool {
|
|
if connection := p.connection.Load(); connection != nil {
|
|
return connection.backend.Play_indentation(level)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p *Player) Discard_progress() {
|
|
if connection := p.connection.Load(); connection != nil {
|
|
connection.backend.Discard_progress()
|
|
}
|
|
}
|
|
|
|
func (p *Player) Clear_progress() {
|
|
if connection := p.connection.Load(); connection != nil {
|
|
connection.backend.Clear_progress()
|
|
}
|
|
}
|
|
|
|
func (p *Player) Set_volume(volume float64) {
|
|
p.volume.Store(math.Float64bits(volume))
|
|
if connection := p.connection.Load(); connection != nil {
|
|
connection.backend.Set_volume(volume)
|
|
}
|
|
}
|
|
|
|
func (p *Player) Finish(ctx context.Context) error {
|
|
p.cancel()
|
|
<-p.done
|
|
if connection := p.connection.Load(); connection != nil {
|
|
return connection.backend.Finish(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Player) Close() {
|
|
p.once.Do(func() {
|
|
p.cancel()
|
|
<-p.done
|
|
if connection := p.connection.Swap(nil); connection != nil {
|
|
connection.backend.Close()
|
|
}
|
|
})
|
|
}
|