Add sound: fifteen synthesized cues, mute, volume, recent log

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-17 01:24:32 -04:00
1 parent 03471f9dff
commit e53a8d0b50
7 files changed
+404 -1

No files matched your search

+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+1
View File
@@ -74,6 +74,7 @@ every event through `log`. The code panel shows the demo function's own source.
- `go vet ./... && go tool mygo vet ./... && go test ./...` and `GOOS=js GOARCH=wasm go vet ./...` before done.
- Behavior tests drive `ui.NewTester` and assert values; interactive tests assert the cue in `sound.Recent()`.
Test binaries start muted, so tests make no sound.
Advance the tester's clock; never sleep.
- Web: `npm install && npm test` in `web/` builds `web/dist` and runs Playwright on a local server;
`BASE_URL=<url> npx playwright test` checks a deployed site. Tests read `window.__crychic`.
+3 -1
View File
@@ -2,8 +2,10 @@ module git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI
go 1.27.1
require github.com/egoist/mygo v0.5.5
require (
github.com/egoist/mygo v0.5.5 // indirect
github.com/ebitengine/purego v0.11.1 // indirect
golang.org/x/text v0.43.0 // indirect
)
+2
View File
@@ -1,3 +1,5 @@
github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/egoist/mygo v0.5.5 h1:3pvlEwumsck/L99cbDpay/Eqcf51B7zhKNXekiodLOw=
github.com/egoist/mygo v0.5.5/go.mod h1:daHGetyAlBkHhIZOOIcAI7C82yLUY7HgeT2+Z6w2VPU=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
+147
View File
@@ -0,0 +1,147 @@
// Package sound plays the cues components make.
package sound
import (
"fmt"
"log/slog"
"sync"
"testing"
"time"
"github.com/egoist/mygo/audio"
)
// Cue names one sound.
type Cue uint8
const (
Tap Cue = iota
Toggle
Select
Tick
Open
Close
Pop
Drop
Success
Warning
Error
Notify
Send
Delete
Swipe
cueCount
)
var names = [cueCount]string{"tap", "toggle", "select", "tick", "open", "close", "pop", "drop", "success",
"warning", "error", "notify", "send", "delete", "swipe"}
func (c Cue) String() string {
if c >= cueCount {
return fmt.Sprintf("cue(%d)", uint8(c))
}
return names[c]
}
// Cues lists every cue in order.
func Cues() []Cue {
out := make([]Cue, cueCount)
for i := range out {
out[i] = Cue(i)
}
return out
}
const recentCap = 32
var (
mu sync.Mutex
// Test binaries start muted; tests read Recent.
enabled = !testing.Testing()
volume = 0.5
recent []Cue
sounds [cueCount]*audio.Sound
watching sync.Once
)
// Play records the cue and plays it when enabled.
func Play(c Cue) {
if c >= cueCount {
panic("sound: unknown " + c.String())
}
mu.Lock()
recent = append(recent, c)
if len(recent) > recentCap {
recent = recent[len(recent)-recentCap:]
}
on, v := enabled, volume
s := sounds[c]
if on && s == nil {
s = audio.NewSound(rate, 1, synth(c))
sounds[c] = s
}
mu.Unlock()
slog.Debug("crychic: sound", "cue", c.String(), "played", on)
if !on {
return
}
s.NewPlayer().SetVolume(v).Play()
watching.Do(func() { go watch() })
}
// watch logs each change of the output's error.
func watch() {
var last error
for range time.Tick(time.Second) {
last = noteErr(last, audio.Err())
}
}
func noteErr(last, err error) error {
switch {
case err != nil && (last == nil || err.Error() != last.Error()):
slog.Warn("crychic: sound output unavailable", "err", err)
case err == nil && last != nil:
slog.Info("crychic: sound output back")
}
return err
}
// SetEnabled turns every cue on or off.
func SetEnabled(on bool) {
mu.Lock()
enabled = on
mu.Unlock()
slog.Debug("crychic: sound enabled", "on", on)
}
// Enabled reports whether cues play.
func Enabled() bool {
mu.Lock()
defer mu.Unlock()
return enabled
}
// SetVolume sets the gain of cues, from 0 to 1.
func SetVolume(v float64) {
if !(v >= 0 && v <= 1) {
panic(fmt.Sprintf("sound: volume %v outside 0–1", v))
}
mu.Lock()
volume = v
mu.Unlock()
}
// Volume returns the gain of cues.
func Volume() float64 {
mu.Lock()
defer mu.Unlock()
return volume
}
// Recent returns the last cues played or muted, oldest first.
func Recent() []Cue {
mu.Lock()
defer mu.Unlock()
return append([]Cue(nil), recent...)
}
+169
View File
@@ -0,0 +1,169 @@
package sound
import (
"bytes"
"errors"
"log/slog"
"math"
"slices"
"strings"
"testing"
)
func resetRecent(t *testing.T) {
mu.Lock()
recent = nil
mu.Unlock()
t.Cleanup(func() {
mu.Lock()
recent = nil
mu.Unlock()
})
}
func TestTestBinaryStartsMuted(t *testing.T) {
if Enabled() {
t.Fatal("sound enabled in a test binary")
}
}
func TestCueNames(t *testing.T) {
seen := map[string]bool{}
for _, c := range Cues() {
s := c.String()
if seen[s] {
t.Errorf("duplicate name %q", s)
}
seen[s] = true
}
if len(seen) != 15 {
t.Errorf("cues = %d, want 15", len(seen))
}
if got := Cue(99).String(); got != "cue(99)" {
t.Errorf("unknown cue name = %q", got)
}
}
func TestSynthBounds(t *testing.T) {
for _, c := range Cues() {
s := synth(c)
if d := float64(len(s)) / rate; d < 0.005 || d > 0.5 {
t.Errorf("%v lasts %.3f s", c, d)
}
peak := 0.0
for _, v := range s {
peak = max(peak, math.Abs(float64(v)))
}
if peak < 0.05 || peak > 1 {
t.Errorf("%v peak %.3f", c, peak)
}
if a, z := math.Abs(float64(s[0])), math.Abs(float64(s[len(s)-1])); a > 0.01 || z > 0.01 {
t.Errorf("%v clicks: first %.3f last %.3f", c, a, z)
}
}
}
func TestCuesDiffer(t *testing.T) {
cues := Cues()
for i, a := range cues {
for _, b := range cues[i+1:] {
if slices.Equal(synth(a), synth(b)) {
t.Errorf("%v and %v sound the same", a, b)
}
}
}
}
func TestRecentKeepsLast32(t *testing.T) {
resetRecent(t)
var played []Cue
for i := range 40 {
c := Cue(i % int(cueCount))
Play(c)
played = append(played, c)
}
if got, want := Recent(), played[8:]; !slices.Equal(got, want) {
t.Errorf("recent = %v, want %v", got, want)
}
}
func TestRecentIsACopy(t *testing.T) {
resetRecent(t)
Play(Tap)
r := Recent()
r[0] = Error
if Recent()[0] != Tap {
t.Error("Recent exposed its buffer")
}
}
func TestSetEnabled(t *testing.T) {
t.Cleanup(func() { SetEnabled(false) })
SetEnabled(true)
if !Enabled() {
t.Error("SetEnabled(true) ignored")
}
SetEnabled(false)
if Enabled() {
t.Error("SetEnabled(false) ignored")
}
}
func TestSetVolume(t *testing.T) {
old := Volume()
t.Cleanup(func() { SetVolume(old) })
SetVolume(0.8)
if Volume() != 0.8 {
t.Errorf("volume = %v", Volume())
}
for _, v := range []float64{-0.1, 1.1, math.NaN()} {
func() {
defer func() {
if recover() == nil {
t.Errorf("SetVolume(%v) did not panic", v)
}
}()
SetVolume(v)
}()
}
}
func TestPlayPanicsOnUnknownCue(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("Play(cueCount) did not panic")
}
}()
Play(cueCount)
}
func TestNoteErrLogsChanges(t *testing.T) {
var buf bytes.Buffer
old := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(old) })
down, other := errors.New("no device"), errors.New("device lost")
steps := []struct {
err error
want string
}{
{nil, ""},
{down, "level=WARN msg=\"crychic: sound output unavailable\" err=\"no device\""},
{down, ""},
{other, "level=WARN msg=\"crychic: sound output unavailable\" err=\"device lost\""},
{nil, "level=INFO msg=\"crychic: sound output back\""},
{nil, ""},
}
var last error
for i, s := range steps {
buf.Reset()
last = noteErr(last, s.err)
got := strings.TrimSpace(buf.String())
if s.want == "" && got != "" || s.want != "" && !strings.Contains(got, s.want) {
t.Errorf("step %d logged %q, want %q", i, got, s.want)
}
if last != s.err {
t.Errorf("step %d kept %v, want %v", i, last, s.err)
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package sound
import "math"
const rate = 48000
type wave uint8
const (
sine wave = iota
triangle
)
// note is one tone sweeping from f0 to f1 Hz.
type note struct {
at, dur float64
f0, f1 float64
gain float64
wave wave
}
var scores = [cueCount][]note{
Tap: {{0, 0.025, 1800, 1200, 0.5, sine}},
Toggle: {{0, 0.03, 880, 880, 0.4, sine}, {0.035, 0.03, 1320, 1320, 0.4, sine}},
Select: {{0, 0.05, 1046, 1046, 0.4, sine}},
Tick: {{0, 0.008, 3000, 3000, 0.3, sine}},
Open: {{0, 0.09, 500, 900, 0.4, sine}},
Close: {{0, 0.09, 900, 500, 0.4, sine}},
Pop: {{0, 0.04, 400, 1200, 0.5, sine}},
Drop: {{0, 0.08, 700, 200, 0.5, sine}},
Success: {{0, 0.12, 523, 523, 0.3, sine}, {0.07, 0.12, 659, 659, 0.3, sine}, {0.14, 0.18, 784, 784, 0.3, sine}},
Warning: {{0, 0.09, 660, 660, 0.35, triangle}, {0.13, 0.09, 660, 660, 0.35, triangle}},
Error: {{0, 0.08, 220, 180, 0.4, triangle}, {0.1, 0.08, 220, 180, 0.4, triangle}},
Notify: {{0, 0.25, 784, 784, 0.3, sine}, {0.1, 0.3, 1046, 1046, 0.3, sine}},
Send: {{0, 0.12, 300, 1500, 0.35, triangle}},
Delete: {{0, 0.14, 600, 150, 0.4, triangle}},
Swipe: {{0, 0.07, 1200, 400, 0.25, sine}},
}
// synth renders a cue's notes to mono samples.
func synth(c Cue) []float32 {
score := scores[c]
if len(score) == 0 {
panic("sound: no score for " + c.String())
}
end := 0.0
for _, n := range score {
end = max(end, n.at+n.dur)
}
out := make([]float32, int(end*rate)+1)
for _, n := range score {
start, frames := int(n.at*rate), int(n.dur*rate)
phase := 0.0
for i := range frames {
t := float64(i) / rate
f := n.f0 + (n.f1-n.f0)*t/n.dur
phase += 2 * math.Pi * f / rate
out[start+i] += float32(n.gain * envelope(t, n.dur) * shape(n.wave, phase))
}
}
return out
}
// envelope: 3 ms attack, decay, 2 ms fade.
func envelope(t, dur float64) float64 {
e := math.Exp(-5 * t / dur)
if t < 0.003 {
e *= t / 0.003
}
if left := dur - t; left < 0.002 {
e *= left / 0.002
}
return e
}
func shape(w wave, phase float64) float64 {
if w == triangle {
return 2 / math.Pi * math.Asin(math.Sin(phase))
}
return math.Sin(phase)
}