Notification sound added. Docs updated.

This commit is contained in:
Storm Dragon committed 2026-11-07 00:27:44 -05:00
1 parent 4c9a267897
commit 81cc630319
29 files changed
+845 -57

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package speech
import (
"context"
"reflect"
"testing"
"time"
)
func TestSpeechPreludeFinishesBeforeSpeechAndPreservesFIFO(t *testing.T) {
fake := new_recording_transport()
service := New(fake)
defer service.Close()
release := make(chan struct{})
service.Say_after("notification", func(ctx context.Context) {
fake.record("sound:start")
select {
case <-release:
case <-ctx.Done():
}
fake.record("sound:drained")
})
service.Say("following text")
wait_event(t, fake.changed, "sound:start")
fake.mu.Lock()
events := append([]string(nil), fake.events...)
fake.mu.Unlock()
if !reflect.DeepEqual(events, []string{"sound:start"}) {
t.Fatal(events)
}
close(release)
wait_event(t, fake.changed, "sent:following text")
fake.mu.Lock()
defer fake.mu.Unlock()
want := []string{"sound:start", "sound:drained", "say:notification", "sent:notification", "say:following text", "sent:following text"}
if !reflect.DeepEqual(fake.events, want) {
t.Fatal(fake.events)
}
}
func TestSpeechPreludeInterruptMuteAndCloseDiscardDelayedSpeech(t *testing.T) {
for _, action := range []string{"interrupt", "mute", "close"} {
t.Run(action, func(t *testing.T) {
fake := new_recording_transport()
service := New(fake)
defer service.Close()
entered, stopped := make(chan struct{}), make(chan struct{})
service.Say_after("discarded notification", func(ctx context.Context) {
close(entered)
<-ctx.Done()
close(stopped)
})
<-entered
switch action {
case "interrupt":
service.Interrupt()
case "mute":
service.Toggle_mute()
case "close":
service.Close()
}
select {
case <-stopped:
case <-time.After(time.Second):
t.Fatal("sound wait was not canceled")
}
if action == "mute" {
service.Toggle_mute()
}
if action != "close" {
service.Say("fresh review")
wait_event(t, fake.changed, "sent:fresh review")
}
fake.mu.Lock()
defer fake.mu.Unlock()
for _, event := range fake.events {
if event == "say:discarded notification" {
t.Fatal("late notification speech escaped cancellation")
}
}
})
}
}
+1
View File
@@ -72,6 +72,7 @@ func TestRecoveryKeepsAdmissionResponsiveAndDropsOutageSpeech(t *testing.T) {
service.Toggle_mute()
service.Say_with_pitch("fresh", 60)
wait_event(t, fresh.changed, "sent:fresh")
wait_event(t, fresh.changed, "pitch:10")
fresh.mu.Lock()
joined := strings.Join(fresh.events, "|")
fresh.mu.Unlock()
+30
View File
@@ -55,6 +55,7 @@ type Service struct {
parameters *Parameters
generation, canceled uint64
muted, closed bool
beforeCancel context.CancelFunc
wake, cancelWake chan struct{}
done chan struct{}
errors chan error
@@ -90,6 +91,7 @@ func (s *Service) Replace(transport Transport) error {
type utterance struct {
text string
before func(context.Context)
queuedAt time.Time
pitch *int
parameters *Parameters
@@ -132,6 +134,12 @@ func (s *Service) Say(text string) {
s.enqueue(utterance{text: text})
}
// Say_after keeps a cancellable sound prelude and its speech in one FIFO
// entry. Neither the prelude nor later speech runs on the input dispatcher.
func (s *Service) Say_after(text string, before func(context.Context)) {
s.enqueue(utterance{text: text, before: before})
}
// Say_with_parameters previews one utterance without changing saved or live
// defaults. The serialized worker restores its latest normal parameters.
func (s *Service) Say_with_parameters(text string, parameters Parameters) {
@@ -176,6 +184,9 @@ func (s *Service) Interrupt() {
}
func (s *Service) interrupt() {
if s.beforeCancel != nil {
s.beforeCancel()
}
clear(s.queue)
s.queue = nil
s.generation++
@@ -342,10 +353,29 @@ func (s *Service) deliver() {
// must account for this send even if it hasn't reached the socket.
s.dispatch.Lock()
generation := s.generation
ctx := s.recoveryContext
var cancel context.CancelFunc
if item.before != nil {
ctx, cancel = context.WithCancel(ctx)
}
s.beforeCancel = cancel
s.mu.Unlock()
diagnostic.Timing("speech queue wait", item.queuedAt, "generation", generation)
if item.before != nil {
item.before(ctx)
}
if ctx.Err() != nil {
if cancel != nil {
cancel()
}
s.dispatch.Unlock()
continue
}
started := time.Now()
err := s.say(item, normalPitch, currentParameters)
if cancel != nil {
cancel()
}
diagnostic.Timing("speech send", started, "generation", generation)
s.dispatch.Unlock()
if err != nil {