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];
-}