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:
1 parent
03471f9dff
commit
e53a8d0b50
7 files changed
+404
-1
No files matched your search
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user