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 }