From 8ed8e2cccb5eba45f0f23d0d00a93adbdb0d19b4 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 09:55:04 -0700 Subject: [PATCH] use sexier audio waveform --- .../src/components/audio/audio-level-bars.tsx | 122 ---------- .../components/audio/centered-waveform.tsx | 219 ++++++++++++++++++ .../features/compose/recording-overlay.tsx | 27 +-- .../src/features/compose/use-recorder.ts | 2 +- .../particles/media-particle-view.tsx | 9 +- .../settings/audio-video-settings-page.tsx | 23 +- js/desktop/src/hooks/use-recording-mode.ts | 22 -- 7 files changed, 248 insertions(+), 176 deletions(-) delete mode 100644 js/desktop/src/components/audio/audio-level-bars.tsx create mode 100644 js/desktop/src/components/audio/centered-waveform.tsx delete mode 100644 js/desktop/src/hooks/use-recording-mode.ts diff --git a/js/desktop/src/components/audio/audio-level-bars.tsx b/js/desktop/src/components/audio/audio-level-bars.tsx deleted file mode 100644 index b5a7881..0000000 --- a/js/desktop/src/components/audio/audio-level-bars.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useEffect, useRef } from 'react'; - -interface AudioLevelBarsProps { - sourceNode: AudioNode; -} - -const BAR_COUNT = 3; -const MIN_HEIGHT_PX = 6; -const MAX_HEIGHT_PX = 48; - -// dB scale -const NOISE_FLOOR_DB = -50; -const DB_RANGE = -NOISE_FLOOR_DB; // 50dB dynamic range - -// Asymmetric smoothing time constants -const ATTACK_MS = 30; -const RELEASE_MS = 300; - -// Bar activation thresholds on the 0..1 normalized dB scale -const BAR_THRESHOLDS = [0.0, 0.15, 0.35]; - -/** - * 3-bar VU meter that visualizes audio levels from any AudioNode source. - * Works with both live MediaStream sources and MediaElement sources. - * - * Uses direct DOM manipulation with exponential smoothing on a dB scale - * for smooth, jitter-free animation independent of frame rate. - */ -export function AudioLevelBars({ sourceNode }: AudioLevelBarsProps) { - const barRefs = useRef<(HTMLDivElement | null)[]>([]); - - useEffect(() => { - const ctx = sourceNode.context as AudioContext; - const analyser = ctx.createAnalyser(); - analyser.fftSize = 256; - sourceNode.connect(analyser); - - // Connect to destination via silent gain node — without this, - // Chromium suspends processing on disconnected audio graphs. - const silentGain = ctx.createGain(); - silentGain.gain.value = 0; - analyser.connect(silentGain); - silentGain.connect(ctx.destination); - - const dataArray = new Uint8Array(analyser.frequencyBinCount); - - let smoothedLevel = 0; - let lastTime = performance.now(); - let rafId = 0; - - function tick() { - const now = performance.now(); - const dt = now - lastTime; - lastTime = now; - - analyser.getByteTimeDomainData(dataArray); - - // Compute RMS of waveform (128 = silence baseline) - let sumSquares = 0; - for (let i = 0; i < dataArray.length; i++) { - const normalized = (dataArray[i] - 128) / 128; - sumSquares += normalized * normalized; - } - const rms = Math.sqrt(sumSquares / dataArray.length); - - // Convert to dB, clamp to noise floor, normalize to 0..1 - const db = rms > 0 ? 20 * Math.log10(rms) : NOISE_FLOOR_DB; - const normalizedDb = Math.max(0, (db - NOISE_FLOOR_DB) / DB_RANGE); - - // Asymmetric exponential smoothing (frame-rate independent) - const timeConstant = - normalizedDb > smoothedLevel ? ATTACK_MS : RELEASE_MS; - const alpha = 1 - Math.exp(-dt / timeConstant); - smoothedLevel += alpha * (normalizedDb - smoothedLevel); - - // Update bar heights via direct DOM writes - for (let i = 0; i < BAR_COUNT; i++) { - const el = barRefs.current[i]; - if (!el) continue; - - const threshold = BAR_THRESHOLDS[i]; - const barLevel = - smoothedLevel <= threshold - ? 0 - : Math.min(1, (smoothedLevel - threshold) / (1 - threshold)); - const height = - MIN_HEIGHT_PX + barLevel * (MAX_HEIGHT_PX - MIN_HEIGHT_PX); - el.style.height = `${height}px`; - } - - rafId = requestAnimationFrame(tick); - } - - rafId = requestAnimationFrame(tick); - - return () => { - cancelAnimationFrame(rafId); - try { - sourceNode.disconnect(analyser); - analyser.disconnect(silentGain); - silentGain.disconnect(ctx.destination); - } catch { - // Nodes may already be disconnected - } - }; - }, [sourceNode]); - - return ( -
- {Array.from({ length: BAR_COUNT }, (_, i) => ( -
{ - barRefs.current[i] = el; - }} - className="w-1.5 rounded-full bg-green-400" - style={{ height: `${MIN_HEIGHT_PX}px` }} - /> - ))} -
- ); -} diff --git a/js/desktop/src/components/audio/centered-waveform.tsx b/js/desktop/src/components/audio/centered-waveform.tsx new file mode 100644 index 0000000..5cda97c --- /dev/null +++ b/js/desktop/src/components/audio/centered-waveform.tsx @@ -0,0 +1,219 @@ +import { useEffect, useRef } from 'react'; + +interface CenteredWaveformProps { + sourceNode: AudioNode; + /** Canvas size in CSS pixels. */ + width?: number; + height?: number; + barWidth?: number; + gap?: number; + /** Bars are drawn with currentColor — set a text-* class to color them. */ + className?: string; +} + +// Voice band fanned out from the center bar (lows) to the edges (highs). +const MIN_FREQ_HZ = 100; +const MAX_FREQ_HZ = 4500; + +// Analyser dB range mapped onto bar height; the defaults (-100/-30) waste +// most of the range on inaudible levels. +const MIN_DB = -75; +const MAX_DB = -25; + +// Per-bar asymmetric smoothing time constants. +const ATTACK_MS = 40; +const RELEASE_MS = 220; + +// Edge bars keep a fraction of their response so the whole row stays alive +// instead of only the middle moving. +const EDGE_RESPONSE = 0.3; + +/** + * Centered live voice indicator, like a meeting app's "speaking" glyph. + * Bars fan out symmetrically from the middle: the center tracks the low + * frequencies where voice energy lives, the edges track the highs, and a + * cosine envelope tapers the response so the shape blooms from the center + * while someone speaks and settles into a dot line in silence. + * + * Renders on canvas with an rAF loop and no React state. No reduced-motion + * branch: the animation is signal-bearing audio feedback, not decoration, + * and only runs while audio is being captured or played. + */ +export function CenteredWaveform({ + sourceNode, + width = 168, + height = 56, + barWidth = 4, + gap = 4, + className, +}: CenteredWaveformProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvasEl = canvasRef.current; + const ctx2d = canvasEl?.getContext('2d'); + if (!canvasEl || !ctx2d) return; + // Rebind post-guard so the narrowed types carry into the closures below. + const canvas = canvasEl; + const drawCtx = ctx2d; + + const { analyser, detach } = attachAnalyser(sourceNode, 512); + analyser.smoothingTimeConstant = 0.8; + analyser.minDecibels = MIN_DB; + analyser.maxDecibels = MAX_DB; + + const bins = new Uint8Array(analyser.frequencyBinCount); + + const stride = barWidth + gap; + // Odd count so one bar sits exactly at the center. + let barCount = Math.floor((width + gap) / stride); + if (barCount % 2 === 0) barCount -= 1; + const half = (barCount - 1) / 2; + + // Map bar k (0 = center) to a frequency bin, log-spaced so the busy low + // end of the voice spectrum spreads across several bars. + const binHz = sourceNode.context.sampleRate / analyser.fftSize; + const binIndex = new Uint16Array(half + 1); + for (let k = 0; k <= half; k++) { + const freq = + MIN_FREQ_HZ * + Math.pow(MAX_FREQ_HZ / MIN_FREQ_HZ, half === 0 ? 0 : k / half); + binIndex[k] = Math.min(bins.length - 1, Math.round(freq / binHz)); + } + + const levels = new Float32Array(half + 1); + + const ensureBackingStore = createBackingStoreScaler( + canvas, + drawCtx, + width, + height, + ); + // currentColor, cached and refreshed periodically (theme switches show + // within 200ms) instead of paying a computed-style read every frame. + let fillColor = ''; + let lastColorTime = 0; + let lastTime = performance.now(); + let rafId = 0; + + function tick() { + const now = performance.now(); + const dt = now - lastTime; + lastTime = now; + + analyser.getByteFrequencyData(bins); + + for (let k = 0; k <= half; k++) { + const raw = bins[binIndex[k]] / 255; + const envelope = + half === 0 + ? 1 + : EDGE_RESPONSE + + (1 - EDGE_RESPONSE) * Math.cos(((k / half) * Math.PI) / 2); + // Gamma keeps the floor quiet so silence reads as a dot line. + const target = Math.pow(raw, 1.4) * envelope; + + // Asymmetric exponential smoothing (frame-rate independent) + const timeConstant = target > levels[k] ? ATTACK_MS : RELEASE_MS; + const alpha = 1 - Math.exp(-dt / timeConstant); + levels[k] += alpha * (target - levels[k]); + } + + if (ensureBackingStore() || !fillColor || now - lastColorTime > 200) { + fillColor = getComputedStyle(canvas).color; + lastColorTime = now; + } + drawCtx.clearRect(0, 0, width, height); + drawCtx.fillStyle = fillColor; + + const centerX = width / 2 - barWidth / 2; + drawCtx.beginPath(); + for (let k = 0; k <= half; k++) { + const h = Math.max(barWidth, levels[k] * height); + const y = (height - h) / 2; + drawCtx.roundRect(centerX + k * stride, y, barWidth, h, barWidth / 2); + if (k > 0) { + drawCtx.roundRect( + centerX - k * stride, + y, + barWidth, + h, + barWidth / 2, + ); + } + } + drawCtx.fill(); + + rafId = requestAnimationFrame(tick); + } + + rafId = requestAnimationFrame(tick); + + return () => { + cancelAnimationFrame(rafId); + detach(); + }; + }, [sourceNode, width, height, barWidth, gap]); + + return ( + + ); +} + +/** + * Connects an AnalyserNode to the source for visualization. + * + * The analyser is routed to the destination via a silent gain node — without + * this, Chromium suspends processing on disconnected audio graphs. + */ +function attachAnalyser( + sourceNode: AudioNode, + fftSize: number, +): { analyser: AnalyserNode; detach: () => void } { + const ctx = sourceNode.context; + const analyser = ctx.createAnalyser(); + analyser.fftSize = fftSize; + sourceNode.connect(analyser); + + const silentGain = ctx.createGain(); + silentGain.gain.value = 0; + analyser.connect(silentGain); + silentGain.connect(ctx.destination); + + return { + analyser, + detach() { + try { + sourceNode.disconnect(analyser); + analyser.disconnect(silentGain); + silentGain.disconnect(ctx.destination); + } catch { + // Nodes may already be disconnected + } + }, + }; +} + +/** + * Keeps the canvas backing store sized to width × height CSS pixels at the + * current devicePixelRatio, rescaling if the DPR changes (window moved + * between monitors). Call the returned function each frame; it returns true + * when the store was (re)initialized so callers can refresh cached state. + */ +function createBackingStoreScaler( + canvas: HTMLCanvasElement, + drawCtx: CanvasRenderingContext2D, + width: number, + height: number, +): () => boolean { + let dpr = 0; + return () => { + const currentDpr = window.devicePixelRatio || 1; + if (currentDpr === dpr) return false; + dpr = currentDpr; + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + drawCtx.setTransform(dpr, 0, 0, dpr, 0, 0); + return true; + }; +} diff --git a/js/desktop/src/features/compose/recording-overlay.tsx b/js/desktop/src/features/compose/recording-overlay.tsx index d720b1f..135404a 100644 --- a/js/desktop/src/features/compose/recording-overlay.tsx +++ b/js/desktop/src/features/compose/recording-overlay.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { Paperclip } from 'lucide-react'; -import type { RecordingMode } from '@/hooks/use-recording-mode'; -import { AudioLevelBars } from '@/components/audio/audio-level-bars'; +import type { RecordingMode } from '@/stores/media-settings-store'; +import { CenteredWaveform } from '@/components/audio/centered-waveform'; import { useAudioSource } from '@/components/audio/use-audio-source'; import { useObjectUrl } from '@/hooks/use-object-url'; import { AttachmentStrip } from '@/features/compose/attachment-strip'; @@ -67,7 +67,6 @@ function ReviewPlayback({ objectFit?: 'cover' | 'contain'; }) { const objectUrl = useObjectUrl(blob); - const audioElRef = useRef(null); const [audioEl, setAudioEl] = useState(null); const audioSource = useAudioSource(isVideo ? null : audioEl); @@ -87,17 +86,12 @@ function ReviewPlayback({ return (
-
- {/* Bottom center: audio level bars (recording with active stream) */} + {/* Bottom center: live waveform (recording with active stream) */} {isRecording && recordingAudioSource && (
- +
)} diff --git a/js/desktop/src/features/compose/use-recorder.ts b/js/desktop/src/features/compose/use-recorder.ts index 2813e0a..b59ae46 100644 --- a/js/desktop/src/features/compose/use-recorder.ts +++ b/js/desktop/src/features/compose/use-recorder.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from 'react'; -import type { RecordingMode } from '@/hooks/use-recording-mode'; +import type { RecordingMode } from '@/stores/media-settings-store'; const VIDEO_PREFERRED_MIME = 'video/webm;codecs=vp9,opus'; const VIDEO_FALLBACK_MIME = 'video/webm'; diff --git a/js/desktop/src/features/particles/media-particle-view.tsx b/js/desktop/src/features/particles/media-particle-view.tsx index ad58c98..65fc725 100644 --- a/js/desktop/src/features/particles/media-particle-view.tsx +++ b/js/desktop/src/features/particles/media-particle-view.tsx @@ -11,7 +11,7 @@ import { useDownloadUrl } from '@/hooks/use-download-url'; import { useTranscriptPlayback } from '@/hooks/use-transcript-playback'; import { TranscriptOverlay } from '@/features/particles/transcript-overlay'; import { Skeleton } from '@/components/ui/skeleton'; -import { AudioLevelBars } from '@/components/audio/audio-level-bars'; +import { CenteredWaveform } from '@/components/audio/centered-waveform'; import { useAudioSource } from '@/components/audio/use-audio-source'; import { useParticleAttachments } from '@/hooks/use-particle-attachments'; import { ParticleAttachments } from '@/features/particles/particle-attachments'; @@ -89,7 +89,7 @@ export const MediaParticleView = forwardRef< currentTime, ); - // WORKAROUND: useAudioSource needs an audio element, but audioRef is only set after mount + // useAudioSource needs the audio element, but audioRef is only set after mount const [audioEl, setAudioEl] = useState(null); const audioSource = useAudioSource(audioEl); @@ -156,7 +156,10 @@ export const MediaParticleView = forwardRef< {audioSource && (
- +
)} diff --git a/js/desktop/src/features/settings/audio-video-settings-page.tsx b/js/desktop/src/features/settings/audio-video-settings-page.tsx index 930e4c7..84dea41 100644 --- a/js/desktop/src/features/settings/audio-video-settings-page.tsx +++ b/js/desktop/src/features/settings/audio-video-settings-page.tsx @@ -12,7 +12,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { AudioLevelBars } from '@/components/audio/audio-level-bars'; +import { CenteredWaveform } from '@/components/audio/centered-waveform'; import { useAudioSource } from '@/components/audio/use-audio-source'; import { useMediaDevices } from '@/hooks/use-media-devices'; import { @@ -105,20 +105,17 @@ function FieldLabel({ children }: { children: React.ReactNode }) { function InlineLevelMeter({ stream }: { stream: MediaStream | null }) { const audioSource = useAudioSource(stream); if (!audioSource) { - return ( -
- {[0, 1, 2].map((i) => ( -
- ))} -
- ); + return
; } return ( -
-
- -
-
+ ); } diff --git a/js/desktop/src/hooks/use-recording-mode.ts b/js/desktop/src/hooks/use-recording-mode.ts deleted file mode 100644 index 5daa54c..0000000 --- a/js/desktop/src/hooks/use-recording-mode.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useState, useCallback } from 'react'; - -export type RecordingMode = 'video' | 'audio'; - -const KEY = 'llink:recording-mode'; - -export function useRecordingMode(): [ - RecordingMode, - (mode: RecordingMode) => void, -] { - const [mode, setModeState] = useState(() => { - const stored = localStorage.getItem(KEY); - return stored === 'audio' ? 'audio' : 'video'; - }); - - const setMode = useCallback((m: RecordingMode) => { - localStorage.setItem(KEY, m); - setModeState(m); - }, []); - - return [mode, setMode]; -}