sound
ci / test (push) Successful in 3m6s
ci / release (push) Failing after 1m5s

This commit is contained in:
2026-07-03 06:47:07 -04:00
parent 38b2abd71b
commit d2c574c614
12 changed files with 517 additions and 11 deletions
+5 -2
View File
@@ -18,8 +18,11 @@ jobs:
run: bash scripts/ci-setup-go.sh
env:
GO_VERSION: ${{ env.GO_VERSION }}
- run: go test ./...
- run: go build -o bin/hp-viz ./cmd/hp-viz/
- name: Install audio deps
run: sudo apt-get update && sudo apt-get install -y libasound2-dev pkg-config
- run: CGO_ENABLED=1 go test ./...
- run: CGO_ENABLED=0 go test -tags nosound ./...
- run: CGO_ENABLED=0 go build -o bin/hp-viz ./cmd/hp-viz/
release:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+2
View File
@@ -19,6 +19,8 @@ require (
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/ebitengine/oto/v3 v3.4.0 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+4
View File
@@ -22,6 +22,10 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ=
github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI=
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+157
View File
@@ -0,0 +1,157 @@
package sound
import (
"bytes"
"encoding/binary"
"os"
"strings"
"sync"
"time"
)
const (
defaultVolume = 0.3
maxAttackSoundsPerSec = 4
)
// Engine plays HoneyPeer viz event sounds (ported from web useVizSound.ts).
type Engine struct {
mu sync.Mutex
enabled bool
volume float64
reduced bool
attackMu sync.Mutex
attackTimes []time.Time
}
func New() *Engine {
return &Engine{
volume: defaultVolume,
reduced: envTruthy("HP_VIZ_REDUCED_MOTION"),
}
}
func (e *Engine) Enabled() bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.enabled
}
func (e *Engine) SetEnabled(v bool) {
e.mu.Lock()
defer e.mu.Unlock()
if v && e.reduced {
return
}
e.enabled = v
}
func (e *Engine) Toggle() bool {
e.mu.Lock()
defer e.mu.Unlock()
if !e.enabled && e.reduced {
return false
}
e.enabled = !e.enabled
return e.enabled
}
func (e *Engine) Close() {
closePlayback()
}
func (e *Engine) PlayAttack() {
if !e.Enabled() {
return
}
scale := e.attackVolScale()
if scale <= 0 {
return
}
vol := e.volume * scale
e.output(renderSweep(900, 150, 300*time.Millisecond, waveSawtooth, vol))
e.output(renderNoise(100*time.Millisecond, vol))
}
func (e *Engine) PlayBlock() {
if !e.Enabled() {
return
}
e.output(renderTone(70, 350*time.Millisecond, waveSine, e.volume))
}
func (e *Engine) PlayModeration() {
if !e.Enabled() {
return
}
vol := e.volume
e.output(renderTone(550, 100*time.Millisecond, waveSine, vol))
time.AfterFunc(90*time.Millisecond, func() {
if !e.Enabled() {
return
}
e.output(renderTone(880, 120*time.Millisecond, waveSine, vol))
})
}
func (e *Engine) PlayPeerOnline() {
if !e.Enabled() {
return
}
e.output(renderSweep(350, 750, 180*time.Millisecond, waveSine, e.volume))
}
func (e *Engine) PlayPeerOffline() {
if !e.Enabled() {
return
}
e.output(renderSweep(600, 200, 250*time.Millisecond, waveSine, e.volume))
}
func (e *Engine) attackVolScale() float64 {
e.attackMu.Lock()
defer e.attackMu.Unlock()
now := time.Now()
cutoff := now.Add(-time.Second)
recent := e.attackTimes[:0]
for _, t := range e.attackTimes {
if t.After(cutoff) {
recent = append(recent, t)
}
}
if len(recent) >= maxAttackSoundsPerSec {
e.attackTimes = recent
return 0
}
e.attackTimes = append(recent, now)
if len(recent) >= 2 {
return 0.55
}
return 1
}
func (e *Engine) output(mono []int16) {
if len(mono) == 0 {
return
}
_ = playPCM(monoToStereoBytes(mono))
}
func monoToStereoBytes(mono []int16) []byte {
buf := make([]byte, len(mono)*4)
for i, s := range mono {
binary.LittleEndian.PutUint16(buf[i*4:], uint16(s))
binary.LittleEndian.PutUint16(buf[i*4+2:], uint16(s))
}
return buf
}
func envTruthy(key string) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
return v == "1" || v == "true" || v == "yes" || v == "on"
}
func pcmReader(pcm []byte) *bytes.Reader {
return bytes.NewReader(pcm)
}
+83
View File
@@ -0,0 +1,83 @@
//go:build cgo && !nosound
package sound
import (
"runtime"
"sync"
"time"
"github.com/ebitengine/oto/v3"
)
var (
audioOnce sync.Once
audioCtx *oto.Context
audioErr error
audioReady chan struct{}
playersMu sync.Mutex
players []*oto.Player
)
func initAudio() {
op := &oto.NewContextOptions{
SampleRate: sampleRate,
ChannelCount: 2,
Format: oto.FormatSignedInt16LE,
}
ctx, ready, err := oto.NewContext(op)
if err != nil {
audioErr = err
return
}
audioCtx = ctx
audioReady = ready
}
func playPCM(pcm []byte) error {
audioOnce.Do(initAudio)
if audioErr != nil {
return audioErr
}
if audioCtx == nil {
return audioErr
}
if audioReady != nil {
<-audioReady
audioReady = nil
}
p := audioCtx.NewPlayer(pcmReader(pcm))
p.Play()
playersMu.Lock()
players = append(players, p)
playersMu.Unlock()
go func(player *oto.Player) {
for player.IsPlaying() {
time.Sleep(10 * time.Millisecond)
}
playersMu.Lock()
for i, cur := range players {
if cur == player {
players = append(players[:i], players[i+1:]...)
break
}
}
playersMu.Unlock()
runtime.KeepAlive(player)
}(p)
return nil
}
func closePlayback() {
playersMu.Lock()
defer playersMu.Unlock()
for _, p := range players {
_ = p.Close()
}
players = nil
}
+61
View File
@@ -0,0 +1,61 @@
//go:build !cgo && !nosound
package sound
import (
"bytes"
"encoding/binary"
"os/exec"
"runtime"
)
// playPCM pipes raw stereo PCM to aplay (Linux) or afplay via a WAV wrapper (macOS).
func playPCM(pcm []byte) error {
if len(pcm) == 0 {
return nil
}
switch runtime.GOOS {
case "linux":
if path, err := exec.LookPath("aplay"); err == nil {
cmd := exec.Command(path, "-q", "-t", "raw", "-f", "S16_LE", "-r", "44100", "-c", "2")
cmd.Stdin = bytes.NewReader(pcm)
return cmd.Start()
}
if path, err := exec.LookPath("paplay"); err == nil {
cmd := exec.Command(path, "--raw", "--rate=44100", "--channels=2", "--format=s16le")
cmd.Stdin = bytes.NewReader(pcm)
return cmd.Start()
}
case "darwin":
if path, err := exec.LookPath("afplay"); err == nil {
wav := wrapWAV(pcm, sampleRate, 2)
cmd := exec.Command(path, "-")
cmd.Stdin = bytes.NewReader(wav)
return cmd.Start()
}
}
return nil
}
func closePlayback() {}
func wrapWAV(pcm []byte, rate, channels int) []byte {
dataLen := len(pcm)
buf := make([]byte, 44+dataLen)
copy(buf[0:4], "RIFF")
binary.LittleEndian.PutUint32(buf[4:8], uint32(36+dataLen))
copy(buf[8:12], "WAVE")
copy(buf[12:16], "fmt ")
binary.LittleEndian.PutUint32(buf[16:20], 16)
binary.LittleEndian.PutUint16(buf[20:22], 1)
binary.LittleEndian.PutUint16(buf[22:24], uint16(channels))
binary.LittleEndian.PutUint32(buf[24:28], uint32(rate))
byteRate := rate * channels * 2
binary.LittleEndian.PutUint32(buf[28:32], uint32(byteRate))
binary.LittleEndian.PutUint16(buf[32:34], uint16(channels*2))
binary.LittleEndian.PutUint16(buf[34:36], 16)
copy(buf[36:40], "data")
binary.LittleEndian.PutUint32(buf[40:44], uint32(dataLen))
copy(buf[44:], pcm)
return buf
}
+9
View File
@@ -0,0 +1,9 @@
//go:build nosound
package sound
func playPCM([]byte) error {
return nil
}
func closePlayback() {}
+98
View File
@@ -0,0 +1,98 @@
package sound
import (
"math"
"math/rand"
"time"
)
const sampleRate = 44100
type waveKind int
const (
waveSine waveKind = iota
waveSawtooth
)
func envelope(t, durSec, vol float64) float64 {
if vol <= 0 || durSec <= 0 {
return 0
}
return vol * math.Pow(0.001/vol, t/durSec)
}
func renderTone(freq float64, dur time.Duration, kind waveKind, vol float64) []int16 {
durSec := dur.Seconds()
n := int(float64(sampleRate) * durSec)
if n < 1 {
return nil
}
out := make([]int16, n)
var phase float64
for i := 0; i < n; i++ {
t := float64(i) / float64(sampleRate)
g := envelope(t, durSec, vol)
phase += 2 * math.Pi * freq / float64(sampleRate)
s := waveSample(phase, kind) * g
out[i] = clampInt16(s * 32767)
}
return out
}
func renderSweep(from, to float64, dur time.Duration, kind waveKind, vol float64) []int16 {
durSec := dur.Seconds()
n := int(float64(sampleRate) * durSec)
if n < 1 {
return nil
}
out := make([]int16, n)
var phase float64
for i := 0; i < n; i++ {
t := float64(i) / float64(sampleRate)
g := envelope(t, durSec, vol)
frac := t / durSec
freq := from * math.Pow(to/from, frac)
phase += 2 * math.Pi * freq / float64(sampleRate)
s := waveSample(phase, kind) * g
out[i] = clampInt16(s * 32767)
}
return out
}
func renderNoise(dur time.Duration, vol float64) []int16 {
durSec := dur.Seconds()
n := int(float64(sampleRate) * durSec)
if n < 1 {
return nil
}
out := make([]int16, n)
noiseVol := vol * 0.25
for i := 0; i < n; i++ {
t := float64(i) / float64(sampleRate)
g := envelope(t, durSec, noiseVol)
s := (rand.Float64()*2 - 1) * g
out[i] = clampInt16(s * 32767)
}
return out
}
func waveSample(phase float64, kind waveKind) float64 {
switch kind {
case waveSawtooth:
p := phase / (2 * math.Pi)
return 2 * (p - math.Floor(p+0.5))
default:
return math.Sin(phase)
}
}
func clampInt16(v float64) int16 {
if v > 32767 {
return 32767
}
if v < -32768 {
return -32768
}
return int16(v)
}
+59
View File
@@ -0,0 +1,59 @@
package sound
import (
"testing"
"time"
)
func TestAttackVolScale(t *testing.T) {
e := New()
e.SetEnabled(true)
if got := e.attackVolScale(); got != 1 {
t.Fatalf("first scale = %v, want 1", got)
}
if got := e.attackVolScale(); got != 1 {
t.Fatalf("second scale = %v, want 1", got)
}
if got := e.attackVolScale(); got != 0.55 {
t.Fatalf("third scale = %v, want 0.55", got)
}
for i := 0; i < 2; i++ {
e.attackVolScale()
}
if got := e.attackVolScale(); got != 0 {
t.Fatalf("rate limited scale = %v, want 0", got)
}
}
func TestRenderToneLength(t *testing.T) {
buf := renderTone(440, 100*time.Millisecond, waveSine, 0.3)
want := int(float64(sampleRate) * 0.1)
if len(buf) != want {
t.Fatalf("len = %d, want %d", len(buf), want)
}
}
func TestReducedMotionBlocksEnable(t *testing.T) {
t.Setenv("HP_VIZ_REDUCED_MOTION", "1")
e := New()
if e.Toggle() {
t.Fatal("expected toggle to stay disabled under reduced motion")
}
if e.Enabled() {
t.Fatal("expected engine disabled")
}
}
func TestToggle(t *testing.T) {
e := New()
if e.Enabled() {
t.Fatal("expected disabled by default")
}
if !e.Toggle() {
t.Fatal("expected enabled after toggle")
}
if e.Toggle() {
t.Fatal("expected disabled after second toggle")
}
}
+8 -2
View File
@@ -252,7 +252,7 @@ func writeWrappedField(b *strings.Builder, label, value string, width, labelWidt
}
}
func renderFooter(t Theme, filtering, searching bool, filter string, follow bool, detailOpen bool, mouseEnabled bool, tab ViewTab, feedMeshVisible bool, historyNote string) string {
func renderFooter(t Theme, filtering, searching bool, filter string, follow bool, detailOpen bool, mouseEnabled bool, tab ViewTab, feedMeshVisible, soundEnabled bool, historyNote string) string {
var hints []string
if searching {
hints = []string{"type id…", "Enter lookup", "Esc cancel"}
@@ -269,6 +269,7 @@ func renderFooter(t Theme, filtering, searching bool, filter string, follow bool
if tab == TabFeed {
hints = append(hints, "v mesh")
}
hints = append(hints, "e sound")
hints = append(hints, "m mouse")
if filtering {
hints = []string{"type filter…", "Enter apply", "Esc cancel"}
@@ -286,6 +287,9 @@ func renderFooter(t Theme, filtering, searching bool, filter string, follow bool
if tab == TabFeed && feedMeshVisible {
line += " " + lipgloss.NewStyle().Foreground(t.Teal).Render("[mesh]")
}
if soundEnabled {
line += " " + lipgloss.NewStyle().Foreground(t.Teal).Render("[sound]")
}
if historyNote != "" {
line += " " + lipgloss.NewStyle().Foreground(t.Muted).Render("[" + historyNote + "]")
}
@@ -304,7 +308,8 @@ func renderHelp(t Theme) string {
PgUp Load older attacks (Feed) / moderation (Incidents) / more posts (Blog)
Feed tab
v Toggle peer mesh animation above the feed
v Toggle peer mesh animation below the feed
e Toggle event sounds (attack, block, peer, moderation)
Blog tab
Enter Read selected briefing
@@ -346,6 +351,7 @@ func renderHelp(t Theme) string {
r Force SSE reconnect
General
e Toggle synthesized event sounds (matches web viz)
? Toggle this help
q, Ctrl+C Quit
`
+1 -1
View File
@@ -48,7 +48,7 @@ func (m Model) chromeBottom() string {
if m.statusErr != "" {
parts = append(parts, renderStatusErr(m.theme, m.statusErr))
}
parts = append(parts, renderFooter(m.theme, m.filtering, m.searching, m.filterQuery, m.follow, m.detailOpen, m.mouseEnabled, m.tab, m.feedMeshVisible, m.historyFooterNote()))
parts = append(parts, renderFooter(m.theme, m.filtering, m.searching, m.filterQuery, m.follow, m.detailOpen, m.mouseEnabled, m.tab, m.feedMeshVisible, m.sound.Enabled(), m.historyFooterNote()))
if m.filtering {
parts = append(parts, m.filterInput.View())
}
+30 -6
View File
@@ -14,6 +14,7 @@ import (
"github.com/honeypeer/cli-viz/internal/config"
"github.com/honeypeer/cli-viz/internal/mesh"
"github.com/honeypeer/cli-viz/internal/sound"
"github.com/honeypeer/cli-viz/internal/transport"
"github.com/honeypeer/cli-viz/internal/viz"
)
@@ -63,6 +64,7 @@ type keyMap struct {
Up, Down, Quit, Help, Tab, Follow, Filter, Search, Enter, Reconnect, Close, Mouse key.Binding
PgUp, PgDn key.Binding
MeshView key.Binding
Sound key.Binding
}
func defaultKeyMap() keyMap {
@@ -82,6 +84,7 @@ func defaultKeyMap() keyMap {
PgUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "history")),
PgDn: key.NewBinding(key.WithKeys("pgdn"), key.WithHelp("pgdn", "down")),
MeshView: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "mesh")),
Sound: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "sound")),
}
}
@@ -146,6 +149,7 @@ type Model struct {
peerMesh *mesh.State
meshTickActive bool
feedMeshVisible bool
sound *sound.Engine
}
func NewModel(cfg config.Config) Model {
@@ -179,6 +183,7 @@ func NewModel(cfg config.Config) Model {
pm := mesh.NewState()
pm.SetReducedMotion(cfg.NoColor || envTruthy("HP_VIZ_REDUCED_MOTION"))
m.peerMesh = pm
m.sound = sound.New()
return m
}
@@ -261,9 +266,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case key.Matches(msg, m.keys.Mouse):
return m.toggleMouse()
case key.Matches(msg, m.keys.Sound):
m.sound.Toggle()
return m, nil
case key.Matches(msg, m.keys.Quit):
m.sse.Stop()
return m, tea.Quit
return m, m.quit()
}
return m, nil
}
@@ -284,9 +291,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case key.Matches(msg, m.keys.Mouse):
return m.toggleMouse()
case key.Matches(msg, m.keys.Sound):
m.sound.Toggle()
return m, nil
case key.Matches(msg, m.keys.Quit):
m.sse.Stop()
return m, tea.Quit
return m, m.quit()
}
return m, nil
}
@@ -294,8 +303,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Mouse):
return m.toggleMouse()
case key.Matches(msg, m.keys.Quit):
m.sse.Stop()
return m, tea.Quit
return m, m.quit()
case key.Matches(msg, m.keys.Help):
m.showHelp = !m.showHelp
case key.Matches(msg, m.keys.Tab):
@@ -324,6 +332,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.meshTickActive = false
}
}
case key.Matches(msg, m.keys.Sound):
m.sound.Toggle()
case key.Matches(msg, m.keys.Filter):
m.cancelSearch()
m.filtering = true
@@ -577,6 +587,7 @@ func (m *Model) handleSSE(evt transport.Event) {
fmt.Print("\a")
}
m.peerMesh.TriggerAttack(a.PeerID, a.Service, m.meshPalette())
m.sound.PlayAttack()
case "peer":
p := data.(viz.PeerEvent)
m.store.Push(viz.FeedFromPeer(p))
@@ -589,6 +600,11 @@ func (m *Model) handleSSE(evt transport.Event) {
net := viz.ApplyPeerEvent(m.network, p)
m.network = net
m.syncMeshFromNetwork()
if p.Status == "online" {
m.sound.PlayPeerOnline()
} else if p.Status == "offline" {
m.sound.PlayPeerOffline()
}
case "block":
b := data.(viz.BlockEvent)
m.store.Push(viz.FeedFromBlock(b))
@@ -596,6 +612,7 @@ func (m *Model) handleSSE(evt transport.Event) {
m.cursor = 0
}
m.peerMesh.TriggerBlock(m.meshPalette())
m.sound.PlayBlock()
case "moderation":
mod := data.(viz.ModerationEvent)
item := viz.FeedFromModeration(mod)
@@ -609,6 +626,7 @@ func (m *Model) handleSSE(evt transport.Event) {
if mod.TierAfter == "block" && mod.TierBefore != "block" {
m.peerMesh.TriggerBlock(m.meshPalette())
}
m.sound.PlayModeration()
case "stats":
s := data.(viz.Stats)
m.stats = &s
@@ -685,6 +703,12 @@ func (m *Model) openDetail() tea.Cmd {
return nil
}
func (m *Model) quit() tea.Cmd {
m.sse.Stop()
m.sound.Close()
return tea.Quit
}
func (m Model) toggleMouse() (Model, tea.Cmd) {
if m.mouseEnabled {
m.mouseEnabled = false