sound changes
ci / test (push) Successful in 38s
ci / release (push) Successful in 1m27s

This commit is contained in:
2026-07-03 07:03:16 -04:00
parent 48e8d0ecdc
commit 4bc8c6f8c8
8 changed files with 249 additions and 160 deletions
+2 -5
View File
@@ -18,11 +18,8 @@ jobs:
run: bash scripts/ci-setup-go.sh
env:
GO_VERSION: ${{ env.GO_VERSION }}
- 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/
- run: go test ./...
- run: go build -o bin/hp-viz ./cmd/hp-viz/
release:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
-2
View File
@@ -19,8 +19,6 @@ 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,10 +22,6 @@ 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=
-5
View File
@@ -1,7 +1,6 @@
package sound
import (
"bytes"
"encoding/binary"
"os"
"strings"
@@ -151,7 +150,3 @@ 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)
}
+227
View File
@@ -0,0 +1,227 @@
//go:build !nosound
package sound
import (
"bytes"
"encoding/binary"
"io"
"os"
"os/exec"
"runtime"
"sync"
)
// Playback routes synthesized PCM through the desktop audio stack (PulseAudio /
// PipeWire / CoreAudio) instead of opening ALSA devices directly.
type player struct {
name string
newCmd func(wav, raw []byte) *exec.Cmd
}
var (
playerOnce sync.Once
playerSel *player
activeMu sync.Mutex
activeCmds []*exec.Cmd
)
func playPCM(pcm []byte) error {
if len(pcm) == 0 {
return nil
}
playerOnce.Do(selectPlayer)
if playerSel == nil {
return nil
}
wav := wrapWAV(pcm, sampleRate, 2)
cmd := playerSel.newCmd(wav, pcm)
if cmd == nil {
return nil
}
cmd.Stderr = io.Discard
if err := cmd.Start(); err != nil {
return err
}
activeMu.Lock()
activeCmds = append(activeCmds, cmd)
activeMu.Unlock()
go reapCmd(cmd)
return nil
}
func closePlayback() {
activeMu.Lock()
cmds := activeCmds
activeCmds = nil
activeMu.Unlock()
for _, cmd := range cmds {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
}
}
func reapCmd(cmd *exec.Cmd) {
_ = cmd.Wait()
activeMu.Lock()
defer activeMu.Unlock()
for i, c := range activeCmds {
if c == cmd {
activeCmds = append(activeCmds[:i], activeCmds[i+1:]...)
break
}
}
}
func selectPlayer() {
if override := os.Getenv("HP_VIZ_SOUND_PLAYER"); override != "" {
if p := playerFromName(override); p != nil {
playerSel = p
return
}
}
switch runtime.GOOS {
case "linux":
playerSel = detectLinuxPlayer()
case "darwin":
playerSel = detectDarwinPlayer()
default:
playerSel = nil
}
}
func detectLinuxPlayer() *player {
// Prefer session sound servers — they use the user's default output device.
candidates := []struct {
name string
path string
fn func(string) *player
}{
{"paplay", "paplay", paplayPlayer},
{"pw-play", "pw-play", pwPlayPlayer},
{"aplay-pulse", "aplay", aplayPulsePlayer},
{"aplay-plug", "aplay", aplayPlugPlayer},
}
for _, c := range candidates {
if path, err := exec.LookPath(c.path); err == nil {
if p := c.fn(path); p != nil {
return p
}
}
}
return nil
}
func detectDarwinPlayer() *player {
if path, err := exec.LookPath("afplay"); err == nil {
return afplayPlayer(path)
}
return nil
}
func playerFromName(name string) *player {
switch name {
case "paplay":
if path, err := exec.LookPath("paplay"); err == nil {
return paplayPlayer(path)
}
case "pw-play":
if path, err := exec.LookPath("pw-play"); err == nil {
return pwPlayPlayer(path)
}
case "afplay":
if path, err := exec.LookPath("afplay"); err == nil {
return afplayPlayer(path)
}
case "aplay-pulse":
if path, err := exec.LookPath("aplay"); err == nil {
return aplayPulsePlayer(path)
}
case "aplay-plug":
if path, err := exec.LookPath("aplay"); err == nil {
return aplayPlugPlayer(path)
}
}
return nil
}
func paplayPlayer(path string) *player {
return &player{
name: "paplay",
newCmd: func(wav, _ []byte) *exec.Cmd {
cmd := exec.Command(path)
cmd.Stdin = bytes.NewReader(wav)
return cmd
},
}
}
func pwPlayPlayer(path string) *player {
return &player{
name: "pw-play",
newCmd: func(wav, _ []byte) *exec.Cmd {
cmd := exec.Command(path, "-")
cmd.Stdin = bytes.NewReader(wav)
return cmd
},
}
}
func afplayPlayer(path string) *player {
return &player{
name: "afplay",
newCmd: func(wav, _ []byte) *exec.Cmd {
cmd := exec.Command(path, "-")
cmd.Stdin = bytes.NewReader(wav)
return cmd
},
}
}
func aplayPulsePlayer(path string) *player {
return &player{
name: "aplay-pulse",
newCmd: func(_, raw []byte) *exec.Cmd {
cmd := exec.Command(path, "-q", "-D", "pulse", "-t", "raw", "-f", "S16_LE", "-r", "44100", "-c", "2")
cmd.Stdin = bytes.NewReader(raw)
return cmd
},
}
}
func aplayPlugPlayer(path string) *player {
device := os.Getenv("HP_VIZ_ALSA_DEVICE")
if device == "" {
device = "plug:default"
}
return &player{
name: "aplay-plug",
newCmd: func(_, raw []byte) *exec.Cmd {
cmd := exec.Command(path, "-q", "-D", device, "-t", "raw", "-f", "S16_LE", "-r", "44100", "-c", "2")
cmd.Stdin = bytes.NewReader(raw)
return cmd
},
}
}
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
}
-83
View File
@@ -1,83 +0,0 @@
//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
@@ -1,61 +0,0 @@
//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
}
+20
View File
@@ -0,0 +1,20 @@
package sound
import (
"encoding/binary"
"testing"
)
func TestWrapWAVHeader(t *testing.T) {
pcm := []byte{0, 1, 2, 3}
wav := wrapWAV(pcm, sampleRate, 2)
if len(wav) != 44+len(pcm) {
t.Fatalf("len = %d", len(wav))
}
if string(wav[0:4]) != "RIFF" || string(wav[8:12]) != "WAVE" {
t.Fatal("bad wav header")
}
if binary.LittleEndian.Uint32(wav[40:44]) != uint32(len(pcm)) {
t.Fatal("bad data chunk size")
}
}