From e53a8d0b503286778e868230fe6fc969fc725770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 17 Oct 2026 01:24:32 -0400 Subject: [PATCH] Add sound: fifteen synthesized cues, mute, volume, recent log Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitattributes | 1 + AGENTS.md | 1 + go.mod | 4 +- go.sum | 2 + sound/sound.go | 147 ++++++++++++++++++++++++++++++++++++++ sound/sound_test.go | 169 ++++++++++++++++++++++++++++++++++++++++++++ sound/synth.go | 81 +++++++++++++++++++++ 7 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 sound/sound.go create mode 100644 sound/sound_test.go create mode 100644 sound/synth.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/AGENTS.md b/AGENTS.md index 327911f..3961ed5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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= npx playwright test` checks a deployed site. Tests read `window.__crychic`. diff --git a/go.mod b/go.mod index 4c793c7..c6dfb09 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 34c01be..bd8cbd7 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/sound/sound.go b/sound/sound.go new file mode 100644 index 0000000..3d1e977 --- /dev/null +++ b/sound/sound.go @@ -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...) +} diff --git a/sound/sound_test.go b/sound/sound_test.go new file mode 100644 index 0000000..d6b4b60 --- /dev/null +++ b/sound/sound_test.go @@ -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) + } + } +} diff --git a/sound/synth.go b/sound/synth.go new file mode 100644 index 0000000..cc78587 --- /dev/null +++ b/sound/synth.go @@ -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) +}