diff --git a/src/main/index.ts b/src/main/index.ts index c45876e..314360d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,6 +22,7 @@ import type { ThemeLibrarySnapshot, } from '../types/theme' import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize' +import type { PerformanceMemorySnapshot } from '../types/performance' import { normalizeProfile } from '../shared/profileState' import { calculateResizedWindowBounds } from '../shared/windowResize' import { FileBackedProfileLibrary } from './profileLibrary' @@ -70,6 +71,25 @@ const POPOUT_DEFAULTS = { minHeight: 160, } +function kilobytesToMegabytes(value: number | undefined): number { + return Math.round((((value ?? 0) / 1024) * 10)) / 10 +} + +function workingSetForMetric(metric: Electron.ProcessMetric | undefined): number { + return kilobytesToMegabytes(metric?.memory.workingSetSize) +} + +function sumWorkingSet(metrics: Electron.ProcessMetric[], predicate: (metric: Electron.ProcessMetric) => boolean): number { + let totalKilobytes = 0 + for (const metric of metrics) { + if (!predicate(metric)) { + continue + } + totalKilobytes += metric.memory.workingSetSize + } + return kilobytesToMegabytes(totalKilobytes) +} + function getProfileLibrary(): FileBackedProfileLibrary { if (!profileLibrary) { profileLibrary = new FileBackedProfileLibrary( @@ -903,6 +923,25 @@ function setupIPC(): void { return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? true }) + ipcMain.handle('performance:get-memory-snapshot', async (event): Promise => { + const metrics = app.getAppMetrics() + const senderPid = event.sender.getOSProcessId() + const browserMetric = metrics.find((metric) => metric.type === 'Browser') + const rendererMetric = metrics.find((metric) => metric.pid === senderPid) + + return { + capturedAt: Date.now(), + appMb: sumWorkingSet(metrics, () => true), + mainMb: workingSetForMetric(browserMetric), + rendererMb: workingSetForMetric(rendererMetric), + rendererPrivateMb: null, + gpuMb: sumWorkingSet(metrics, (metric) => metric.type === 'GPU'), + utilityMb: sumWorkingSet(metrics, (metric) => metric.type === 'Utility'), + jsHeapUsedMb: null, + jsHeapLimitMb: null, + } + }) + ipcMain.handle('audio:get-desktop-sources', async () => { const sources = await desktopCapturer.getSources({ types: ['screen'] }) return sources.map((s) => ({ id: s.id, name: s.name })) diff --git a/src/preload/index.ts b/src/preload/index.ts index a3d6620..64b7be6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -28,9 +28,14 @@ import type { } from '../types/theme' import type { ResizeDirection } from '../types/windowResize' import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' +import type { PerformanceMemorySnapshot } from '../types/performance' type NativeAddonModule = VisualizerDSP & NativeCaptureAPI +function kilobytesToMegabytes(value: number | undefined): number { + return Math.round((((value ?? 0) / 1024) * 10)) / 10 +} + // Expose Electron API to renderer contextBridge.exposeInMainWorld('electronAPI', { platform: process.platform, @@ -45,6 +50,18 @@ contextBridge.exposeInMainWorld('electronAPI', { repositionWindow: (position: 'top' | 'bottom') => ipcRenderer.send('window:reposition', position), toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'), isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'), + getPerformanceMemorySnapshot: async () => { + const snapshot = await ipcRenderer.invoke('performance:get-memory-snapshot') as PerformanceMemorySnapshot + try { + const rendererMemory = await process.getProcessMemoryInfo() + return { + ...snapshot, + rendererPrivateMb: kilobytesToMegabytes(rendererMemory.private), + } satisfies PerformanceMemorySnapshot + } catch { + return snapshot + } + }, getDesktopSources: () => ipcRenderer.invoke('audio:get-desktop-sources') as Promise<{ id: string; name: string }[]>, getCaptureBackendSupport: async () => { const support = await ipcRenderer.invoke('capture:get-backend-support') as CaptureBackendSupport diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f0fdcd6..1c4fa1d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -8,6 +8,7 @@ import WindowResizeOverlay from './components/WindowResizeOverlay' import { useSettingsStore } from './stores/settingsStore' import { useAstraStore } from './stores/astraStore' import { useAudioStore } from './stores/audioStore' +import { usePerformanceStore } from './stores/performanceStore' import { useThemeStore } from './stores/themeStore' import { SCOPE_KINDS } from '../types/scope' @@ -27,6 +28,8 @@ export default function App(): JSX.Element { const guardProfileTransition = useSettingsStore((s) => s.guardProfileTransition) const importProfileFromPath = useSettingsStore((s) => s.importProfileFromPath) const updateMainWindowBounds = useSettingsStore((s) => s.updateMainWindowBounds) + const startMemoryMonitoring = usePerformanceStore((s) => s.startMemoryMonitoring) + const stopMemoryMonitoring = usePerformanceStore((s) => s.stopMemoryMonitoring) const initializeThemes = useThemeStore((s) => s.initializeThemes) const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot) const initializeAstra = useAstraStore((s) => s.initialize) @@ -102,6 +105,13 @@ export default function App(): JSX.Element { updateMainWindowBounds, ]) + useEffect(() => { + startMemoryMonitoring() + return () => { + stopMemoryMonitoring() + } + }, [startMemoryMonitoring, stopMemoryMonitoring]) + const measuredSettingsHeight = settingsPanelHeight > 0 && bottomBarHeight > 0 ? settingsPanelHeight + bottomBarHeight : DEFAULT_SETTINGS_HEIGHT diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 6892ea3..27d7561 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -46,6 +46,9 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const toggleScope = useSettingsStore((s) => s.toggleScope) const frameTarget = usePerformanceStore((s) => s.frameTarget) const dockedRenderFps = usePerformanceStore((s) => s.dockedRenderFps) + const memorySample = usePerformanceStore((s) => s.memorySample) + const rendererMemoryDeltaMb = usePerformanceStore((s) => s.rendererMemoryDeltaMb) + const appMemoryDeltaMb = usePerformanceStore((s) => s.appMemoryDeltaMb) const setFrameTarget = usePerformanceStore((s) => s.setFrameTarget) const themeId = useSettingsStore((s) => s.themeId) const setThemeId = useSettingsStore((s) => s.setThemeId) @@ -155,6 +158,9 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const trimPercent = Math.min(100, Math.max(0, ((inputGainDb + 12) / 24) * 100)) const roundedDockedRenderFps = Math.max(0, Math.round(dockedRenderFps)) const themeEntries = Object.entries(themes) + const rendererMemoryMb = memorySample?.rendererPrivateMb ?? memorySample?.rendererMb ?? 0 + const jsHeapUsedMb = memorySample?.jsHeapUsedMb ?? null + const jsHeapLimitMb = memorySample?.jsHeapLimitMb ?? null const handleThemeChange = async (value: string): Promise => { await loadTheme(value) @@ -435,6 +441,28 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): {roundedDockedRenderFps} FPS + + {memorySample ? ( +
+
+ App {memorySample.appMb.toFixed(1)} MB +
+
+ Renderer {rendererMemoryMb.toFixed(1)} MB +
+
+ ΔR {rendererMemoryDeltaMb >= 0 ? '+' : ''}{rendererMemoryDeltaMb.toFixed(1)} MB +
+
+ ΔA {appMemoryDeltaMb >= 0 ? '+' : ''}{appMemoryDeltaMb.toFixed(1)} MB +
+ {jsHeapUsedMb !== null ? ( +
+ JS {jsHeapUsedMb.toFixed(1)} MB +
+ ) : null} +
+ ) : null} diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index a1b9465..fbcebaa 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -29,6 +29,7 @@ import type { ThemeLibrarySnapshot, } from '../types/theme' import type { ResizeDirection } from '../types/windowResize' +import type { PerformanceMemorySnapshot } from '../types/performance' declare global { interface Window { @@ -47,6 +48,7 @@ declare global { repositionWindow: (position: 'top' | 'bottom') => void toggleAlwaysOnTop: () => void isAlwaysOnTop: () => Promise + getPerformanceMemorySnapshot: () => Promise getDesktopSources: () => Promise<{ id: string; name: string }[]> getCaptureBackendSupport: () => Promise getAstraConfig: () => Promise diff --git a/src/renderer/stores/performanceStore.ts b/src/renderer/stores/performanceStore.ts index 92cb727..ee27ce7 100644 --- a/src/renderer/stores/performanceStore.ts +++ b/src/renderer/stores/performanceStore.ts @@ -1,5 +1,9 @@ import { create } from 'zustand' -import { isVisualizerFrameTarget, type VisualizerFrameTarget } from '../../types/performance' +import { + isVisualizerFrameTarget, + type PerformanceMemorySnapshot, + type VisualizerFrameTarget, +} from '../../types/performance' const STORAGE_KEY = 'prism:performance' const SYNC_CHANNEL_NAME = 'prism:performance' @@ -11,8 +15,14 @@ interface PersistedPerformanceState { interface PerformanceState { frameTarget: VisualizerFrameTarget dockedRenderFps: number + memorySample: PerformanceMemorySnapshot | null + memoryHistory: PerformanceMemorySnapshot[] + rendererMemoryDeltaMb: number + appMemoryDeltaMb: number setFrameTarget: (target: VisualizerFrameTarget) => void setDockedRenderFps: (fps: number) => void + startMemoryMonitoring: () => void + stopMemoryMonitoring: () => void } interface StorageLike { @@ -66,10 +76,110 @@ function persistPerformancePreferences(target: VisualizerFrameTarget, storage = } const storedPreferences = loadPerformancePreferences() +const MEMORY_SAMPLE_INTERVAL_MS = 5000 +const MEMORY_HISTORY_LIMIT = 720 + +interface ChromiumPerformanceMemory { + jsHeapSizeLimit: number + totalJSHeapSize: number + usedJSHeapSize: number +} + +let memoryMonitorTimer: ReturnType | null = null +let memoryMonitorRefCount = 0 +let memoryMonitorInFlight = false + +function isMainWindowContext(): boolean { + if (typeof window === 'undefined') { + return false + } + + const params = new URLSearchParams(window.location.search) + return params.get('window') !== 'scope-popout' +} + +function bytesToMegabytes(value: number | undefined): number { + return Math.round((((value ?? 0) / (1024 * 1024)) * 10)) / 10 +} + +async function collectMemorySnapshot(): Promise { + if (typeof window === 'undefined' || typeof window.electronAPI?.getPerformanceMemorySnapshot !== 'function') { + return null + } + + const snapshot = await window.electronAPI.getPerformanceMemorySnapshot() + const performanceWithMemory = performance as Performance & { memory?: ChromiumPerformanceMemory } + const heap = performanceWithMemory.memory + return { + ...snapshot, + jsHeapUsedMb: heap ? bytesToMegabytes(heap.usedJSHeapSize) : null, + jsHeapLimitMb: heap ? bytesToMegabytes(heap.jsHeapSizeLimit) : null, + } +} + +async function sampleMemory(): Promise { + if (memoryMonitorInFlight) { + return + } + + memoryMonitorInFlight = true + try { + const snapshot = await collectMemorySnapshot() + if (!snapshot) { + return + } + + usePerformanceStore.setState((state) => { + const baseline = state.memoryHistory[0] ?? snapshot + const nextHistory = [...state.memoryHistory, snapshot] + if (nextHistory.length > MEMORY_HISTORY_LIMIT) { + nextHistory.splice(0, nextHistory.length - MEMORY_HISTORY_LIMIT) + } + + const baselineRendererMb = baseline.rendererPrivateMb ?? baseline.rendererMb + const currentRendererMb = snapshot.rendererPrivateMb ?? snapshot.rendererMb + + return { + memorySample: snapshot, + memoryHistory: nextHistory, + rendererMemoryDeltaMb: Math.round((currentRendererMb - baselineRendererMb) * 10) / 10, + appMemoryDeltaMb: Math.round((snapshot.appMb - baseline.appMb) * 10) / 10, + } + }) + } finally { + memoryMonitorInFlight = false + } +} + +function ensureMemoryMonitor(): void { + if (memoryMonitorTimer || !isMainWindowContext()) { + return + } + + void sampleMemory() + memoryMonitorTimer = setInterval(() => { + void sampleMemory() + }, MEMORY_SAMPLE_INTERVAL_MS) +} + +function releaseMemoryMonitor(): void { + if (memoryMonitorRefCount > 0) { + return + } + + if (memoryMonitorTimer) { + clearInterval(memoryMonitorTimer) + memoryMonitorTimer = null + } +} export const usePerformanceStore = create((set) => ({ frameTarget: storedPreferences.frameTarget, dockedRenderFps: 0, + memorySample: null, + memoryHistory: [], + rendererMemoryDeltaMb: 0, + appMemoryDeltaMb: 0, setFrameTarget: (target: VisualizerFrameTarget) => { persistPerformancePreferences(target) @@ -87,6 +197,16 @@ export const usePerformanceStore = create((set) => ({ return { ...state, dockedRenderFps: nextFps } }) }, + + startMemoryMonitoring: () => { + memoryMonitorRefCount += 1 + ensureMemoryMonitor() + }, + + stopMemoryMonitoring: () => { + memoryMonitorRefCount = Math.max(0, memoryMonitorRefCount - 1) + releaseMemoryMonitor() + }, })) let syncChannel: BroadcastChannel | null = null diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 59297cd..a60262c 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -1414,6 +1414,12 @@ select { gap: 10px; } +.bottom-bar__inline--memory { + gap: 6px; + flex-wrap: wrap; + white-space: normal; +} + .bottom-bar__divider { width: 1px; align-self: stretch; @@ -1483,6 +1489,13 @@ select { letter-spacing: 0.08em; } +.bottom-bar__memory-pill { + justify-content: center; + color: var(--text-primary); + font-family: 'JetBrains Mono', monospace; + letter-spacing: 0.06em; +} + .bottom-bar__error-text { margin-top: 0; max-width: 360px; diff --git a/src/types/performance.ts b/src/types/performance.ts index 3d85d18..9001fd7 100644 --- a/src/types/performance.ts +++ b/src/types/performance.ts @@ -2,6 +2,18 @@ export const VISUALIZER_FRAME_TARGETS = [10, 30, 60, 120, 144, 'display-sync'] a export type VisualizerFrameTarget = typeof VISUALIZER_FRAME_TARGETS[number] +export interface PerformanceMemorySnapshot { + capturedAt: number + appMb: number + mainMb: number + rendererMb: number + rendererPrivateMb: number | null + gpuMb: number + utilityMb: number + jsHeapUsedMb: number | null + jsHeapLimitMb: number | null +} + export function isVisualizerFrameTarget(value: unknown): value is VisualizerFrameTarget { return VISUALIZER_FRAME_TARGETS.includes(value as VisualizerFrameTarget) }