Files

433 lines
12 KiB
Go

package sound
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"math"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
bundled "git.stormux.org/storm/cthulhu/assets/sounds"
)
const queueCapacity = 8
const maxSoundBytes = 4 * 1024 * 1024
type Service struct {
stopMutex sync.Mutex
player Player
logger *slog.Logger
theme string
volume float64
queue chan queuedCue
done chan struct{}
cancel context.CancelFunc
mutex sync.Mutex
running bool
enabled bool
stopPending bool
dropped atomic.Uint64
cache map[string][]byte
available map[string]bool
generation uint64
active context.CancelFunc
activeID uint64
// The sound worker owns one pending progress value under mutex. Updates
// replace this slot without canceling playback or the role-sound queue.
pendingProgress *queuedCue
progressWake chan struct{}
}
type queuedCue struct {
Cue
generation uint64
}
func New(logger *slog.Logger, player Player, theme string, volume float64) *Service {
return &Service{
logger: logger, player: player, theme: theme, volume: volume, enabled: true,
queue: make(chan queuedCue, queueCapacity), cache: make(map[string][]byte), available: cueAvailability(theme),
progressWake: make(chan struct{}, 1),
}
}
// Cue is immutable presentation data for one sound. Pan ranges from -1 (left)
// through 0 (center) to 1 (right). Cue names never encode placement metadata.
type Cue struct {
Name string
Pan float64
}
type Configuration struct {
Enabled bool
Sink string
Theme string
Volume float64
}
// Configure atomically changes playback without restarting the owned worker.
// Active and pending cues from the old policy are canceled.
func (service *Service) Configure(configuration Configuration) {
available := cueAvailability(configuration.Theme)
service.mutex.Lock()
defer service.mutex.Unlock()
service.generation++
if service.active != nil {
service.active()
service.active = nil
}
service.discardPending()
service.enabled = configuration.Enabled && configuration.Theme != "none" && configuration.Volume > 0
service.theme = configuration.Theme
service.volume = configuration.Volume
service.available = available
if player, ok := service.player.(NativePlayer); ok {
player.Sink = configuration.Sink
service.player = player
}
}
// Available reports whether current policy permits a named cue and the
// configured theme contains it. It performs no filesystem I/O.
func (service *Service) Available(name string) bool {
service.mutex.Lock()
defer service.mutex.Unlock()
return service.running && service.cue_available(name)
}
func (service *Service) Start(ctx context.Context) error {
service.mutex.Lock()
defer service.mutex.Unlock()
if service.done != nil {
return errors.New("sound service already started")
}
workerContext, cancel := context.WithCancel(ctx)
service.cancel = cancel
service.done = make(chan struct{})
service.running = true
service.stopPending = false
go service.run(workerContext, service.done)
return nil
}
func (service *Service) Enqueue(cue Cue) bool {
return service.EnqueueBatch([]Cue{cue})
}
// EnqueueBatch replaces the previous presentation, then plays its cues in
// order. Python Cthulhu interrupts on the first cue and appends later cues.
// A rejected batch leaves the current presentation alone so sound-only callers
// can retain speech when its cues are unavailable. A single progress cue instead
// replaces the pending percentage and lets the active sound finish.
func (service *Service) EnqueueBatch(cues []Cue) bool {
if len(cues) == 0 {
return true
}
service.mutex.Lock()
defer service.mutex.Unlock()
if !service.running {
return false
}
if len(cues) > cap(service.queue) {
service.dropped.Add(uint64(len(cues)))
return false
}
for _, cue := range cues {
if !service.cue_available(cue.Name) {
return false
}
}
if len(cues) == 1 {
if _, progress := progress_percentage(cues[0].Name); progress {
cue := cues[0]
cue.Pan = normalizedPan(cue.Pan)
service.pendingProgress = &queuedCue{Cue: cue, generation: service.generation}
select {
case service.progressWake <- struct{}{}:
default:
}
return true
}
}
service.generation++
if service.active != nil {
service.active()
}
service.discardPending()
for _, cue := range cues {
cue.Pan = normalizedPan(cue.Pan)
service.queue <- queuedCue{Cue: cue, generation: service.generation}
}
return true
}
// The caller holds mutex, excluding producers. The worker can still receive
// before acquiring mutex, so checking len and then receiving can deadlock.
func (service *Service) discardPending() {
service.pendingProgress = nil
for {
select {
case <-service.queue:
default:
return
}
}
}
func (service *Service) run(ctx context.Context, done chan struct{}) {
defer close(done)
for {
cue, ok := service.next_cue(ctx)
if !ok {
return
}
if count := service.dropped.Swap(0); count != 0 {
service.logger.Warn("sound queue overflow", "dropped", count)
}
if err := service.playQueued(ctx, cue); err != nil && ctx.Err() == nil {
service.logger.Warn("play sound", "cue", cue.Name, "error", err)
}
}
}
// Preserve the order of ordinary presentation cues before taking the newest
// progress value. progressWake is only a notification; mutex owns the slot.
func (service *Service) next_cue(ctx context.Context) (queuedCue, bool) {
for {
if ctx.Err() != nil {
return queuedCue{}, false
}
service.mutex.Lock()
select {
case cue := <-service.queue:
service.mutex.Unlock()
return cue, true
default:
}
pending := service.pendingProgress
service.pendingProgress = nil
service.mutex.Unlock()
if pending != nil {
return *pending, true
}
select {
case <-ctx.Done():
return queuedCue{}, false
case cue := <-service.queue:
return cue, true
case <-service.progressWake:
}
}
}
func (service *Service) playQueued(ctx context.Context, cue queuedCue) error {
service.mutex.Lock()
if cue.generation != service.generation || !service.running || !service.cue_available(cue.Name) {
service.mutex.Unlock()
return nil
}
player, theme, volume := service.player, service.theme, service.volume
if _, valid := progress_percentage(cue.Name); valid {
volume = 1
}
cacheKey := theme + "\x00" + cue.Name
data, exists := service.cache[cacheKey]
service.activeID++
activeID := service.activeID
playContext, cancel := context.WithTimeout(ctx, 3*time.Second)
service.active = cancel
service.mutex.Unlock()
defer func() {
cancel()
service.mutex.Lock()
if service.activeID == activeID {
service.active = nil
}
service.mutex.Unlock()
}()
if !exists {
var err error
data, err = soundData(theme, cue.Name)
if err != nil {
return err
}
service.mutex.Lock()
if !generated_tone(cue.Name) {
service.cache[cacheKey] = data
}
service.mutex.Unlock()
}
return player.Play(playContext, data, volume, normalizedPan(cue.Pan))
}
func (service *Service) play(ctx context.Context, cue Cue) error {
service.mutex.Lock()
player, theme, volume := service.player, service.theme, service.volume
if _, valid := progress_percentage(cue.Name); valid {
volume = 1
}
cacheKey := theme + "\x00" + cue.Name
data, exists := service.cache[cacheKey]
service.mutex.Unlock()
if !exists {
var err error
data, err = soundData(theme, cue.Name)
if err != nil {
return err
}
service.mutex.Lock()
if !generated_tone(cue.Name) {
service.cache[cacheKey] = data
}
service.mutex.Unlock()
}
playContext, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
return player.Play(playContext, data, volume, normalizedPan(cue.Pan))
}
func normalizedPan(pan float64) float64 {
if math.IsNaN(pan) || math.IsInf(pan, 0) || pan < -1 || pan > 1 {
return 0
}
return pan
}
var namedCues = []string{
"start", "stop", "button", "checkbox_checked", "checkbox_mixed", "checkbox_unchecked",
"autocomplete", "combobox", "link", "link_visited", "radiobutton_checked", "radiobutton_unchecked",
"togglebutton_checked", "togglebutton_unchecked", "switch_on", "switch_off", "browse_mode", "focus_mode",
}
func cueAvailability(theme string) map[string]bool {
available := make(map[string]bool, len(namedCues))
for _, name := range namedCues {
if data, err := load_sound(theme, name); err == nil && len(data) <= maxSoundBytes {
available[name] = true
}
}
return available
}
// Called under the service mutex. Progress output is controlled by its own
// output mode, independent of role-sound enablement, theme and volume.
func (service *Service) cue_available(name string) bool {
if _, valid := indentation_frequency(name); valid {
return service.enabled && service.theme != "none" && service.volume > 0
}
if _, valid := progress_percentage(name); valid {
return true
}
if _, valid := set_position(name); valid {
return service.enabled && service.theme != "none" && service.volume > 0
}
return service.enabled && service.available[name]
}
func generated_tone(name string) bool {
_, indentation := indentation_frequency(name)
_, progress := progress_percentage(name)
_, position := set_position(name)
return progress || position || indentation
}
func soundData(theme, cue string) ([]byte, error) {
if strings.HasPrefix(cue, indentationCuePrefix) {
frequency, valid := indentation_frequency(cue)
if !valid {
return nil, errors.New("invalid generated indentation tone")
}
return indentation_tone_wav(frequency), nil
}
if strings.HasPrefix(cue, positionCuePrefix) {
position, valid := set_position(cue)
if !valid {
return nil, errors.New("invalid generated position tone")
}
return position_tone_wav(position), nil
}
if strings.HasPrefix(cue, progressCuePrefix) {
percentage, valid := progress_percentage(cue)
if !valid {
return nil, errors.New("invalid generated progress tone")
}
return progress_tone_wav(percentage), nil
}
return load_sound(theme, cue)
}
// Stop cancels any active cue, discards pending cues, and plays the stop cue
// with the caller's shutdown deadline. The sound service never owns speech.
func (service *Service) Stop(ctx context.Context) error {
service.stopMutex.Lock()
defer service.stopMutex.Unlock()
service.mutex.Lock()
if service.done == nil {
service.mutex.Unlock()
return nil
}
playStopCue := (service.running && service.enabled && service.available["stop"]) || service.stopPending
service.running = false
service.discardPending()
service.stopPending = true
done := service.done
if done != nil {
service.cancel()
}
service.mutex.Unlock()
if done != nil {
select {
case <-done:
case <-ctx.Done():
return ctx.Err()
}
}
if playStopCue {
if err := service.play(ctx, Cue{Name: "stop"}); err != nil {
return err
}
}
service.mutex.Lock()
service.stopPending = false
service.done = nil
service.mutex.Unlock()
return nil
}
func load_sound(theme, cue string) ([]byte, error) {
if filepath.Base(theme) != theme || theme == "." || theme == ".." || filepath.Base(cue) != cue || cue == "." || cue == ".." {
return nil, errors.New("invalid sound theme or cue name")
}
if theme == "default" {
return bundled.Default.ReadFile("default/" + cue + ".wav")
}
dataHome := os.Getenv("XDG_DATA_HOME")
if dataHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
dataHome = filepath.Join(home, ".local", "share")
}
file, err := os.Open(filepath.Join(dataHome, "cthulhu", "sounds", theme, cue+".wav"))
if err != nil {
return nil, err
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, maxSoundBytes+1))
if err != nil {
return nil, err
}
if len(data) > maxSoundBytes {
return nil, fmt.Errorf("sound exceeds %d bytes", maxSoundBytes)
}
return data, nil
}