// 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() } }) }