Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"strconv"
"strings"
"time"
)
const indentationCuePrefix = "generated-indentation-tone:"
func IndentationCueName(frequency int) string { return indentationCuePrefix + strconv.Itoa(frequency) }
func indentation_frequency(name string) (int, bool) {
if !strings.HasPrefix(name, indentationCuePrefix) {
return 0, false
}
frequency, err := strconv.Atoi(strings.TrimPrefix(name, indentationCuePrefix))
return frequency, err == nil && frequency >= 100 && frequency <= 2750
}
func indentation_tone_wav(frequency int) []byte {
return tone_wav(float64(frequency), 150*time.Millisecond, .7, 5*time.Millisecond)
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"encoding/binary"
"log/slog"
"testing"
)
func TestIndentationToneIsBoundedAndHonorsSoundPolicy(t *testing.T) {
service := New(slog.New(slog.DiscardHandler), nil, "default", .5)
for _, frequency := range []int{100, 200, 2750} {
name := IndentationCueName(frequency)
data, err := soundData("default", name)
if err != nil || len(data) != 44+3307*2 || binary.LittleEndian.Uint32(data[24:]) != 22050 {
t.Fatalf("indentation WAV: bytes %d, error %v", len(data), err)
}
if !service.cue_available(name) || !generated_tone(name) {
t.Fatal("generated indentation unavailable")
}
}
if _, err := soundData("default", IndentationCueName(2751)); err == nil {
t.Fatal("unbounded pitch accepted")
}
service.Configure(Configuration{Enabled: false, Theme: "default", Volume: .5})
if service.cue_available(IndentationCueName(200)) {
t.Fatal("disabled sound played indentation")
}
service.Configure(Configuration{Enabled: true, Theme: "none", Volume: .5})
if service.cue_available(IndentationCueName(200)) {
t.Fatal("None theme played indentation")
}
}
+110
View File
@@ -0,0 +1,110 @@
package sound
/*
#cgo pkg-config: gstreamer-1.0
#include <gst/gst.h>
#include <stdlib.h>
static GstElement *start_sound(const void *data, size_t length, double volume, double pan, const char *sink, GError **error) {
if (!gst_init_check(NULL, NULL, error)) return NULL;
gchar *description = g_strdup_printf("appsrc name=source ! wavparse ! audioconvert ! audioresample ! audiopanorama name=panorama ! volume name=volume ! %s", sink);
GError *parse_error = NULL;
GstElement *pipeline = gst_parse_launch(description, &parse_error);
g_free(description);
if (parse_error != NULL) {
if (pipeline) gst_object_unref(pipeline);
g_error_free(parse_error);
// audiopanorama is an optional GStreamer plugin. Preserve playback
// when it is unavailable; the typed cue remains safely centered.
description = g_strdup_printf("appsrc name=source ! wavparse ! audioconvert ! audioresample ! volume name=volume ! %s", sink);
pipeline = gst_parse_launch(description, error);
g_free(description);
if (*error != NULL) { if (pipeline) gst_object_unref(pipeline); return NULL; }
}
GstElement *source = gst_bin_get_by_name(GST_BIN(pipeline), "source");
GstElement *gain = gst_bin_get_by_name(GST_BIN(pipeline), "volume");
GstElement *panorama = gst_bin_get_by_name(GST_BIN(pipeline), "panorama");
g_object_set(gain, "volume", volume, NULL);
if (panorama != NULL) g_object_set(panorama, "panorama", pan, NULL);
gst_object_unref(gain);
if (panorama != NULL) gst_object_unref(panorama);
GstBuffer *buffer = gst_buffer_new_allocate(NULL, length, NULL);
gst_buffer_fill(buffer, 0, data, length);
GstFlowReturn flow;
g_signal_emit_by_name(source, "push-buffer", buffer, &flow);
gst_buffer_unref(buffer);
g_signal_emit_by_name(source, "end-of-stream", &flow);
gst_object_unref(source);
if (gst_element_set_state(pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) {
g_set_error_literal(error, g_quark_from_static_string("cthulhu-sound"), 1, "cannot start audio pipeline");
gst_element_set_state(pipeline, GST_STATE_NULL); gst_object_unref(pipeline); return NULL;
}
return pipeline;
}
static int poll_sound(GstElement *pipeline, GError **error) {
GstBus *bus = gst_element_get_bus(pipeline);
GstMessage *message = gst_bus_timed_pop_filtered(bus, 20 * GST_MSECOND, GST_MESSAGE_ERROR | GST_MESSAGE_EOS);
gst_object_unref(bus);
if (message == NULL) return 0;
int result = 1;
if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) {
gst_message_parse_error(message, error, NULL); result = -1;
}
gst_message_unref(message); return result;
}
static void stop_sound(GstElement *pipeline) {
gst_element_set_state(pipeline, GST_STATE_NULL); gst_object_unref(pipeline);
}
*/
import "C"
import (
"context"
"errors"
"fmt"
"unsafe"
)
type Player interface {
Play(context.Context, []byte, float64, float64) error
}
type NativePlayer struct{ Sink string }
func (player NativePlayer) Play(ctx context.Context, data []byte, volume, pan float64) error {
if err := ctx.Err(); err != nil {
return err
}
if len(data) == 0 {
return errors.New("empty sound")
}
sink := map[string]string{"auto": "autoaudiosink", "pipewire": "pipewiresink", "pulse": "pulsesink", "alsa": "alsasink", "test": "fakesink"}[player.Sink]
if sink == "" {
return fmt.Errorf("unknown sound sink %q", player.Sink)
}
nativeSink := C.CString(sink)
defer C.free(unsafe.Pointer(nativeSink))
var nativeErr *C.GError
pan = normalizedPan(pan)
pipeline := C.start_sound(unsafe.Pointer(&data[0]), C.size_t(len(data)), C.double(volume), C.double(pan), nativeSink, &nativeErr)
if nativeErr != nil {
defer C.g_error_free(nativeErr)
return errors.New(C.GoString(nativeErr.message))
}
if pipeline == nil {
return errors.New("cannot create sound pipeline")
}
defer C.stop_sound(pipeline)
for {
if err := ctx.Err(); err != nil {
return err
}
result := C.poll_sound(pipeline, &nativeErr)
if nativeErr != nil {
defer C.g_error_free(nativeErr)
return errors.New(C.GoString(nativeErr.message))
}
if result > 0 {
return nil
}
}
}
+33
View File
@@ -0,0 +1,33 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"math"
"strconv"
"strings"
"time"
)
const positionCuePrefix = "generated-position-tone:"
// PositionCueName encodes relative position: zero is first, one is last.
func PositionCueName(position float64) string {
return positionCuePrefix + strconv.FormatFloat(position, 'g', -1, 64)
}
func set_position(cue string) (float64, bool) {
if !strings.HasPrefix(cue, positionCuePrefix) {
return 0, false
}
position, err := strconv.ParseFloat(strings.TrimPrefix(cue, positionCuePrefix), 64)
return position, err == nil && !math.IsNaN(position) && !math.IsInf(position, 0) && position >= 0 && position <= 1
}
func position_tone_wav(position float64) []byte {
// Two octaves distinguish the ends of a set. Smooth the edges of this
// short cue; the service supplies the user's volume and stereo position.
return tone_wav(220*math.Pow(4, position), 60*time.Millisecond, 0.5, 5*time.Millisecond)
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"context"
"encoding/binary"
"log/slog"
"testing"
"time"
)
func TestPositionTonesDecodeAndRiseInPitch(t *testing.T) {
previousCrossings := 0
for _, position := range []float64{0, 0.5, 1} {
data, err := soundData("default", PositionCueName(position))
if err != nil {
t.Fatal(err)
}
if binary.LittleEndian.Uint32(data[24:]) != 22050 || binary.LittleEndian.Uint16(data[22:]) != 1 || len(data) != 44+1323*2 {
t.Fatal("position tone is not 60 ms mono PCM at 22050 Hz")
}
crossings := 0
previousSample := int16(0)
for offset := 44; offset < len(data); offset += 2 {
sample := int16(binary.LittleEndian.Uint16(data[offset:]))
if previousSample < 0 && sample >= 0 {
crossings++
}
previousSample = sample
}
if crossings <= previousCrossings {
t.Fatalf("pitch failed to rise at %g: %d cycles", position, crossings)
}
previousCrossings = crossings
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
err = (NativePlayer{Sink: "test"}).Play(ctx, data, 0.3, -0.5)
cancel()
if err != nil {
t.Fatal(err)
}
}
for _, value := range []string{"NaN", "+Inf", "-1", "1.1", "bad"} {
if _, err := soundData("default", positionCuePrefix+value); err == nil {
t.Fatalf("accepted position %s", value)
}
}
}
func TestPositionSoundUsesObjectSoundPolicy(t *testing.T) {
player := &configurationPlayer{played: make(chan configuredPlayback, 8)}
service := New(slog.New(slog.DiscardHandler), player, "default", 0.3)
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := service.Stop(context.Background()); err != nil {
t.Error(err)
}
})
cue := Cue{Name: PositionCueName(0.5), Pan: -0.5}
if !service.Enqueue(cue) {
t.Fatal("position cue was rejected")
}
select {
case playback := <-player.played:
if playback.volume != 0.3 || playback.pan != -0.5 {
t.Fatalf("position playback = %+v", playback)
}
case <-time.After(time.Second):
t.Fatal("position tone did not reach player")
}
service.mutex.Lock()
cached := len(service.cache)
service.mutex.Unlock()
if cached != 0 {
t.Fatal("continuous position values populated the sound cache")
}
for _, configuration := range []Configuration{
{Enabled: false, Theme: "default", Volume: 0.3},
{Enabled: true, Theme: "none", Volume: 0.3},
{Enabled: true, Theme: "default", Volume: 0},
} {
service.Configure(configuration)
if service.Available(cue.Name) || service.Enqueue(cue) {
t.Fatalf("position ignored policy: %+v", configuration)
}
if !service.Available(ProgressCueName(50)) {
t.Fatal("position policy affected progress tones")
}
}
service.Configure(Configuration{Enabled: true, Theme: "custom", Volume: 0.3})
if !service.Available(cue.Name) {
t.Fatal("generated position requires a theme asset")
}
}
+64
View File
@@ -0,0 +1,64 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"bytes"
"encoding/binary"
"math"
"strconv"
"strings"
)
const progressCuePrefix = "generated-progress-tone:"
func ProgressCueName(percentage float64) string {
return progressCuePrefix + strconv.FormatFloat(percentage, 'g', -1, 64)
}
func progress_percentage(cue string) (float64, bool) {
if !strings.HasPrefix(cue, progressCuePrefix) {
return 0, false
}
percentage, err := strconv.ParseFloat(strings.TrimPrefix(cue, progressCuePrefix), 64)
return percentage, err == nil && !math.IsNaN(percentage) && !math.IsInf(percentage, 0) && percentage >= 0 && percentage <= 100
}
// Match NVDA's default ProgressBar event_valueChange and generateBeep:
// 110 * 2^(percentage/25) Hz, 40 ms padded to the next integer cycle,
// 44100 Hz stereo PCM, and a sine clipped after multiplying by two.
// NVDA passes frequency through a C float and defaults each channel to 50%.
// Reference: nvaccess/nvda a4a89720ccbf3973a0e1e50a76dd8bff38db3d15,
// source/NVDAObjects/behaviors.py and nvdaHelper/local/beeps.cpp.
func progress_tone_wav(percentage float64) []byte {
const sampleRate = 44100
frequency := float64(float32(110 * math.Pow(2, percentage/25)))
// sampleRate/hz is evaluated as a float in NVDA's C++ generator.
cycleLength := float64(float32(sampleRate) / float32(frequency))
samplesPerCycle := int(cycleLength)
sampleCount := int(0.040 / (1.0 / sampleRate))
sampleCount += samplesPerCycle - sampleCount%samplesPerCycle
dataSize := sampleCount * 4
buffer := bytes.NewBuffer(make([]byte, 0, 44+dataSize))
buffer.WriteString("RIFF")
_ = binary.Write(buffer, binary.LittleEndian, uint32(36+dataSize))
buffer.WriteString("WAVEfmt ")
_ = binary.Write(buffer, binary.LittleEndian, uint32(16))
_ = binary.Write(buffer, binary.LittleEndian, uint16(1))
_ = binary.Write(buffer, binary.LittleEndian, uint16(2))
_ = binary.Write(buffer, binary.LittleEndian, uint32(sampleRate))
_ = binary.Write(buffer, binary.LittleEndian, uint32(sampleRate*4))
_ = binary.Write(buffer, binary.LittleEndian, uint16(4))
_ = binary.Write(buffer, binary.LittleEndian, uint16(16))
buffer.WriteString("data")
_ = binary.Write(buffer, binary.LittleEndian, uint32(dataSize))
sinFrequency := 2 * math.Pi / cycleLength
for sample := 0; sample < sampleCount; sample++ {
value := int16(min(1.0, max(-1.0, math.Sin(float64(sample%sampleRate)*sinFrequency)*2)) * 7000)
_ = binary.Write(buffer, binary.LittleEndian, value)
_ = binary.Write(buffer, binary.LittleEndian, value)
}
return buffer.Bytes()
}
+185
View File
@@ -0,0 +1,185 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"log/slog"
"testing"
"time"
)
func TestProgressAudioMatchesNVDAGenerator(t *testing.T) {
// PCM digests from the unmodified generateBeep in the reference commit
// named in progress.go, compiled locally with a small output-only harness.
all := sha256.New()
for percentage := 0; percentage <= 100; percentage++ {
data := progress_tone_wav(float64(percentage))
if binary.LittleEndian.Uint32(data[24:]) != 44100 || binary.LittleEndian.Uint16(data[22:]) != 2 {
t.Fatal("progress tone is not 44100 Hz stereo")
}
all.Write(data[44:])
}
if got := fmt.Sprintf("%x", all.Sum(nil)); got != "ef56e46be365269af544e22d94eb1de7d171c5946d1dd5b168cc7e570c0a159f" {
t.Fatalf("0 through 100 percent PCM differs from NVDA: %s", got)
}
for percentage, digest := range map[float64]string{
12.5: "4fc87ad5310038ed281a4516e9431d5dc247b7ce75662e4fe51cb4d2ab616a36",
50.25: "6b4a9a7d254686b2fb52ead07763ff596ccdcbdd30d5e9d4ce956df13cc1bdf5",
} {
if got := fmt.Sprintf("%x", sha256.Sum256(progress_tone_wav(percentage)[44:])); got != digest {
t.Fatalf("%g percent PCM differs from NVDA: %s", percentage, got)
}
}
}
type progressPlayback struct {
context context.Context
data []byte
}
type progressTestPlayer struct {
started chan progressPlayback
release chan struct{}
}
func (player *progressTestPlayer) Play(ctx context.Context, data []byte, _, _ float64) error {
player.started <- progressPlayback{context: ctx, data: data}
select {
case <-ctx.Done():
return ctx.Err()
case <-player.release:
return nil
}
}
func new_progress_test_service(t *testing.T) (*Service, *progressTestPlayer) {
t.Helper()
player := &progressTestPlayer{started: make(chan progressPlayback, 16), release: make(chan struct{}, 16)}
service := New(slog.New(slog.DiscardHandler), player, "none", 0)
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := service.Stop(ctx); err != nil {
t.Error(err)
}
})
return service, player
}
func next_progress_playback(t *testing.T, player *progressTestPlayer) progressPlayback {
t.Helper()
select {
case playback := <-player.started:
return playback
case <-time.After(time.Second):
t.Fatal("sound did not start")
return progressPlayback{}
}
}
func TestRapidProgressFinishesActiveToneAndPlaysLatestValue(t *testing.T) {
service, player := new_progress_test_service(t)
service.Enqueue(Cue{Name: ProgressCueName(0)})
first := next_progress_playback(t, player)
for percentage := 1; percentage <= 100; percentage++ {
if !service.EnqueueBatch([]Cue{{Name: ProgressCueName(float64(percentage))}}) {
t.Fatal("rapid progress update rejected")
}
}
if err := first.context.Err(); err != nil {
t.Fatalf("rapid updates canceled the active progress tone: %v", err)
}
player.release <- struct{}{}
latest := next_progress_playback(t, player)
if !bytes.Equal(latest.data, progress_tone_wav(100)) {
t.Fatal("played an obsolete percentage instead of the latest value")
}
}
func TestProgressPreservesActiveAndPendingRoleSounds(t *testing.T) {
service, player := new_progress_test_service(t)
service.mutex.Lock()
service.enabled, service.theme, service.volume = true, "default", 0.5
service.available = map[string]bool{"button": true, "link": true}
service.mutex.Unlock()
service.EnqueueBatch([]Cue{{Name: "button"}, {Name: "link"}})
first := next_progress_playback(t, player)
service.Enqueue(Cue{Name: ProgressCueName(100)})
if err := first.context.Err(); err != nil {
t.Fatalf("progress interrupted a role sound: %v", err)
}
player.release <- struct{}{}
second := next_progress_playback(t, player)
want, err := load_sound("default", "link")
if err != nil || !bytes.Equal(second.data, want) {
t.Fatal("progress displaced a pending role sound")
}
player.release <- struct{}{}
if got := next_progress_playback(t, player); !bytes.Equal(got.data, progress_tone_wav(100)) {
t.Fatal("latest progress did not follow the role sounds")
}
}
func TestProgressCancellationDiscardsPendingValue(t *testing.T) {
for _, action := range []string{"presentation", "configure", "stop"} {
t.Run(action, func(t *testing.T) {
service, player := new_progress_test_service(t)
service.Enqueue(Cue{Name: ProgressCueName(0)})
first := next_progress_playback(t, player)
service.Enqueue(Cue{Name: ProgressCueName(100)})
switch action {
case "presentation":
service.mutex.Lock()
service.enabled, service.available["button"] = true, true
service.theme = "default"
service.mutex.Unlock()
service.Enqueue(Cue{Name: "button"})
case "configure":
service.Configure(Configuration{Theme: "none"})
case "stop":
if err := service.Stop(context.Background()); err != nil {
t.Fatal(err)
}
}
if first.context.Err() == nil {
t.Fatal("active progress was not canceled")
}
service.mutex.Lock()
pending := service.pendingProgress
service.mutex.Unlock()
if pending != nil {
t.Fatal("obsolete progress remained pending")
}
})
}
}
func TestProgressSoundIsIndependentOfRoleSoundSettings(t *testing.T) {
service := New(nil, nil, "none", 0)
service.Configure(Configuration{Enabled: false, Theme: "none", Volume: 0})
service.running = true
if service.Available("button") || !service.Available(ProgressCueName(50.25)) {
t.Fatal("progress tone availability follows role-sound policy")
}
if !service.Enqueue(Cue{Name: ProgressCueName(50.25)}) {
t.Fatal("progress tone rejected with role sounds disabled")
}
for _, value := range []string{"NaN", "+Inf", "-1", "101", "bad"} {
if service.Available(progressCuePrefix + value) {
t.Fatalf("invalid percentage %s available", value)
}
if _, err := soundData("none", progressCuePrefix+value); err == nil {
t.Fatalf("invalid percentage %s decoded", value)
}
}
}
+432
View File
@@ -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
}
+628
View File
@@ -0,0 +1,628 @@
package sound
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func TestBundledSoundsDecodeInProcess(t *testing.T) {
if !cueAvailability("default")["autocomplete"] {
t.Fatal("default theme does not offer the autocomplete cue")
}
for _, cue := range []string{"start", "stop", "autocomplete", "button", "checkbox_checked", "checkbox_mixed", "checkbox_unchecked", "combobox", "link", "link_visited", "radiobutton_checked", "radiobutton_unchecked", "togglebutton_checked", "togglebutton_unchecked", "switch_on", "switch_off", "browse_mode", "focus_mode"} {
t.Run(cue, func(t *testing.T) {
data, err := load_sound("default", cue)
if err != nil {
t.Fatal(err)
}
// The first GStreamer initialization can exceed one second on a
// cold process even when the cue itself is short.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := (NativePlayer{Sink: "test"}).Play(ctx, data, 0.5, 0); err != nil {
t.Fatal(err)
}
})
}
}
func TestGeneratedProgressSoundsDecodeInProcess(t *testing.T) {
for _, percentage := range []int{0, 50, 99, 100} {
data, err := soundData("default", fmt.Sprintf("generated-progress-tone:%d", percentage))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
err = (NativePlayer{Sink: "test"}).Play(ctx, data, 0.5, 0)
cancel()
if err != nil {
t.Fatalf("percentage %d: %v", percentage, err)
}
}
if _, err := soundData("default", "generated-progress-tone:101"); err == nil {
t.Fatal("accepted invalid generated progress tone")
}
}
func TestBundledSoundPanDecodes(t *testing.T) {
data, err := load_sound("default", "button")
if err != nil {
t.Fatal(err)
}
for _, pan := range []float64{-1, 0, 0.35, 1} {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
err := (NativePlayer{Sink: "test"}).Play(ctx, data, 0.5, pan)
cancel()
if err != nil {
t.Fatalf("pan %v: %v", pan, err)
}
}
}
func TestThemePathsAndSizeAreBounded(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
for _, name := range []string{"..", "../other", "/tmp/other"} {
if _, err := load_sound(name, "start"); err == nil {
t.Fatalf("accepted theme %q", name)
}
if _, err := load_sound("default", name); err == nil {
t.Fatalf("accepted cue %q", name)
}
}
path := filepath.Join(os.Getenv("XDG_DATA_HOME"), "cthulhu", "sounds", "custom")
if err := os.MkdirAll(path, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "start.wav"), []byte("custom"), 0o600); err != nil {
t.Fatal(err)
}
data, err := load_sound("custom", "start")
if err != nil || string(data) != "custom" {
t.Fatalf("custom theme = %q, %v", data, err)
}
if err := os.WriteFile(filepath.Join(path, "start.wav"), make([]byte, maxSoundBytes+1), 0o600); err != nil {
t.Fatal(err)
}
if _, err := load_sound("custom", "start"); err == nil {
t.Fatal("accepted oversized sound")
}
}
type blockingPlayer struct {
started chan struct{}
calls int
}
func TestQueueReplacementDoesNotBlockConcurrentConsumer(t *testing.T) {
service := New(slog.New(slog.DiscardHandler), nil, "default", 0.5)
service.running = true
service.enabled = true
service.available = map[string]bool{"button": true}
ctx, cancel := context.WithCancel(context.Background())
consumerDone := make(chan struct{})
go func() {
defer close(consumerDone)
for {
if ctx.Err() != nil {
return
}
select {
case <-ctx.Done():
return
case <-service.queue:
// The runtime receives before taking the playback mutex.
service.mutex.Lock()
service.mutex.Unlock()
}
}
}()
done := make(chan struct{})
go func() {
defer close(done)
for range 100000 {
if ctx.Err() != nil {
return
}
service.Enqueue(Cue{Name: "button"})
}
}()
select {
case <-done:
cancel()
<-consumerDone
case <-time.After(5 * time.Second):
cancel()
// Release a blocked drain so both test goroutines can be joined.
service.queue <- queuedCue{}
<-done
<-consumerDone
t.Fatal("queue replacement blocked while the consumer waited for the mutex")
}
}
type interruptPlayer struct {
started chan struct{}
canceled chan struct{}
played chan []byte
once sync.Once
}
func (player *interruptPlayer) Play(ctx context.Context, data []byte, _, _ float64) error {
first := false
player.once.Do(func() { first = true; close(player.started) })
if first {
<-ctx.Done()
close(player.canceled)
return ctx.Err()
}
player.played <- data
return nil
}
func TestNewPresentationInterruptsOldCueAndDiscardsPendingCues(t *testing.T) {
player := &interruptPlayer{started: make(chan struct{}), canceled: make(chan struct{}), played: make(chan []byte, 2)}
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
if !service.Enqueue(Cue{Name: "button"}) {
t.Fatal("initial cue rejected")
}
select {
case <-player.started:
case <-time.After(time.Second):
t.Fatal("initial cue did not start")
}
for range 20 {
if !service.Enqueue(Cue{Name: "link"}) {
t.Fatal("new link cue rejected")
}
}
select {
case <-player.canceled:
case <-time.After(time.Second):
t.Fatal("initial cue was not interrupted")
}
want, err := load_sound("default", "link")
if err != nil {
t.Fatal(err)
}
select {
case got := <-player.played:
if !bytes.Equal(got, want) {
t.Fatal("played stale cue instead of latest link")
}
case <-time.After(time.Second):
t.Fatal("latest link cue did not play")
}
if len(service.queue) != 0 {
t.Fatal("stale link cues remained queued")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := service.Stop(ctx); err != nil {
t.Fatal(err)
}
}
type configuredPlayback struct {
volume float64
pan float64
}
type configurationPlayer struct {
played chan configuredPlayback
}
type reconfigurePlayer struct {
started chan struct{}
release chan struct{}
canceled chan struct{}
played chan struct{}
once sync.Once
}
func (player *reconfigurePlayer) Play(ctx context.Context, _ []byte, _, _ float64) error {
first := false
player.once.Do(func() {
first = true
close(player.started)
})
if first {
select {
case <-player.release:
case <-ctx.Done():
close(player.canceled)
return ctx.Err()
}
return nil
}
player.played <- struct{}{}
return nil
}
func (player *configurationPlayer) Play(_ context.Context, _ []byte, volume, pan float64) error {
player.played <- configuredPlayback{volume: volume, pan: pan}
return nil
}
func TestCuePanIsValidatedAndPassedToPlayer(t *testing.T) {
player := &configurationPlayer{played: make(chan configuredPlayback, 2)}
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
service.Enqueue(Cue{Name: "button", Pan: 0.5})
select {
case playback := <-player.played:
if playback.pan != 0.5 {
t.Fatalf("pan = %v, want 0.5", playback.pan)
}
case <-time.After(time.Second):
t.Fatal("panned cue was not played")
}
service.Enqueue(Cue{Name: "button", Pan: 2})
select {
case playback := <-player.played:
if playback.pan != 0 {
t.Fatalf("invalid pan = %v, want centered", playback.pan)
}
case <-time.After(time.Second):
t.Fatal("invalid-pan cue was not played")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := service.Stop(ctx); err != nil {
t.Fatal(err)
}
}
func TestCustomThemeCueSupportsPan(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
path := filepath.Join(dataHome, "cthulhu", "sounds", "custom")
if err := os.MkdirAll(path, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "button.wav"), []byte("custom wav"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "stop.wav"), []byte("custom stop"), 0o600); err != nil {
t.Fatal(err)
}
player := &configurationPlayer{played: make(chan configuredPlayback, 1)}
service := New(slog.New(slog.DiscardHandler), player, "custom", 0.5)
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
if !service.Available("button") {
t.Fatal("custom theme cue was not available")
}
service.Enqueue(Cue{Name: "button", Pan: -1})
select {
case playback := <-player.played:
if playback.pan != -1 {
t.Fatalf("custom cue pan = %v, want -1", playback.pan)
}
case <-time.After(time.Second):
t.Fatal("custom cue was not played")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := service.Stop(ctx); err != nil {
t.Fatal(err)
}
}
func TestConfigureAppliesEnableAndVolumeToSubsequentCues(t *testing.T) {
player := &configurationPlayer{played: make(chan configuredPlayback, 2)}
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
service.Configure(Configuration{Enabled: false, Sink: "auto", Theme: "default", Volume: 0.5})
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
service.Enqueue(Cue{Name: "button"})
if len(service.queue) != 0 {
t.Fatal("disabled service queued a cue")
}
service.Configure(Configuration{Enabled: true, Sink: "pulse", Theme: "default", Volume: 0.8})
service.Enqueue(Cue{Name: "button"})
select {
case playback := <-player.played:
if playback.volume != 0.8 {
t.Fatalf("volume = %v, want 0.8", playback.volume)
}
case <-time.After(time.Second):
t.Fatal("configured cue was not played")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := service.Stop(ctx); err != nil {
t.Fatal(err)
}
}
func TestConfigureCancelsActiveOldPolicyCue(t *testing.T) {
player := &reconfigurePlayer{started: make(chan struct{}), release: make(chan struct{}), canceled: make(chan struct{}), played: make(chan struct{}, 1)}
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
if !service.Enqueue(Cue{Name: "button"}) {
t.Fatal("first cue was not admitted")
}
select {
case <-player.started:
case <-time.After(time.Second):
t.Fatal("first cue did not start")
}
service.Configure(Configuration{Enabled: false, Theme: "default", Volume: 0.5})
select {
case <-player.canceled:
case <-time.After(time.Second):
t.Fatal("active old-policy cue was not canceled")
}
select {
case <-player.played:
t.Fatal("old-policy pending cue played after reconfiguration")
case <-time.After(30 * time.Millisecond):
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := service.Stop(ctx); err != nil {
t.Fatal(err)
}
}
func TestCueBatchAdmissionIsAtomic(t *testing.T) {
player := &blockingPlayer{started: make(chan struct{})}
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
if !service.Enqueue(Cue{Name: "start"}) {
t.Fatal("blocking cue was not admitted")
}
select {
case <-player.started:
case <-time.After(time.Second):
t.Fatal("sound worker did not start")
}
batch := make([]Cue, queueCapacity+1)
for index := range batch {
batch[index] = Cue{Name: "button"}
}
if service.EnqueueBatch(batch) {
t.Fatal("oversized presentation batch was admitted")
}
if len(service.queue) != 0 {
t.Fatal("part of rejected presentation batch was queued")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := service.Stop(ctx); err != nil {
t.Fatal(err)
}
}
func TestPartialCustomThemeStopsWithoutShutdownCue(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
path := filepath.Join(dataHome, "cthulhu", "sounds", "partial")
if err := os.MkdirAll(path, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "button.wav"), []byte("button"), 0o600); err != nil {
t.Fatal(err)
}
service := New(slog.New(slog.DiscardHandler), &configurationPlayer{played: make(chan configuredPlayback, 1)}, "partial", 0.5)
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := service.Stop(ctx); err != nil {
t.Fatal(err)
}
}
func (player *blockingPlayer) Play(ctx context.Context, _ []byte, _, _ float64) error {
player.calls++
if player.calls == 1 {
close(player.started)
<-ctx.Done()
return ctx.Err()
}
return nil
}
func TestSoundWorkerBoundsQueueAndStopsActiveCue(t *testing.T) {
player := &blockingPlayer{started: make(chan struct{})}
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
service.Enqueue(Cue{Name: "start"})
select {
case <-player.started:
case <-time.After(time.Second):
t.Fatal("sound worker did not start")
}
batch := make([]Cue, queueCapacity+1)
for index := range batch {
batch[index] = Cue{Name: "button"}
}
service.EnqueueBatch(batch)
if service.dropped.Load() == 0 {
t.Fatal("overflow not counted")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := service.Stop(ctx); err != nil {
t.Fatal(err)
}
if player.calls != 2 {
t.Fatalf("played %d cues; expected active cue then stop", player.calls)
}
if err := service.Stop(ctx); err != nil {
t.Fatal(err)
}
if player.calls != 2 {
t.Fatal("repeated stop played another cue")
}
}
func TestStopPlaysShutdownCueAfterOversizedPresentation(t *testing.T) {
stopData, err := load_sound("default", "stop")
if err != nil {
t.Fatal(err)
}
player := &queueBlockingPlayer{stopData: stopData, entered: make(chan struct{})}
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
service.Enqueue(Cue{Name: "button"})
select {
case <-player.entered:
case <-time.After(time.Second):
t.Fatal("sound worker did not start")
}
batch := make([]Cue, queueCapacity+1)
for index := range batch {
batch[index] = Cue{Name: "button"}
}
service.EnqueueBatch(batch)
if service.dropped.Load() == 0 {
t.Fatal("oversized presentation was not dropped")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := service.Stop(ctx); err != nil {
t.Fatal(err)
}
if player.stopCalls != 1 {
t.Fatalf("played %d stop cues; expected 1", player.stopCalls)
}
if err := service.Stop(ctx); err != nil {
t.Fatal(err)
}
if player.stopCalls != 1 {
t.Fatalf("repeated stop played %d stop cues; expected no more", player.stopCalls)
}
}
func TestStopTimeoutPreservesShutdownCueForRetry(t *testing.T) {
stopData, err := load_sound("default", "stop")
if err != nil {
t.Fatal(err)
}
player := &stagedStopPlayer{stopData: stopData, entered: make(chan struct{}), release: make(chan struct{})}
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
service.Enqueue(Cue{Name: "button"})
select {
case <-player.entered:
case <-time.After(time.Second):
t.Fatal("sound worker did not start")
}
timeoutContext, cancelTimeout := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancelTimeout()
if err := service.Stop(timeoutContext); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("timed-out Stop error = %v; want deadline exceeded", err)
}
close(player.release)
retryContext, cancelRetry := context.WithTimeout(context.Background(), time.Second)
defer cancelRetry()
if err := service.Stop(retryContext); err != nil {
t.Fatal(err)
}
if player.stopCalls != 1 {
t.Fatalf("played %d stop cues; expected 1 after retry", player.stopCalls)
}
}
type stopCountingPlayer struct {
stopData []byte
stopCalls int
}
type queueBlockingPlayer struct {
stopData []byte
entered chan struct{}
enterOnce sync.Once
stopCalls int
}
func (player *queueBlockingPlayer) Play(ctx context.Context, data []byte, _, _ float64) error {
if bytes.Equal(data, player.stopData) {
player.stopCalls++
return nil
}
player.enterOnce.Do(func() { close(player.entered) })
<-ctx.Done()
return ctx.Err()
}
func (player *stopCountingPlayer) Play(_ context.Context, data []byte, _, _ float64) error {
if bytes.Equal(data, player.stopData) {
player.stopCalls++
}
return nil
}
type stagedStopPlayer struct {
stopData []byte
entered chan struct{}
release chan struct{}
stopCalls int
}
func (player *stagedStopPlayer) Play(_ context.Context, data []byte, _, _ float64) error {
if bytes.Equal(data, player.stopData) {
player.stopCalls++
return nil
}
close(player.entered)
<-player.release
return nil
}
func TestCancelledNativeSoundDoesNotStart(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := (NativePlayer{Sink: "test"}).Play(ctx, []byte("invalid"), 0.5, 0); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
}
func TestLiveSound(t *testing.T) {
if os.Getenv("CTHULHU_LIVE_SOUND") != "1" {
t.Skip("explicit live audio test")
}
for _, cue := range []string{"start", "stop"} {
data, err := load_sound("default", cue)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
err = (NativePlayer{Sink: "auto"}).Play(ctx, data, 0.5, 0)
cancel()
if err != nil {
t.Fatal(err)
}
}
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"context"
"time"
)
var speechBeepWAV = tone_wav(440, 40*time.Millisecond, 0.35, 5*time.Millisecond)
// SpeechBeep replaces an utterance, independently of role-sound enablement or
// theme availability. The speech worker owns its ordering and cancellation;
// sound preferences still supply the output sink and volume.
func (service *Service) SpeechBeep(ctx context.Context) error {
service.mutex.Lock()
player, volume := service.player, service.volume
service.mutex.Unlock()
beepContext, cancelBeep := context.WithTimeout(ctx, 3*time.Second)
defer cancelBeep()
return player.Play(beepContext, speechBeepWAV, volume, 0)
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"context"
"errors"
"log/slog"
"testing"
"time"
)
func TestSpeechBeepDecodesWithRoleSoundsDisabled(t *testing.T) {
service := New(slog.New(slog.DiscardHandler), NativePlayer{Sink: "auto"}, "default", 0.5)
service.Configure(Configuration{Enabled: false, Theme: "none", Sink: "test", Volume: 0.4})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := service.SpeechBeep(ctx); err != nil {
t.Fatal(err)
}
canceled, cancelBeep := context.WithCancel(ctx)
cancelBeep()
if err := service.SpeechBeep(canceled); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled beep = %v", err)
}
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"bytes"
"encoding/binary"
"math"
"time"
)
// tone_wav generates mono, 16-bit PCM at 22050 Hz for the existing Player.
// Callers supply a positive frequency/duration, amplitude in [0, 1], and a
// nonnegative edge ramp. A zero ramp preserves an unsmoothed sine tone.
// Playback volume, pan, queuing, and cancellation belong to the sound service.
func tone_wav(frequency float64, duration time.Duration, amplitude float64, ramp time.Duration) []byte {
const sampleRate = 22050
sampleCount := int(float64(sampleRate) * duration.Seconds())
rampSamples := int(float64(sampleRate) * ramp.Seconds())
dataSize := sampleCount * 2
buffer := bytes.NewBuffer(make([]byte, 0, 44+dataSize))
buffer.WriteString("RIFF")
_ = binary.Write(buffer, binary.LittleEndian, uint32(36+dataSize))
buffer.WriteString("WAVEfmt ")
_ = binary.Write(buffer, binary.LittleEndian, uint32(16))
_ = binary.Write(buffer, binary.LittleEndian, uint16(1))
_ = binary.Write(buffer, binary.LittleEndian, uint16(1))
_ = binary.Write(buffer, binary.LittleEndian, uint32(sampleRate))
_ = binary.Write(buffer, binary.LittleEndian, uint32(sampleRate*2))
_ = binary.Write(buffer, binary.LittleEndian, uint16(2))
_ = binary.Write(buffer, binary.LittleEndian, uint16(16))
buffer.WriteString("data")
_ = binary.Write(buffer, binary.LittleEndian, uint32(dataSize))
for sample := 0; sample < sampleCount; sample++ {
envelope := 1.0
if rampSamples > 0 {
envelope = min(1.0, float64(min(sample, sampleCount-1-sample))/float64(rampSamples))
}
value := int16(math.Sin(2*math.Pi*frequency*float64(sample)/sampleRate) * amplitude * envelope * math.MaxInt16)
_ = binary.Write(buffer, binary.LittleEndian, value)
}
return buffer.Bytes()
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"crypto/sha256"
"fmt"
"testing"
)
func TestTonePreservesSpeechAudio(t *testing.T) {
// Preserve the accepted speech beep while progress audio follows NVDA.
const speechDigest = "2f0184d3deefce36901b9a940d5caefde08e15058898d20c398ad10ad1ce1b94"
if got := fmt.Sprintf("%x", sha256.Sum256(speechBeepWAV)); got != speechDigest {
t.Fatalf("speech beep audio changed: %s", got)
}
}