151 lines
3.4 KiB
Go
151 lines
3.4 KiB
Go
// 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
|
|
}
|
|
}
|