sound changes
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user