diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7098c15..0b9432b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -231,6 +231,23 @@ systemctl restart jetkvm cd ui && npm run lint ``` +### Local Code Quality Tools + +The project includes several Makefile targets for local code quality checks that mirror the GitHub Actions workflows: + +```bash +# Run Go linting (mirrors .github/workflows/lint.yml) +make lint + +# Run Go linting with auto-fix +make lint-fix + +# Run UI linting (mirrors .github/workflows/ui-lint.yml) +make ui-lint +``` + +**Note:** The `lint` and `lint-fix` targets require audio dependencies. Run `make dev_env` first if you haven't already. + ### API Testing ```bash diff --git a/Makefile b/Makefile index 381aa7f..f59cd11 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # --- JetKVM Audio/Toolchain Dev Environment Setup --- -.PHONY: setup_toolchain build_audio_deps dev_env +.PHONY: setup_toolchain build_audio_deps dev_env lint lint-fix ui-lint # Clone the rv1106-system toolchain to $HOME/.jetkvm/rv1106-system setup_toolchain: @@ -126,3 +126,27 @@ release: @shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256 rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION)/jetkvm_app rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256 + +# Run golangci-lint locally with the same configuration as CI +lint: build_audio_deps + @echo "Running golangci-lint..." + @mkdir -p static && touch static/.gitkeep + CGO_ENABLED=1 \ + CGO_CFLAGS="-I$(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/include -I$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/include -I$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/celt" \ + CGO_LDFLAGS="-L$(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/src/.libs -lasound -L$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/.libs -lopus -lm -ldl -static" \ + golangci-lint run --verbose + +# Run golangci-lint with auto-fix +lint-fix: build_audio_deps + @echo "Running golangci-lint with auto-fix..." + @mkdir -p static && touch static/.gitkeep + CGO_ENABLED=1 \ + CGO_CFLAGS="-I$(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/include -I$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/include -I$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/celt" \ + CGO_LDFLAGS="-L$(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/src/.libs -lasound -L$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/.libs -lopus -lm -ldl -static" \ + golangci-lint run --fix --verbose + +# Run UI linting locally (mirrors GitHub workflow ui-lint.yml) +ui-lint: + @echo "Running UI lint..." + @cd ui && npm ci + @cd ui && npm run lint diff --git a/cmd/main.go b/cmd/main.go index 1066fac..0cdb2b3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -12,6 +12,7 @@ func main() { versionPtr := flag.Bool("version", false, "print version and exit") versionJsonPtr := flag.Bool("version-json", false, "print version as json and exit") audioServerPtr := flag.Bool("audio-server", false, "Run as audio server subprocess") + audioInputServerPtr := flag.Bool("audio-input-server", false, "Run as audio input server subprocess") flag.Parse() if *versionPtr || *versionJsonPtr { @@ -24,5 +25,5 @@ func main() { return } - kvm.Main(*audioServerPtr) + kvm.Main(*audioServerPtr, *audioInputServerPtr) } diff --git a/internal/audio/api.go b/internal/audio/api.go index dcc3ae6..b465fe8 100644 --- a/internal/audio/api.go +++ b/internal/audio/api.go @@ -3,6 +3,13 @@ package audio import ( "os" "strings" + "sync/atomic" + "unsafe" +) + +var ( + // Global audio output supervisor instance + globalOutputSupervisor unsafe.Pointer // *AudioServerSupervisor ) // isAudioServerProcess detects if we're running as the audio server subprocess @@ -49,3 +56,17 @@ func StartNonBlockingAudioStreaming(send func([]byte)) error { func StopNonBlockingAudioStreaming() { StopAudioOutputStreaming() } + +// SetAudioOutputSupervisor sets the global audio output supervisor +func SetAudioOutputSupervisor(supervisor *AudioServerSupervisor) { + atomic.StorePointer(&globalOutputSupervisor, unsafe.Pointer(supervisor)) +} + +// GetAudioOutputSupervisor returns the global audio output supervisor +func GetAudioOutputSupervisor() *AudioServerSupervisor { + ptr := atomic.LoadPointer(&globalOutputSupervisor) + if ptr == nil { + return nil + } + return (*AudioServerSupervisor)(ptr) +} diff --git a/internal/audio/audio.go b/internal/audio/audio.go index 220cdad..0a7b468 100644 --- a/internal/audio/audio.go +++ b/internal/audio/audio.go @@ -157,9 +157,20 @@ func GetMicrophoneConfig() AudioConfig { // GetAudioMetrics returns current audio metrics func GetAudioMetrics() AudioMetrics { + // Get base metrics + framesReceived := atomic.LoadInt64(&metrics.FramesReceived) + framesDropped := atomic.LoadInt64(&metrics.FramesDropped) + + // If audio relay is running, use relay stats instead + if IsAudioRelayRunning() { + relayReceived, relayDropped := GetAudioRelayStats() + framesReceived = relayReceived + framesDropped = relayDropped + } + return AudioMetrics{ - FramesReceived: atomic.LoadInt64(&metrics.FramesReceived), - FramesDropped: atomic.LoadInt64(&metrics.FramesDropped), + FramesReceived: framesReceived, + FramesDropped: framesDropped, BytesProcessed: atomic.LoadInt64(&metrics.BytesProcessed), LastFrameTime: metrics.LastFrameTime, ConnectionDrops: atomic.LoadInt64(&metrics.ConnectionDrops), diff --git a/internal/audio/events.go b/internal/audio/events.go index 124c382..01236e8 100644 --- a/internal/audio/events.go +++ b/internal/audio/events.go @@ -21,6 +21,8 @@ const ( AudioEventMetricsUpdate AudioEventType = "audio-metrics-update" AudioEventMicrophoneState AudioEventType = "microphone-state-changed" AudioEventMicrophoneMetrics AudioEventType = "microphone-metrics-update" + AudioEventProcessMetrics AudioEventType = "audio-process-metrics" + AudioEventMicProcessMetrics AudioEventType = "microphone-process-metrics" ) // AudioEvent represents a WebSocket audio event @@ -60,6 +62,17 @@ type MicrophoneMetricsData struct { AverageLatency string `json:"average_latency"` } +// ProcessMetricsData represents process metrics data for WebSocket events +type ProcessMetricsData struct { + PID int `json:"pid"` + CPUPercent float64 `json:"cpu_percent"` + MemoryRSS int64 `json:"memory_rss"` + MemoryVMS int64 `json:"memory_vms"` + MemoryPercent float64 `json:"memory_percent"` + Running bool `json:"running"` + ProcessName string `json:"process_name"` +} + // AudioEventSubscriber represents a WebSocket connection subscribed to audio events type AudioEventSubscriber struct { conn *websocket.Conn @@ -220,6 +233,25 @@ func (aeb *AudioEventBroadcaster) sendCurrentMetrics(subscriber *AudioEventSubsc } aeb.sendToSubscriber(subscriber, audioMetricsEvent) + // Send audio process metrics + if outputSupervisor := GetAudioOutputSupervisor(); outputSupervisor != nil { + if processMetrics := outputSupervisor.GetProcessMetrics(); processMetrics != nil { + audioProcessEvent := AudioEvent{ + Type: AudioEventProcessMetrics, + Data: ProcessMetricsData{ + PID: processMetrics.PID, + CPUPercent: processMetrics.CPUPercent, + MemoryRSS: processMetrics.MemoryRSS, + MemoryVMS: processMetrics.MemoryVMS, + MemoryPercent: processMetrics.MemoryPercent, + Running: outputSupervisor.IsRunning(), + ProcessName: processMetrics.ProcessName, + }, + } + aeb.sendToSubscriber(subscriber, audioProcessEvent) + } + } + // Send microphone metrics using session provider sessionProvider := GetSessionProvider() if sessionProvider.IsSessionActive() { @@ -239,12 +271,31 @@ func (aeb *AudioEventBroadcaster) sendCurrentMetrics(subscriber *AudioEventSubsc aeb.sendToSubscriber(subscriber, micMetricsEvent) } } + + // Send microphone process metrics + if inputSupervisor := GetAudioInputIPCSupervisor(); inputSupervisor != nil { + if processMetrics := inputSupervisor.GetProcessMetrics(); processMetrics != nil { + micProcessEvent := AudioEvent{ + Type: AudioEventMicProcessMetrics, + Data: ProcessMetricsData{ + PID: processMetrics.PID, + CPUPercent: processMetrics.CPUPercent, + MemoryRSS: processMetrics.MemoryRSS, + MemoryVMS: processMetrics.MemoryVMS, + MemoryPercent: processMetrics.MemoryPercent, + Running: inputSupervisor.IsRunning(), + ProcessName: processMetrics.ProcessName, + }, + } + aeb.sendToSubscriber(subscriber, micProcessEvent) + } + } } // startMetricsBroadcasting starts a goroutine that periodically broadcasts metrics func (aeb *AudioEventBroadcaster) startMetricsBroadcasting() { - // Use 5-second interval instead of 2 seconds for constrained environments - ticker := time.NewTicker(5 * time.Second) + // Use 1-second interval to match Connection Stats sidebar frequency for smooth histogram progression + ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for range ticker.C { @@ -311,6 +362,44 @@ func (aeb *AudioEventBroadcaster) startMetricsBroadcasting() { aeb.broadcast(micMetricsEvent) } } + + // Broadcast audio process metrics + if outputSupervisor := GetAudioOutputSupervisor(); outputSupervisor != nil { + if processMetrics := outputSupervisor.GetProcessMetrics(); processMetrics != nil { + audioProcessEvent := AudioEvent{ + Type: AudioEventProcessMetrics, + Data: ProcessMetricsData{ + PID: processMetrics.PID, + CPUPercent: processMetrics.CPUPercent, + MemoryRSS: processMetrics.MemoryRSS, + MemoryVMS: processMetrics.MemoryVMS, + MemoryPercent: processMetrics.MemoryPercent, + Running: outputSupervisor.IsRunning(), + ProcessName: processMetrics.ProcessName, + }, + } + aeb.broadcast(audioProcessEvent) + } + } + + // Broadcast microphone process metrics + if inputSupervisor := GetAudioInputIPCSupervisor(); inputSupervisor != nil { + if processMetrics := inputSupervisor.GetProcessMetrics(); processMetrics != nil { + micProcessEvent := AudioEvent{ + Type: AudioEventMicProcessMetrics, + Data: ProcessMetricsData{ + PID: processMetrics.PID, + CPUPercent: processMetrics.CPUPercent, + MemoryRSS: processMetrics.MemoryRSS, + MemoryVMS: processMetrics.MemoryVMS, + MemoryPercent: processMetrics.MemoryPercent, + Running: inputSupervisor.IsRunning(), + ProcessName: processMetrics.ProcessName, + }, + } + aeb.broadcast(micProcessEvent) + } + } } } diff --git a/internal/audio/input_server_main.go b/internal/audio/input_server_main.go index 6ce66f1..971fe4a 100644 --- a/internal/audio/input_server_main.go +++ b/internal/audio/input_server_main.go @@ -10,11 +10,6 @@ import ( "github.com/jetkvm/kvm/internal/logging" ) -// IsAudioInputServerProcess detects if we're running as the audio input server subprocess -func IsAudioInputServerProcess() bool { - return os.Getenv("JETKVM_AUDIO_INPUT_SERVER") == "true" -} - // RunAudioInputServer runs the audio input server subprocess // This should be called from main() when the subprocess is detected func RunAudioInputServer() error { diff --git a/internal/audio/input_supervisor.go b/internal/audio/input_supervisor.go index ae2b941..701ce75 100644 --- a/internal/audio/input_supervisor.go +++ b/internal/audio/input_supervisor.go @@ -53,10 +53,9 @@ func (ais *AudioInputSupervisor) Start() error { } // Create command for audio input server subprocess - cmd := exec.CommandContext(ctx, execPath) + cmd := exec.CommandContext(ctx, execPath, "--audio-input-server") cmd.Env = append(os.Environ(), - "JETKVM_AUDIO_INPUT_SERVER=true", // Flag to indicate this is the input server process - "JETKVM_AUDIO_INPUT_IPC=true", // Enable IPC mode + "JETKVM_AUDIO_INPUT_IPC=true", // Enable IPC mode ) // Set process group to allow clean termination diff --git a/internal/audio/process_monitor.go b/internal/audio/process_monitor.go index 9cad7e9..6d90e06 100644 --- a/internal/audio/process_monitor.go +++ b/internal/audio/process_monitor.go @@ -208,6 +208,10 @@ func (pm *ProcessMonitor) collectMetrics(pid int, state *processState) (ProcessM if timeDelta > 0 { metric.CPUPercent = (cpuSeconds / timeDelta) * 100.0 + // Cap CPU percentage at 100% to handle multi-core usage + if metric.CPUPercent > 100.0 { + metric.CPUPercent = 100.0 + } } } @@ -249,6 +253,11 @@ func (pm *ProcessMonitor) getTotalMemory() int64 { return 0 } +// GetTotalMemory returns total system memory in bytes (public method) +func (pm *ProcessMonitor) GetTotalMemory() int64 { + return pm.getTotalMemory() +} + // Global process monitor instance var globalProcessMonitor *ProcessMonitor var processMonitorOnce sync.Once diff --git a/main.go b/main.go index 7dbd080..0d7a8ba 100644 --- a/main.go +++ b/main.go @@ -63,6 +63,9 @@ func startAudioSubprocess() error { // Create audio server supervisor audioSupervisor = audio.NewAudioServerSupervisor() + // Set the global supervisor for access from audio package + audio.SetAudioOutputSupervisor(audioSupervisor) + // Set up callbacks for process lifecycle events audioSupervisor.SetCallbacks( // onProcessStart @@ -112,7 +115,7 @@ func startAudioSubprocess() error { return nil } -func Main(audioServer bool) { +func Main(audioServer bool, audioInputServer bool) { // Initialize channel and set audio server flag isAudioServer = audioServer audioProcessDone = make(chan struct{}) @@ -124,7 +127,7 @@ func Main(audioServer bool) { } // If running as audio input server, only initialize audio input processing - if audio.IsAudioInputServerProcess() { + if audioInputServer { err := audio.RunAudioInputServer() if err != nil { logger.Error().Err(err).Msg("audio input server failed") @@ -209,6 +212,14 @@ func Main(audioServer bool) { audio.InitializeAudioEventBroadcaster() logger.Info().Msg("audio event broadcaster initialized") + // Start audio input system for microphone processing + err = audio.StartAudioInput() + if err != nil { + logger.Warn().Err(err).Msg("failed to start audio input system") + } else { + logger.Info().Msg("audio input system started") + } + if err := setInitialVirtualMediaState(); err != nil { logger.Warn().Err(err).Msg("failed to set initial virtual media state") } @@ -261,6 +272,10 @@ func Main(audioServer bool) { // Stop audio subprocess and wait for cleanup if !isAudioServer { + // Stop audio input system + logger.Info().Msg("stopping audio input system") + audio.StopAudioInput() + if audioSupervisor != nil { logger.Info().Msg("stopping audio supervisor") if err := audioSupervisor.Stop(); err != nil { diff --git a/ui/src/components/AudioMetricsDashboard.tsx b/ui/src/components/AudioMetricsDashboard.tsx index e32ce1e..0c3032a 100644 --- a/ui/src/components/AudioMetricsDashboard.tsx +++ b/ui/src/components/AudioMetricsDashboard.tsx @@ -3,6 +3,7 @@ import { MdGraphicEq, MdSignalWifi4Bar, MdError, MdMic } from "react-icons/md"; import { LuActivity, LuClock, LuHardDrive, LuSettings, LuCpu, LuMemoryStick } from "react-icons/lu"; import { AudioLevelMeter } from "@components/AudioLevelMeter"; +import StatChart from "@components/StatChart"; import { cx } from "@/cva.config"; import { useMicrophone } from "@/hooks/useMicrophone"; import { useAudioLevel } from "@/hooks/useAudioLevel"; @@ -50,28 +51,165 @@ const qualityLabels = { 3: "Ultra" }; +// Format percentage values to 2 decimal places +function formatPercentage(value: number | null | undefined): string { + if (value === null || value === undefined || isNaN(value)) { + return "0.00%"; + } + return `${value.toFixed(2)}%`; +} + +function formatMemoryMB(rssBytes: number | null | undefined): string { + if (rssBytes === null || rssBytes === undefined || isNaN(rssBytes)) { + return "0.00 MB"; + } + const mb = rssBytes / (1024 * 1024); + return `${mb.toFixed(2)} MB`; +} + +// Default system memory estimate in MB (will be replaced by actual value from backend) +const DEFAULT_SYSTEM_MEMORY_MB = 4096; // 4GB default + +// Create chart array similar to connectionStats.tsx +function createChartArray( + stream: Map, + metric: K, +): { date: number; stat: T[K] | null }[] { + const stat = Array.from(stream).map(([key, stats]) => { + return { date: key, stat: stats[metric] }; + }); + + // Sort the dates to ensure they are in chronological order + const sortedStat = stat.map(x => x.date).sort((a, b) => a - b); + + // Determine the earliest statistic date + const earliestStat = sortedStat[0]; + + // Current time in seconds since the Unix epoch + const now = Math.floor(Date.now() / 1000); + + // Determine the starting point for the chart data + const firstChartDate = earliestStat ? Math.min(earliestStat, now - 120) : now - 120; + + // Generate the chart array for the range between 'firstChartDate' and 'now' + return Array.from({ length: now - firstChartDate }, (_, i) => { + const currentDate = firstChartDate + i; + return { + date: currentDate, + // Find the statistic for 'currentDate', or use the last known statistic if none exists for that date + stat: stat.find(x => x.date === currentDate)?.stat ?? null, + }; + }); +} + export default function AudioMetricsDashboard() { + // System memory state + const [systemMemoryMB, setSystemMemoryMB] = useState(DEFAULT_SYSTEM_MEMORY_MB); + // Use WebSocket-based audio events for real-time updates const { audioMetrics, microphoneMetrics: wsMicrophoneMetrics, + audioProcessMetrics: wsAudioProcessMetrics, + microphoneProcessMetrics: wsMicrophoneProcessMetrics, isConnected: wsConnected } = useAudioEvents(); + + // Fetch system memory information on component mount + useEffect(() => { + const fetchSystemMemory = async () => { + try { + const response = await api.GET('/system/memory'); + const data = await response.json(); + setSystemMemoryMB(data.total_memory_mb); + } catch (error) { + console.warn('Failed to fetch system memory, using default:', error); + } + }; + fetchSystemMemory(); + }, []); + + // Update historical data when WebSocket process metrics are received + useEffect(() => { + if (wsConnected && wsAudioProcessMetrics && wsAudioProcessMetrics.running) { + const now = Math.floor(Date.now() / 1000); // Convert to seconds for StatChart + // Validate that now is a valid number + if (isNaN(now)) return; + + const cpuStat = isNaN(wsAudioProcessMetrics.cpu_percent) ? null : wsAudioProcessMetrics.cpu_percent; + + setAudioCpuStats(prev => { + const newMap = new Map(prev); + newMap.set(now, { cpu_percent: cpuStat }); + // Keep only last 120 seconds of data for memory management + const cutoff = now - 120; + for (const [key] of newMap) { + if (key < cutoff) newMap.delete(key); + } + return newMap; + }); + + setAudioMemoryStats(prev => { + const newMap = new Map(prev); + const memoryRss = isNaN(wsAudioProcessMetrics.memory_rss) ? null : wsAudioProcessMetrics.memory_rss; + newMap.set(now, { memory_rss: memoryRss }); + // Keep only last 120 seconds of data for memory management + const cutoff = now - 120; + for (const [key] of newMap) { + if (key < cutoff) newMap.delete(key); + } + return newMap; + }); + } + }, [wsConnected, wsAudioProcessMetrics]); + + useEffect(() => { + if (wsConnected && wsMicrophoneProcessMetrics) { + const now = Math.floor(Date.now() / 1000); // Convert to seconds for StatChart + // Validate that now is a valid number + if (isNaN(now)) return; + + const cpuStat = isNaN(wsMicrophoneProcessMetrics.cpu_percent) ? null : wsMicrophoneProcessMetrics.cpu_percent; + + setMicCpuStats(prev => { + const newMap = new Map(prev); + newMap.set(now, { cpu_percent: cpuStat }); + // Keep only last 120 seconds of data for memory management + const cutoff = now - 120; + for (const [key] of newMap) { + if (key < cutoff) newMap.delete(key); + } + return newMap; + }); + + setMicMemoryStats(prev => { + const newMap = new Map(prev); + const memoryRss = isNaN(wsMicrophoneProcessMetrics.memory_rss) ? null : wsMicrophoneProcessMetrics.memory_rss; + newMap.set(now, { memory_rss: memoryRss }); + // Keep only last 120 seconds of data for memory management + const cutoff = now - 120; + for (const [key] of newMap) { + if (key < cutoff) newMap.delete(key); + } + return newMap; + }); + } + }, [wsConnected, wsMicrophoneProcessMetrics]); // Fallback state for when WebSocket is not connected const [fallbackMetrics, setFallbackMetrics] = useState(null); const [fallbackMicrophoneMetrics, setFallbackMicrophoneMetrics] = useState(null); const [fallbackConnected, setFallbackConnected] = useState(false); - // Process metrics state - const [audioProcessMetrics, setAudioProcessMetrics] = useState(null); - const [microphoneProcessMetrics, setMicrophoneProcessMetrics] = useState(null); + // Process metrics state (fallback for when WebSocket is not connected) + const [fallbackAudioProcessMetrics, setFallbackAudioProcessMetrics] = useState(null); + const [fallbackMicrophoneProcessMetrics, setFallbackMicrophoneProcessMetrics] = useState(null); - // Historical data for histograms (last 60 data points, ~1 minute at 1s intervals) - const [audioCpuHistory, setAudioCpuHistory] = useState([]); - const [audioMemoryHistory, setAudioMemoryHistory] = useState([]); - const [micCpuHistory, setMicCpuHistory] = useState([]); - const [micMemoryHistory, setMicMemoryHistory] = useState([]); + // Historical data for charts using Maps for better memory management + const [audioCpuStats, setAudioCpuStats] = useState>(new Map()); + const [audioMemoryStats, setAudioMemoryStats] = useState>(new Map()); + const [micCpuStats, setMicCpuStats] = useState>(new Map()); + const [micMemoryStats, setMicMemoryStats] = useState>(new Map()); // Configuration state (these don't change frequently, so we can load them once) const [config, setConfig] = useState(null); @@ -81,6 +219,8 @@ export default function AudioMetricsDashboard() { // Use WebSocket data when available, fallback to polling data otherwise const metrics = wsConnected && audioMetrics !== null ? audioMetrics : fallbackMetrics; const microphoneMetrics = wsConnected && wsMicrophoneMetrics !== null ? wsMicrophoneMetrics : fallbackMicrophoneMetrics; + const audioProcessMetrics = wsConnected && wsAudioProcessMetrics !== null ? wsAudioProcessMetrics : fallbackAudioProcessMetrics; + const microphoneProcessMetrics = wsConnected && wsMicrophoneProcessMetrics !== null ? wsMicrophoneProcessMetrics : fallbackMicrophoneProcessMetrics; const isConnected = wsConnected ? wsConnected : fallbackConnected; // Microphone state for audio level monitoring @@ -147,17 +287,37 @@ export default function AudioMetricsDashboard() { const audioProcessResp = await api.GET("/audio/process-metrics"); if (audioProcessResp.ok) { const audioProcessData = await audioProcessResp.json(); - setAudioProcessMetrics(audioProcessData); + setFallbackAudioProcessMetrics(audioProcessData); - // Update historical data for histograms (keep last 60 points) + // Update historical data for charts (keep last 120 seconds) if (audioProcessData.running) { - setAudioCpuHistory(prev => { - const newHistory = [...prev, audioProcessData.cpu_percent]; - return newHistory.slice(-60); // Keep last 60 data points + const now = Math.floor(Date.now() / 1000); // Convert to seconds for StatChart + // Validate that now is a valid number + if (isNaN(now)) return; + + const cpuStat = isNaN(audioProcessData.cpu_percent) ? null : audioProcessData.cpu_percent; + const memoryRss = isNaN(audioProcessData.memory_rss) ? null : audioProcessData.memory_rss; + + setAudioCpuStats(prev => { + const newMap = new Map(prev); + newMap.set(now, { cpu_percent: cpuStat }); + // Keep only last 120 seconds of data for memory management + const cutoff = now - 120; + for (const [key] of newMap) { + if (key < cutoff) newMap.delete(key); + } + return newMap; }); - setAudioMemoryHistory(prev => { - const newHistory = [...prev, audioProcessData.memory_percent]; - return newHistory.slice(-60); + + setAudioMemoryStats(prev => { + const newMap = new Map(prev); + newMap.set(now, { memory_rss: memoryRss }); + // Keep only last 120 seconds of data for memory management + const cutoff = now - 120; + for (const [key] of newMap) { + if (key < cutoff) newMap.delete(key); + } + return newMap; }); } } @@ -182,19 +342,37 @@ export default function AudioMetricsDashboard() { const micProcessResp = await api.GET("/microphone/process-metrics"); if (micProcessResp.ok) { const micProcessData = await micProcessResp.json(); - setMicrophoneProcessMetrics(micProcessData); + setFallbackMicrophoneProcessMetrics(micProcessData); - // Update historical data for histograms (keep last 60 points) - if (micProcessData.running) { - setMicCpuHistory(prev => { - const newHistory = [...prev, micProcessData.cpu_percent]; - return newHistory.slice(-60); // Keep last 60 data points - }); - setMicMemoryHistory(prev => { - const newHistory = [...prev, micProcessData.memory_percent]; - return newHistory.slice(-60); - }); - } + // Update historical data for charts (keep last 120 seconds) + const now = Math.floor(Date.now() / 1000); // Convert to seconds for StatChart + // Validate that now is a valid number + if (isNaN(now)) return; + + const cpuStat = isNaN(micProcessData.cpu_percent) ? null : micProcessData.cpu_percent; + const memoryRss = isNaN(micProcessData.memory_rss) ? null : micProcessData.memory_rss; + + setMicCpuStats(prev => { + const newMap = new Map(prev); + newMap.set(now, { cpu_percent: cpuStat }); + // Keep only last 120 seconds of data for memory management + const cutoff = now - 120; + for (const [key] of newMap) { + if (key < cutoff) newMap.delete(key); + } + return newMap; + }); + + setMicMemoryStats(prev => { + const newMap = new Map(prev); + newMap.set(now, { memory_rss: memoryRss }); + // Keep only last 120 seconds of data for memory management + const cutoff = now - 120; + for (const [key] of newMap) { + if (key < cutoff) newMap.delete(key); + } + return newMap; + }); } } catch (micProcessError) { console.debug("Microphone process metrics not available:", micProcessError); @@ -222,15 +400,7 @@ export default function AudioMetricsDashboard() { return ((metrics.frames_dropped / metrics.frames_received) * 100); }; - const formatMemory = (bytes: number) => { - if (bytes === 0) return "0 MB"; - const mb = bytes / (1024 * 1024); - if (mb < 1024) { - return `${mb.toFixed(1)} MB`; - } - const gb = mb / 1024; - return `${gb.toFixed(2)} GB`; - }; + @@ -244,53 +414,6 @@ export default function AudioMetricsDashboard() { } }; - // Histogram component for displaying historical data - const Histogram = ({ data, title, unit, color }: { - data: number[], - title: string, - unit: string, - color: string - }) => { - if (data.length === 0) return null; - - const maxValue = Math.max(...data, 1); // Avoid division by zero - const minValue = Math.min(...data); - const range = maxValue - minValue; - - return ( -
-
- - {title} - - - {data.length > 0 ? `${data[data.length - 1].toFixed(1)}${unit}` : `0${unit}`} - -
-
- {data.slice(-30).map((value, index) => { // Show last 30 points - const height = range > 0 ? ((value - minValue) / range) * 100 : 0; - return ( -
- ); - })} -
-
- {minValue.toFixed(1)}{unit} - {maxValue.toFixed(1)}{unit} -
-
- ); - }; - return (
{/* Header */} @@ -405,30 +528,41 @@ export default function AudioMetricsDashboard() { )} />
- - +
+

CPU Usage

+
+ +
+
+
+

Memory Usage

+
+ ({ + date: item.date, + stat: item.stat ? item.stat / (1024 * 1024) : null // Convert bytes to MB + }))} + unit="MB" + domain={[0, systemMemoryMB]} + /> +
+
- {formatMemory(audioProcessMetrics.memory_rss)} + {formatPercentage(audioProcessMetrics.cpu_percent)}
-
RSS
+
CPU
- {formatMemory(audioProcessMetrics.memory_vms)} + {formatMemoryMB(audioProcessMetrics.memory_rss)}
-
VMS
+
Memory
@@ -449,30 +583,41 @@ export default function AudioMetricsDashboard() { )} />
- - +
+

CPU Usage

+
+ +
+
+
+

Memory Usage

+
+ ({ + date: item.date, + stat: item.stat ? item.stat / (1024 * 1024) : null // Convert bytes to MB + }))} + unit="MB" + domain={[0, systemMemoryMB]} + /> +
+
- {formatMemory(microphoneProcessMetrics.memory_rss)} + {formatPercentage(microphoneProcessMetrics.cpu_percent)}
-
RSS
+
CPU
- {formatMemory(microphoneProcessMetrics.memory_vms)} + {formatMemoryMB(microphoneProcessMetrics.memory_rss)}
-
VMS
+
Memory
diff --git a/ui/src/hooks/useAudioEvents.ts b/ui/src/hooks/useAudioEvents.ts index 6579448..c61ca1c 100644 --- a/ui/src/hooks/useAudioEvents.ts +++ b/ui/src/hooks/useAudioEvents.ts @@ -6,7 +6,9 @@ export type AudioEventType = | 'audio-mute-changed' | 'audio-metrics-update' | 'microphone-state-changed' - | 'microphone-metrics-update'; + | 'microphone-metrics-update' + | 'audio-process-metrics' + | 'microphone-process-metrics'; // Audio event data interfaces export interface AudioMuteData { @@ -36,10 +38,20 @@ export interface MicrophoneMetricsData { average_latency: string; } +export interface ProcessMetricsData { + pid: number; + cpu_percent: number; + memory_rss: number; + memory_vms: number; + memory_percent: number; + running: boolean; + process_name: string; +} + // Audio event structure export interface AudioEvent { type: AudioEventType; - data: AudioMuteData | AudioMetricsData | MicrophoneStateData | MicrophoneMetricsData; + data: AudioMuteData | AudioMetricsData | MicrophoneStateData | MicrophoneMetricsData | ProcessMetricsData; } // Hook return type @@ -56,6 +68,10 @@ export interface UseAudioEventsReturn { microphoneState: MicrophoneStateData | null; microphoneMetrics: MicrophoneMetricsData | null; + // Process metrics + audioProcessMetrics: ProcessMetricsData | null; + microphoneProcessMetrics: ProcessMetricsData | null; + // Manual subscription control subscribe: () => void; unsubscribe: () => void; @@ -74,6 +90,8 @@ export function useAudioEvents(): UseAudioEventsReturn { const [audioMetrics, setAudioMetrics] = useState(null); const [microphoneState, setMicrophoneState] = useState(null); const [microphoneMetrics, setMicrophoneMetricsData] = useState(null); + const [audioProcessMetrics, setAudioProcessMetrics] = useState(null); + const [microphoneProcessMetrics, setMicrophoneProcessMetrics] = useState(null); // Local subscription state const [isLocallySubscribed, setIsLocallySubscribed] = useState(false); @@ -214,6 +232,18 @@ export function useAudioEvents(): UseAudioEventsReturn { break; } + case 'audio-process-metrics': { + const audioProcessData = audioEvent.data as ProcessMetricsData; + setAudioProcessMetrics(audioProcessData); + break; + } + + case 'microphone-process-metrics': { + const micProcessData = audioEvent.data as ProcessMetricsData; + setMicrophoneProcessMetrics(micProcessData); + break; + } + default: // Ignore other message types (WebRTC signaling, etc.) break; @@ -275,6 +305,10 @@ export function useAudioEvents(): UseAudioEventsReturn { microphoneState, microphoneMetrics: microphoneMetrics, + // Process metrics + audioProcessMetrics, + microphoneProcessMetrics, + // Manual subscription control subscribe, unsubscribe, diff --git a/web.go b/web.go index 66ed27a..11bc633 100644 --- a/web.go +++ b/web.go @@ -503,6 +503,16 @@ func setupRouter() *gin.Engine { }) }) + // System memory information endpoint + protected.GET("/system/memory", func(c *gin.Context) { + processMonitor := audio.GetProcessMonitor() + totalMemory := processMonitor.GetTotalMemory() + c.JSON(200, gin.H{ + "total_memory_bytes": totalMemory, + "total_memory_mb": totalMemory / (1024 * 1024), + }) + }) + protected.POST("/microphone/reset", func(c *gin.Context) { if currentSession == nil { c.JSON(400, gin.H{"error": "no active session"})