// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package sound import ( "context" "errors" "fmt" "syscall" "time" "github.com/jfreymuth/pulse" ) const audioPeriod = 5 * time.Millisecond const pcmTimeout = 500 * time.Millisecond type pcmOutput interface { write([]float32) (int, error) prepare() error drain() error close() } func (s *Service) start_pcm(pcm pcmOutput) { drained := make(chan error, 1) s.closeBackend = pcm.close s.drainBackend = func(ctx context.Context) error { select { case err := <-drained: return err case <-ctx.Done(): return ctx.Err() case <-s.done: return nil } } s.workers.Go(func() { err := s.run_pcm(pcm) drained <- err if err != nil && !s.closed.Load() { select { case s.errors <- fmt.Errorf("ALSA playback: %w", err): default: } } }) } func (s *Service) run_pcm(pcm pcmOutput) error { // Pace even a virtual PCM such as ALSA's null sink. No busy silence loop, // and the same cancellation and cue priority apply to both audio drivers. tick := time.NewTicker(audioPeriod) defer tick.Stop() samples := make([]float32, int(int64(sampleRate*2)*int64(audioPeriod)/int64(time.Second))) for { select { case <-s.done: return nil case <-tick.C: } count, err := s.read_samples(samples) if err == errNotificationDrain { request := s.draining s.draining = nil if err := s.drain_notification(request, func(ctx context.Context) error { if err := s.drain_pcm(ctx, pcm); err != nil { return err } return pcm.prepare() }); err != nil { return err } continue } if err == pulse.EndOfData { ctx, cancel := context.WithTimeout(context.Background(), pcmTimeout) defer cancel() return s.drain_pcm(ctx, pcm) } if err != nil { return err } if err := s.write_pcm(pcm, samples[:count]); err != nil { return err } } } func (s *Service) write_pcm(pcm pcmOutput, samples []float32) error { ctx, cancel := context.WithTimeout(context.Background(), pcmTimeout) defer cancel() for len(samples) > 0 { select { case <-s.done: return nil case <-ctx.Done(): return fmt.Errorf("PCM write stalled: %w", ctx.Err()) default: } count, err := pcm.write(samples) if count < 0 || count > len(samples) || count%2 != 0 { return fmt.Errorf("invalid PCM sample count %d", count) } samples = samples[count:] switch { case errors.Is(err, syscall.EPIPE), errors.Is(err, syscall.ESTRPIPE): // prepare drops the broken buffer without snd_pcm_resume/recover's // potentially blocking suspend retry loop. Bound repeated failures. if err := pcm.prepare(); err != nil { return err } case err != nil && !errors.Is(err, syscall.EAGAIN) && !errors.Is(err, syscall.EINTR): return err case err == nil && count > 0: continue } if err := s.wait_pcm(ctx); err != nil { return err } } return nil } func (s *Service) drain_pcm(ctx context.Context, pcm pcmOutput) error { for { err := pcm.drain() if !errors.Is(err, syscall.EAGAIN) && !errors.Is(err, syscall.EINTR) { return err } if err := s.wait_pcm(ctx); err != nil { return err } } } func (s *Service) wait_pcm(ctx context.Context) error { timer := time.NewTimer(audioPeriod) defer timer.Stop() select { case <-s.done: return context.Canceled case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } }