Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,432 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user