// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . // Package sound plays preloaded cues through one persistent in-process stream. package sound import ( "context" "errors" "fmt" "math" "os" "path/filepath" "strconv" "sync" "sync/atomic" "time" "github.com/jfreymuth/pulse" "git.stormux.org/storm/fenrir/internal/presentation" ) type Service struct { clips map[presentation.Cue]*clip pending atomic.Pointer[clip] notification atomic.Pointer[clip] draining *notificationPlayback // audio callback owns this boundary tones [101]*clip indentationTones [101]*clip progress atomic.Pointer[clip] cancelProgress atomic.Bool progressing bool // audio callback owns this flag startup atomic.Pointer[clip] closed atomic.Bool finishing atomic.Bool volume atomic.Uint32 // float32 bits shared with the audio callback current *clip // audio callback owns current and offset offset int starting bool // audio callback keeps the startup cue through its last sample closeBackend func() drainBackend func(context.Context) error errors chan error done chan struct{} workers sync.WaitGroup } // Open may return a usable service plus warnings for missing individual cues. // Availability failures are nonfatal to speech and keyboard operation. func Open(roots []string, options Options) (*Service, error) { if err := Validate_selection(options.Driver, options.Device); err != nil { return nil, err } volume := options.Volume if math.IsNaN(volume) || math.IsInf(volume, 0) || volume < 0 || volume > 1 { return nil, fmt.Errorf("sound volume must be between 0.0 and 1.0") } clips, warning := load_theme(roots) // Generated tones remain available even without an installed sound pack. s := &Service{clips: clips, tones: progress_clips(), indentationTones: indentation_clips(), errors: make(chan error, 1), done: make(chan struct{})} s.Set_volume(volume) driver, _ := Find_driver(options.Driver) if err := driver.open(s, options.Device); err != nil { return nil, errors.Join(warning, err) } return s, warning } func (s *Service) Errors() <-chan error { return s.errors } // Set_volume applies a validated 0..1 gain to the next audio buffer, including // any cue or progress tone already playing. Cached samples remain unchanged. func (s *Service) Set_volume(volume float64) { s.volume.Store(math.Float32bits(float32(volume))) } // Server returns PulseAudio protocol addresses in connection order. The bundled // PipeWire helper exposes /tmp/pulse.sock for console clients; system-wide // PulseAudio services can expose /run/pulse/native. // The client library tries each address, including after authentication errors. func Server(explicit, sudoUID string) string { if explicit != "" { return explicit } runtimeDir := os.Getenv("XDG_RUNTIME_DIR") if uid, err := strconv.ParseUint(sudoUID, 10, 32); err == nil { runtimeDir = fmt.Sprintf("/run/user/%d", uid) } else if runtimeDir == "" { runtimeDir = fmt.Sprintf("/run/user/%d", os.Getuid()) } return "unix:" + filepath.Join(runtimeDir, "pulse/native") + " unix:/tmp/pulse.sock unix:/run/pulse/native" } // Play only replaces a mailbox pointer. Startup has its own slot so initial // screen feedback cannot replace it. Other cues retain only the latest request. // Admission never waits for audio or touches the filesystem. func (s *Service) Play(cue presentation.Cue) bool { if s == nil || s.closed.Load() || s.finishing.Load() { return false } if item := s.clips[cue]; item != nil { if cue == presentation.Ready { s.startup.Store(item) } else { s.pending.Store(item) } return true } return false } func (s *Service) read_samples(samples []float32) (int, error) { clear(samples) if s.closed.Load() { return len(samples), nil } // Notification cues retain their tail despite ordinary cue updates. Their // boundary drains the device before speech is released by Play_wait. if s.current != nil && s.current.notification != nil { request := s.current.notification if request.ctx.Err() != nil || s.finishing.Load() { s.offset = len(s.current.samples) } if s.offset == len(s.current.samples) { s.draining, s.current = request, nil return 0, errNotificationDrain } return s.copy_samples(samples) } if s.cancelProgress.Swap(false) && s.progressing { s.current, s.progressing = nil, false } if s.finishing.Load() { s.progress.Store(nil) s.startup.Store(nil) s.starting = false } else if next := s.startup.Swap(nil); next != nil { s.current, s.offset, s.starting, s.progressing = next, 0, true, false } if !s.starting { if next := s.notification.Swap(nil); next != nil && next.notification.ctx.Err() == nil && !s.finishing.Load() { s.current, s.offset, s.progressing = next, 0, false } else if next := s.pending.Swap(nil); next != nil { s.current, s.offset, s.progressing = next, 0, false } } if s.current == nil && !s.finishing.Load() { if next := s.progress.Swap(nil); next != nil { s.current, s.offset, s.progressing = next, 0, true } } if s.current != nil && s.offset == len(s.current.samples) && s.current.finished != nil { close(s.current.finished) s.current = nil return 0, pulse.EndOfData } return s.copy_samples(samples) } func (s *Service) copy_samples(samples []float32) (int, error) { if s.current != nil { count := copy(samples, s.current.samples[s.offset:]) volume := math.Float32frombits(s.volume.Load()) if s.progressing { volume *= progressGain } for index := range count { samples[index] *= volume } s.offset += count if s.offset == len(s.current.samples) && s.current.finished == nil && s.current.notification == nil { s.current = nil s.starting, s.progressing = false, false } } return len(samples), nil } // Finish is called after input/review admission stops and keyboards are // released. Wait for the final cue's samples to be submitted, then drain the // server before Close destroys the stream. Waiting is bounded by the clip's // duration plus one second even when callbacks stop arriving. func (s *Service) Finish(ctx context.Context) error { if s == nil || s.closed.Load() || s.finishing.Swap(true) { return nil } s.Clear_progress() item := s.clips[presentation.Stopped] if item == nil || math.Float32frombits(s.volume.Load()) == 0 { return nil } ctx, cancel := context.WithTimeout(ctx, time.Second+time.Duration(len(item.samples))*time.Second/(sampleRate*2)) defer cancel() final := &clip{samples: item.samples, finished: make(chan struct{})} s.pending.Store(final) select { case <-final.finished: if s.drainBackend != nil { return s.drainBackend(ctx) } return nil case <-s.done: return nil case err := <-s.errors: return err case <-ctx.Done(): return ctx.Err() } } func (s *Service) Close() { if s == nil || s.closed.Swap(true) { return } s.pending.Store(nil) s.notification.Store(nil) s.Clear_progress() s.startup.Store(nil) if s.done != nil { close(s.done) } s.workers.Wait() if s.closeBackend != nil { s.closeBackend() } }