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,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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user