Notification sound added. Docs updated.
This commit is contained in:
1 parent
4c9a267897
commit
81cc630319
29 files changed
+845
-57
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user