Volume Controls
This commit is contained in:
+51
-15
@@ -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 (0–1).
|
||||
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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
`
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user