Volume Controls
ci / test (push) Successful in 36s
ci / release (push) Successful in 1m26s

This commit is contained in:
2026-07-03 07:09:52 -04:00
parent 4bc8c6f8c8
commit 64490dbd13
6 changed files with 157 additions and 18 deletions
+51 -15
View File
@@ -11,6 +11,9 @@ import (
const (
defaultVolume = 0.3
maxAttackSoundsPerSec = 4
// VolumeStep is the adjustment per -/+ key press.
VolumeStep = 0.05
volumeStep = VolumeStep
)
// Engine plays HoneyPeer viz event sounds (ported from web useVizSound.ts).
@@ -56,56 +59,89 @@ func (e *Engine) Toggle() bool {
return e.enabled
}
// Volume returns the current playback level (01).
func (e *Engine) Volume() float64 {
e.mu.Lock()
defer e.mu.Unlock()
return e.volume
}
// AdjustVolume changes volume by delta and returns the clamped result.
func (e *Engine) AdjustVolume(delta float64) float64 {
e.mu.Lock()
defer e.mu.Unlock()
e.volume += delta
if e.volume < 0 {
e.volume = 0
}
if e.volume > 1 {
e.volume = 1
}
return e.volume
}
func (e *Engine) playbackVolume() float64 {
e.mu.Lock()
defer e.mu.Unlock()
if !e.enabled {
return 0
}
return e.volume
}
func (e *Engine) Close() {
closePlayback()
}
func (e *Engine) PlayAttack() {
if !e.Enabled() {
return
}
scale := e.attackVolScale()
if scale <= 0 {
return
}
vol := e.volume * scale
vol := e.playbackVolume()
if vol <= 0 {
return
}
vol *= 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() {
vol := e.playbackVolume()
if vol <= 0 {
return
}
e.output(renderTone(70, 350*time.Millisecond, waveSine, e.volume))
e.output(renderTone(70, 350*time.Millisecond, waveSine, vol))
}
func (e *Engine) PlayModeration() {
if !e.Enabled() {
vol := e.playbackVolume()
if vol <= 0 {
return
}
vol := e.volume
e.output(renderTone(550, 100*time.Millisecond, waveSine, vol))
time.AfterFunc(90*time.Millisecond, func() {
if !e.Enabled() {
return
if vol := e.playbackVolume(); vol > 0 {
e.output(renderTone(880, 120*time.Millisecond, waveSine, vol))
}
e.output(renderTone(880, 120*time.Millisecond, waveSine, vol))
})
}
func (e *Engine) PlayPeerOnline() {
if !e.Enabled() {
vol := e.playbackVolume()
if vol <= 0 {
return
}
e.output(renderSweep(350, 750, 180*time.Millisecond, waveSine, e.volume))
e.output(renderSweep(350, 750, 180*time.Millisecond, waveSine, vol))
}
func (e *Engine) PlayPeerOffline() {
if !e.Enabled() {
vol := e.playbackVolume()
if vol <= 0 {
return
}
e.output(renderSweep(600, 200, 250*time.Millisecond, waveSine, e.volume))
e.output(renderSweep(600, 200, 250*time.Millisecond, waveSine, vol))
}
func (e *Engine) attackVolScale() float64 {
+19
View File
@@ -57,3 +57,22 @@ func TestToggle(t *testing.T) {
t.Fatal("expected disabled after second toggle")
}
}
func TestAdjustVolume(t *testing.T) {
e := New()
if got := e.Volume(); got != defaultVolume {
t.Fatalf("default = %v", got)
}
e.AdjustVolume(VolumeStep)
if got := e.Volume(); got != defaultVolume+VolumeStep {
t.Fatalf("after up = %v", got)
}
e.AdjustVolume(-2)
if got := e.Volume(); got != 0 {
t.Fatalf("clamped min = %v", got)
}
e.AdjustVolume(3)
if got := e.Volume(); got != 1 {
t.Fatalf("clamped max = %v", got)
}
}
+20 -2
View File
@@ -325,7 +325,21 @@ 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, soundEnabled bool, historyNote string) string {
func renderVolumeBar(t Theme, vol float64) string {
const segments = 10
filled := int(vol*float64(segments) + 0.5)
if filled < 0 {
filled = 0
}
if filled > segments {
filled = segments
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", segments-filled)
pct := int(vol*100 + 0.5)
return lipgloss.NewStyle().Foreground(t.Teal).Render(fmt.Sprintf("vol [%s] %d%%", bar, pct))
}
func renderFooter(t Theme, filtering, searching bool, filter string, follow bool, detailOpen bool, mouseEnabled bool, tab ViewTab, feedMeshVisible, soundEnabled bool, soundVolume float64, historyNote string) string {
var hints []string
if searching {
hints = []string{"type id…", "Enter lookup", "Esc cancel"}
@@ -343,6 +357,9 @@ func renderFooter(t Theme, filtering, searching bool, filter string, follow bool
hints = append(hints, "v mesh")
}
hints = append(hints, "e sound")
if soundEnabled {
hints = append(hints, "-/= vol")
}
hints = append(hints, "m mouse")
if filtering {
hints = []string{"type filter…", "Enter apply", "Esc cancel"}
@@ -361,7 +378,7 @@ func renderFooter(t Theme, filtering, searching bool, filter string, follow bool
line += " " + lipgloss.NewStyle().Foreground(t.Teal).Render("[mesh]")
}
if soundEnabled {
line += " " + lipgloss.NewStyle().Foreground(t.Teal).Render("[sound]")
line += " " + renderVolumeBar(t, soundVolume)
}
if historyNote != "" {
line += " " + lipgloss.NewStyle().Foreground(t.Muted).Render("[" + historyNote + "]")
@@ -425,6 +442,7 @@ func renderHelp(t Theme) string {
General
e Toggle synthesized event sounds (matches web viz)
- / = (+) Volume down / up (while sound is on)
? 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.sound.Enabled(), 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.sound.Volume(), m.historyFooterNote()))
if m.filtering {
parts = append(parts, m.filterInput.View())
}
+27
View File
@@ -69,6 +69,8 @@ type keyMap struct {
PgUp, PgDn key.Binding
MeshView key.Binding
Sound key.Binding
VolDown key.Binding
VolUp key.Binding
}
func defaultKeyMap() keyMap {
@@ -89,6 +91,8 @@ func defaultKeyMap() keyMap {
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")),
VolDown: key.NewBinding(key.WithKeys("-", "_"), key.WithHelp("-", "quieter")),
VolUp: key.NewBinding(key.WithKeys("=", "+"), key.WithHelp("=/+", "louder")),
}
}
@@ -276,6 +280,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Sound):
m.sound.Toggle()
return m, nil
case key.Matches(msg, m.keys.VolDown):
m.adjustSoundVolume(-sound.VolumeStep)
return m, nil
case key.Matches(msg, m.keys.VolUp):
m.adjustSoundVolume(sound.VolumeStep)
return m, nil
case key.Matches(msg, m.keys.Quit):
return m, m.quit()
}
@@ -301,6 +311,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Sound):
m.sound.Toggle()
return m, nil
case key.Matches(msg, m.keys.VolDown):
m.adjustSoundVolume(-sound.VolumeStep)
return m, nil
case key.Matches(msg, m.keys.VolUp):
m.adjustSoundVolume(sound.VolumeStep)
return m, nil
case key.Matches(msg, m.keys.Quit):
return m, m.quit()
}
@@ -342,6 +358,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case key.Matches(msg, m.keys.Sound):
m.sound.Toggle()
case key.Matches(msg, m.keys.VolDown):
m.adjustSoundVolume(-sound.VolumeStep)
case key.Matches(msg, m.keys.VolUp):
m.adjustSoundVolume(sound.VolumeStep)
case key.Matches(msg, m.keys.Filter):
m.cancelSearch()
m.filtering = true
@@ -730,6 +750,13 @@ func (m *Model) openDetail() tea.Cmd {
return nil
}
func (m *Model) adjustSoundVolume(delta float64) {
m.sound.AdjustVolume(delta)
if m.sound.Enabled() {
m.sound.PlayBlock()
}
}
func (m *Model) quit() tea.Cmd {
m.sse.Stop()
m.sound.Close()
+39
View File
@@ -0,0 +1,39 @@
package ui
import (
"strings"
"testing"
)
func TestRenderVolumeBar(t *testing.T) {
tm := NewTheme(false)
out := renderVolumeBar(tm, 0.3)
if !strings.Contains(out, "30%") {
t.Fatalf("expected percent: %q", out)
}
if !strings.Contains(out, "█") || !strings.Contains(out, "░") {
t.Fatalf("expected bar chars: %q", out)
}
}
func TestFooterShowsVolumeWhenSoundOn(t *testing.T) {
tm := NewTheme(false)
out := renderFooter(tm, false, false, "", false, false, false, TabFeed, false, true, 0.45, "")
if !strings.Contains(out, "vol [") {
t.Fatalf("missing volume bar: %q", out)
}
if !strings.Contains(out, "45%") {
t.Fatalf("missing percent: %q", out)
}
if strings.Contains(out, "[sound]") {
t.Fatal("legacy sound tag should be replaced by bar")
}
}
func TestFooterHidesVolumeBarWhenSoundOff(t *testing.T) {
tm := NewTheme(false)
out := renderFooter(tm, false, false, "", false, false, false, TabFeed, false, false, 0.3, "")
if strings.Contains(out, "vol [") {
t.Fatal("volume bar should hide when sound off")
}
}