use sexier audio waveform
This commit is contained in:
@@ -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 (
|
|
||||||
<div className="flex items-end gap-1.5">
|
|
||||||
{Array.from({ length: BAR_COUNT }, (_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
ref={(el) => {
|
|
||||||
barRefs.current[i] = el;
|
|
||||||
}}
|
|
||||||
className="w-1.5 rounded-full bg-green-400"
|
|
||||||
style={{ height: `${MIN_HEIGHT_PX}px` }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<HTMLCanvasElement | null>(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 (
|
||||||
|
<canvas ref={canvasRef} className={className} style={{ width, height }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Paperclip } from 'lucide-react';
|
import { Paperclip } from 'lucide-react';
|
||||||
import type { RecordingMode } from '@/hooks/use-recording-mode';
|
import type { RecordingMode } from '@/stores/media-settings-store';
|
||||||
import { AudioLevelBars } from '@/components/audio/audio-level-bars';
|
import { CenteredWaveform } from '@/components/audio/centered-waveform';
|
||||||
import { useAudioSource } from '@/components/audio/use-audio-source';
|
import { useAudioSource } from '@/components/audio/use-audio-source';
|
||||||
import { useObjectUrl } from '@/hooks/use-object-url';
|
import { useObjectUrl } from '@/hooks/use-object-url';
|
||||||
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
||||||
@@ -67,7 +67,6 @@ function ReviewPlayback({
|
|||||||
objectFit?: 'cover' | 'contain';
|
objectFit?: 'cover' | 'contain';
|
||||||
}) {
|
}) {
|
||||||
const objectUrl = useObjectUrl(blob);
|
const objectUrl = useObjectUrl(blob);
|
||||||
const audioElRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||||
const audioSource = useAudioSource(isVideo ? null : audioEl);
|
const audioSource = useAudioSource(isVideo ? null : audioEl);
|
||||||
|
|
||||||
@@ -87,17 +86,12 @@ function ReviewPlayback({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<audio
|
<audio ref={setAudioEl} src={objectUrl} autoPlay loop />
|
||||||
ref={(el) => {
|
|
||||||
audioElRef.current = el;
|
|
||||||
setAudioEl(el);
|
|
||||||
}}
|
|
||||||
src={objectUrl}
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
/>
|
|
||||||
{audioSource ? (
|
{audioSource ? (
|
||||||
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
<CenteredWaveform
|
||||||
|
sourceNode={audioSource.sourceNode}
|
||||||
|
className="text-white"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-white/60">Playing back audio...</span>
|
<span className="text-sm text-white/60">Playing back audio...</span>
|
||||||
)}
|
)}
|
||||||
@@ -198,10 +192,13 @@ export function RecordingOverlay({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom center: audio level bars (recording with active stream) */}
|
{/* Bottom center: live waveform (recording with active stream) */}
|
||||||
{isRecording && recordingAudioSource && (
|
{isRecording && recordingAudioSource && (
|
||||||
<div className="z-10 absolute bottom-15">
|
<div className="z-10 absolute bottom-15">
|
||||||
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
|
<CenteredWaveform
|
||||||
|
sourceNode={recordingAudioSource.sourceNode}
|
||||||
|
className="text-white"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
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_PREFERRED_MIME = 'video/webm;codecs=vp9,opus';
|
||||||
const VIDEO_FALLBACK_MIME = 'video/webm';
|
const VIDEO_FALLBACK_MIME = 'video/webm';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useDownloadUrl } from '@/hooks/use-download-url';
|
|||||||
import { useTranscriptPlayback } from '@/hooks/use-transcript-playback';
|
import { useTranscriptPlayback } from '@/hooks/use-transcript-playback';
|
||||||
import { TranscriptOverlay } from '@/features/particles/transcript-overlay';
|
import { TranscriptOverlay } from '@/features/particles/transcript-overlay';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
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 { useAudioSource } from '@/components/audio/use-audio-source';
|
||||||
import { useParticleAttachments } from '@/hooks/use-particle-attachments';
|
import { useParticleAttachments } from '@/hooks/use-particle-attachments';
|
||||||
import { ParticleAttachments } from '@/features/particles/particle-attachments';
|
import { ParticleAttachments } from '@/features/particles/particle-attachments';
|
||||||
@@ -89,7 +89,7 @@ export const MediaParticleView = forwardRef<
|
|||||||
currentTime,
|
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<HTMLAudioElement | null>(null);
|
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||||
const audioSource = useAudioSource(audioEl);
|
const audioSource = useAudioSource(audioEl);
|
||||||
|
|
||||||
@@ -156,7 +156,10 @@ export const MediaParticleView = forwardRef<
|
|||||||
|
|
||||||
{audioSource && (
|
{audioSource && (
|
||||||
<div className="z-10 absolute bottom-20">
|
<div className="z-10 absolute bottom-20">
|
||||||
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
<CenteredWaveform
|
||||||
|
sourceNode={audioSource.sourceNode}
|
||||||
|
className="text-white"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} 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 { useAudioSource } from '@/components/audio/use-audio-source';
|
||||||
import { useMediaDevices } from '@/hooks/use-media-devices';
|
import { useMediaDevices } from '@/hooks/use-media-devices';
|
||||||
import {
|
import {
|
||||||
@@ -105,20 +105,17 @@ function FieldLabel({ children }: { children: React.ReactNode }) {
|
|||||||
function InlineLevelMeter({ stream }: { stream: MediaStream | null }) {
|
function InlineLevelMeter({ stream }: { stream: MediaStream | null }) {
|
||||||
const audioSource = useAudioSource(stream);
|
const audioSource = useAudioSource(stream);
|
||||||
if (!audioSource) {
|
if (!audioSource) {
|
||||||
return (
|
return <div className="bg-muted h-3 w-20 rounded-sm opacity-50" />;
|
||||||
<div className="flex h-3 items-end gap-1">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<div key={i} className="bg-muted h-1 w-1 rounded-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex h-3 items-end">
|
<CenteredWaveform
|
||||||
<div className="scale-[0.55] origin-right">
|
sourceNode={audioSource.sourceNode}
|
||||||
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
width={80}
|
||||||
</div>
|
height={12}
|
||||||
</div>
|
barWidth={2}
|
||||||
|
gap={3}
|
||||||
|
className="text-primary"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<RecordingMode>(() => {
|
|
||||||
const stored = localStorage.getItem(KEY);
|
|
||||||
return stored === 'audio' ? 'audio' : 'video';
|
|
||||||
});
|
|
||||||
|
|
||||||
const setMode = useCallback((m: RecordingMode) => {
|
|
||||||
localStorage.setItem(KEY, m);
|
|
||||||
setModeState(m);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return [mode, setMode];
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user