mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -07:00
- Audio player: aria-labels for Play/Pause, Loop, Mute, Close; labelled playback and volume sliders - Generation: aria-labels for Generate speech and Fine tune instructions buttons - Voice cards: focusable, labelled, Enter/Space to select - History rows: focusable, labelled, Enter/Space to play; transcript textarea labelled - Voices tab: focusable rows, labelled, Enter/Space to edit; Actions button labelled - Model management: focusable model rows and labelled Download/Delete buttons - Server tab: regions with aria-label and tabIndex for Connection, Status, App Updates - Stories: focusable story rows, labelled, Enter/Space to select; Actions and track editor buttons labelled - Voice profile samples: Play/Pause/Stop and mini-player slider labelled Tested with NVDA and Narrator on Windows. See docs/PR-ACCESSIBILITY.md for full description. Made-with: Cursor
179 lines
5.8 KiB
TypeScript
179 lines
5.8 KiB
TypeScript
import { Mic, Pause, Play, Square } from 'lucide-react';
|
|
import { memo, useEffect, useState } from 'react';
|
|
import { Visualizer } from 'react-sound-visualizer';
|
|
import { Button } from '@/components/ui/button';
|
|
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
|
|
|
const MemoizedWaveform = memo(function MemoizedWaveform({
|
|
audioStream,
|
|
}: {
|
|
audioStream: MediaStream;
|
|
}) {
|
|
return (
|
|
<div className="absolute inset-0 pointer-events-none flex items-center justify-center opacity-30">
|
|
<Visualizer audio={audioStream} autoStart strokeColor="#b39a3d">
|
|
{({ canvasRef }) => (
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={500}
|
|
height={150}
|
|
className="w-full h-full"
|
|
/>
|
|
)}
|
|
</Visualizer>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
interface AudioSampleRecordingProps {
|
|
file: File | null | undefined;
|
|
isRecording: boolean;
|
|
duration: number;
|
|
onStart: () => void;
|
|
onStop: () => void;
|
|
onCancel: () => void;
|
|
onTranscribe: () => void;
|
|
onPlayPause: () => void;
|
|
isPlaying: boolean;
|
|
isTranscribing?: boolean;
|
|
showWaveform?: boolean;
|
|
}
|
|
|
|
export function AudioSampleRecording({
|
|
file,
|
|
isRecording,
|
|
duration,
|
|
onStart,
|
|
onStop,
|
|
onCancel,
|
|
onTranscribe,
|
|
onPlayPause,
|
|
isPlaying,
|
|
isTranscribing = false,
|
|
showWaveform = true,
|
|
}: AudioSampleRecordingProps) {
|
|
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
|
|
|
|
// Request microphone access when component mounts
|
|
useEffect(() => {
|
|
if (!showWaveform) return;
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
|
|
|
|
let stream: MediaStream | null = null;
|
|
|
|
navigator.mediaDevices
|
|
.getUserMedia({ audio: true, video: false })
|
|
.then((s) => {
|
|
stream = s;
|
|
setAudioStream(s);
|
|
})
|
|
.catch((err) => {
|
|
console.warn('Could not access microphone for visualization:', err);
|
|
});
|
|
|
|
return () => {
|
|
if (stream) {
|
|
stream.getTracks().forEach((track) => {
|
|
track.stop();
|
|
});
|
|
}
|
|
};
|
|
}, [showWaveform]);
|
|
|
|
return (
|
|
<FormItem>
|
|
<FormControl>
|
|
<div className="space-y-4">
|
|
{!isRecording && !file && (
|
|
<div className="relative flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px] overflow-hidden">
|
|
{showWaveform && audioStream && (
|
|
<MemoizedWaveform audioStream={audioStream} />
|
|
)}
|
|
<Button
|
|
type="button"
|
|
onClick={onStart}
|
|
size="lg"
|
|
className="relative z-10 flex items-center gap-2"
|
|
>
|
|
<Mic className="h-5 w-5" />
|
|
Start Recording
|
|
</Button>
|
|
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
|
Click to start recording. Maximum duration: 30 seconds.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{isRecording && (
|
|
<div className="relative flex flex-col items-center justify-center gap-4 p-4 border-2 border-accent rounded-lg bg-accent/5 min-h-[180px] overflow-hidden">
|
|
{showWaveform && audioStream && (
|
|
<MemoizedWaveform audioStream={audioStream} />
|
|
)}
|
|
<div className="relative z-10 flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-3 w-3 rounded-full bg-accent animate-pulse" />
|
|
<span className="text-lg font-mono font-semibold">
|
|
{formatAudioDuration(duration)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
onClick={onStop}
|
|
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
|
|
>
|
|
<Square className="h-4 w-4" />
|
|
Stop Recording
|
|
</Button>
|
|
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
|
{formatAudioDuration(30 - duration)} remaining
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{file && !isRecording && (
|
|
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
|
<div className="flex items-center gap-2">
|
|
<Mic className="h-5 w-5 text-primary" />
|
|
<span className="font-medium">Recording complete</span>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="outline"
|
|
onClick={onPlayPause}
|
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
>
|
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={onTranscribe}
|
|
disabled={isTranscribing}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Mic className="h-4 w-4" />
|
|
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={onCancel}
|
|
className="flex items-center gap-2"
|
|
>
|
|
Record Again
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
);
|
|
}
|