433 lines
12 KiB
Go
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
|
|
}
|