From 2860102627731eaff1a67329cbc7099a5d8f5755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Luis=20Monta=C3=B1es=20Ojados?= Date: Sat, 17 Jan 2026 17:02:18 +0100 Subject: [PATCH] Fix mic bar background continuity in status bar --- cmd/tui/model.go | 181 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 157 insertions(+), 24 deletions(-) diff --git a/cmd/tui/model.go b/cmd/tui/model.go index 0801013..5f47956 100644 --- a/cmd/tui/model.go +++ b/cmd/tui/model.go @@ -97,12 +97,15 @@ type Model struct { talkingClients map[uint16]bool // ClientID -> isTalking // Audio state - audioPlayer *audio.Player - audioCapturer *audio.Capturer - playbackVol int // 0-100 - micLevel int // 0-100 (current input level) - isMuted bool // Mic muted - isPTT bool // Push-to-talk active + audioPlayer *audio.Player + audioCapturer *audio.Capturer + playbackVol int // 0-100 + micLevel int // 0-100 (current input level) + isMuted bool // Mic muted + isPTT bool // Push-to-talk active (Manual TX) + vadEnabled bool // Voice Activation Detection active + vadThreshold int // 0-100 threshold for VAD + vadLastTriggered time.Time // Last time VAD threshold was exceeded // Popup State showPokePopup bool @@ -140,6 +143,8 @@ func NewModel(serverAddr, nickname string) *Model { logMessages: []string{"Starting..."}, talkingClients: make(map[uint16]bool), playbackVol: 80, // Default 80% volume + vadEnabled: true, + vadThreshold: 50, } } @@ -299,16 +304,52 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.audioCapturer = capturer // Set callback to send audio to server when PTT is active + // Set callback to send audio to server when PTT is active m.audioCapturer.SetCallback(func(samples []int16) { - if m.isPTT && m.client != nil && !m.isMuted { + // Calculate level of this frame for VAD decision + // Note: GetLevel() is smoothed, we might want instant frame level for VAD trigger? + // But pkg/audio/level.go is efficient. Let's re-calculate for precision. + level := audio.CalculateRMSLevel(samples) + + // Determine if we should transmit + shouldTransmit := false + + // Manual PTT (Locked on with 'v') + if m.isPTT { + shouldTransmit = true + } + + // VAD Logic + if m.vadEnabled && !m.isMuted { + if level > m.vadThreshold { + shouldTransmit = true + m.vadLastTriggered = time.Now() + } else if !m.vadLastTriggered.IsZero() && time.Since(m.vadLastTriggered) < 1*time.Second { + // Hold VAD open for 1 second (decay) + shouldTransmit = true + } + } + + // Allow transmission if forced or VAD triggered + if shouldTransmit && m.client != nil && !m.isMuted { m.client.SendAudio(samples) } - // Update mic level for display + + // Update mic level for display (use the calculated level) if m.program != nil { - m.program.Send(micLevelMsg(m.audioCapturer.GetLevel())) + m.program.Send(micLevelMsg(level)) } }) m.addLog("Audio capturer initialized") + + // Start capture immediately if VAD is enabled or PTT is active + if m.vadEnabled || m.isPTT { + if err := m.audioCapturer.Start(); err != nil { + m.addLog("Error starting audio capture: %v", err) + } else { + m.addLog("Audio capture started (VAD/PTT active)") + } + } } // Connect asynchronously @@ -348,16 +389,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Update mic level when PTT is active (multiply for better visibility) - if m.isPTT && m.audioCapturer != nil { - level := m.audioCapturer.GetLevel() * 4 // Boost for visibility - if level > 100 { - level = 100 - } - m.micLevel = level - } else { - m.micLevel = 0 // Reset when not transmitting - } + // Legacy mic level handling removed to support VAD event-driven updates // Continue ticking (100ms for responsive mic level) return m, tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { @@ -649,6 +681,34 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil + case "ctrl+up", "ctrl+right": + // Increase VAD threshold + m.vadThreshold += 5 + if m.vadThreshold > 100 { + m.vadThreshold = 100 + } + m.addLog("VAD Threshold: %d", m.vadThreshold) + return m, nil + + case "ctrl+down", "ctrl+left": + // Decrease VAD threshold + m.vadThreshold -= 5 + if m.vadThreshold < 0 { + m.vadThreshold = 0 + } + m.addLog("VAD Threshold: %d", m.vadThreshold) + return m, nil + + case "g", "G": + // Toggle VAD (Gate) + m.vadEnabled = !m.vadEnabled + state := "OFF" + if m.vadEnabled { + state = "ON" + } + m.addLog("Voice Activation (Gate): %s", state) + return m, nil + case "v", "V": // Toggle voice (PTT) m.isPTT = !m.isPTT @@ -1053,12 +1113,85 @@ func (m *Model) renderStatusBar() string { } volPart := fmt.Sprintf("%s:%s%d%%", muteIcon, volBar, m.playbackVol) - micBar := audio.LevelToBar(m.micLevel, 6) - pttIcon := "MIC" - if m.isPTT { - pttIcon = "*TX*" + // Custom Mic Bar with VAD Threshold + micBarWidth := 8 + var micBar string + + if m.vadEnabled { + // Calculate threshold position + threshPos := m.vadThreshold * micBarWidth / 100 + if threshPos >= micBarWidth { + threshPos = micBarWidth - 1 + } + + // Calculate filled position based on current level + filled := m.micLevel * micBarWidth / 100 + + // Build bar + var sb strings.Builder + for i := 0; i < micBarWidth; i++ { + char := "░" + if i < filled { + char = "█" + } + + // Overlay threshold marker + if i == threshPos { + if i < filled { + // Threshold is met + char = "▓" + } else { + // Threshold not met + char = "│" + } + } + sb.WriteString(char) + } + micBar = sb.String() + } else { + micBar = audio.LevelToBar(m.micLevel, micBarWidth) } - micPart := fmt.Sprintf("%s:%s", pttIcon, micBar) + + pttStyle := lipgloss.NewStyle() + pttIcon := "MIC" + + if m.isPTT { + pttIcon = " ON" // Manual ON + pttStyle = pttStyle.Foreground(lipgloss.Color("196")).Bold(true) // Red + } else if m.vadEnabled { + pttIcon = "VAD" + // Check if actively transmitting (using logic with decay) + isTransmitting := false + if !m.isMuted { + if m.micLevel > m.vadThreshold { + isTransmitting = true + } else if !m.vadLastTriggered.IsZero() && time.Since(m.vadLastTriggered) < 1*time.Second { + isTransmitting = true + } + } + + if isTransmitting { + // Transmitting via VAD: Red/Bold + pttStyle = pttStyle.Foreground(lipgloss.Color("196")).Bold(true) + } else { + // Idle VAD: Gray/Faint + pttStyle = pttStyle.Foreground(lipgloss.Color("240")).Faint(true) + } + } else { + // Standard Mic (PTT mode but off) + pttStyle = pttStyle.Foreground(lipgloss.Color("255")) // White + } + + // Apply status bar background color to prevent cutting + pttStyle = pttStyle.Background(lipgloss.Color("57")) // Matches Top Bar Background + + // Style for the bar itself to maintain background continuity + barStyle := lipgloss.NewStyle().Background(lipgloss.Color("57")).Foreground(lipgloss.Color("255")) + + micPart := fmt.Sprintf("%s%s%s", + pttStyle.Render(pttIcon), + barStyle.Render(":"), + barStyle.Render(micBar)) rightPart := fmt.Sprintf("%s | %s ", volPart, micPart)