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.
|
- `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()`.
|
- 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.
|
Advance the tester's clock; never sleep.
|
||||||
- Web: `npm install && npm test` in `web/` builds `web/dist` and runs Playwright on a local server;
|
- 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`.
|
`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
|
go 1.27.1
|
||||||
|
|
||||||
|
require github.com/egoist/mygo v0.5.5
|
||||||
|
|
||||||
require (
|
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
|
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 h1:3pvlEwumsck/L99cbDpay/Eqcf51B7zhKNXekiodLOw=
|
||||||
github.com/egoist/mygo v0.5.5/go.mod h1:daHGetyAlBkHhIZOOIcAI7C82yLUY7HgeT2+Z6w2VPU=
|
github.com/egoist/mygo v0.5.5/go.mod h1:daHGetyAlBkHhIZOOIcAI7C82yLUY7HgeT2+Z6w2VPU=
|
||||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
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