diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..f3ecbbce --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +# Force bun usage +engine-strict=true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69a83802..a7d06561 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,6 +159,26 @@ After starting the backend server: ``` This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/` +### Convert Assets to Web Formats + +To optimize images and videos for the web, run: +```bash +bun run convert:assets +``` + +This script: +- Converts PNG → WebP (better compression, same quality) +- Converts MOV → WebM (VP9 codec, smaller file size) +- Processes files in `landing/public/` and `docs/public/` +- **Deletes original files** after successful conversion + +**Requirements:** Install `webp` and `ffmpeg`: +```bash +brew install webp ffmpeg +``` + +> **Note:** Run this before committing new images or videos to keep the repository size small. + ## Development Workflow ### 1. Create a Branch diff --git a/app/package.json b/app/package.json index a67d4527..edae6c50 100644 --- a/app/package.json +++ b/app/package.json @@ -48,6 +48,7 @@ "react": "^18.3.0", "react-dom": "^18.3.0", "react-hook-form": "^7.53.0", + "react-sound-visualizer": "^1.4.0", "tailwind-merge": "^2.5.4", "wavesurfer.js": "^7.0.0", "zod": "^3.23.8", diff --git a/app/src/components/VoiceProfiles/AudioSampleRecording.tsx b/app/src/components/VoiceProfiles/AudioSampleRecording.tsx index 9c7c9b62..4f2db4e3 100644 --- a/app/src/components/VoiceProfiles/AudioSampleRecording.tsx +++ b/app/src/components/VoiceProfiles/AudioSampleRecording.tsx @@ -1,8 +1,31 @@ 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, FormLabel, FormMessage } from '@/components/ui/form'; +import { FormControl, FormItem, FormMessage } from '@/components/ui/form'; import { formatAudioDuration } from '@/lib/utils/audio'; +const MemoizedWaveform = memo(function MemoizedWaveform({ + audioStream, +}: { + audioStream: MediaStream; +}) { + return ( +
+ + {({ canvasRef }) => ( + + )} + +
+ ); +}); + interface AudioSampleRecordingProps { file: File | null | undefined; isRecording: boolean; @@ -14,6 +37,7 @@ interface AudioSampleRecordingProps { onPlayPause: () => void; isPlaying: boolean; isTranscribing?: boolean; + showWaveform?: boolean; } export function AudioSampleRecording({ @@ -27,29 +51,67 @@ export function AudioSampleRecording({ onPlayPause, isPlaying, isTranscribing = false, + showWaveform = true, }: AudioSampleRecordingProps) { + const [audioStream, setAudioStream] = useState(null); + + // Request microphone access when component mounts + useEffect(() => { + if (!showWaveform) 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 ( - Record Audio
{!isRecording && !file && ( -
- -

+

Click to start recording. Maximum duration: 30 seconds.

)} {isRecording && ( -
-
+
+ {showWaveform && audioStream && ( + + )} +
-
+
{formatAudioDuration(duration)} @@ -58,13 +120,12 @@ export function AudioSampleRecording({ -

+

{formatAudioDuration(30 - duration)} remaining

diff --git a/app/src/components/VoiceProfiles/AudioSampleSystem.tsx b/app/src/components/VoiceProfiles/AudioSampleSystem.tsx index 1f4d7b95..0cc892b2 100644 --- a/app/src/components/VoiceProfiles/AudioSampleSystem.tsx +++ b/app/src/components/VoiceProfiles/AudioSampleSystem.tsx @@ -1,6 +1,6 @@ import { Mic, Monitor, Pause, Play, Square } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { FormControl, FormItem, FormMessage } from '@/components/ui/form'; import { formatAudioDuration } from '@/lib/utils/audio'; interface AudioSampleSystemProps { @@ -30,7 +30,6 @@ export function AudioSampleSystem({ }: AudioSampleSystemProps) { return ( - Capture System Audio
{!isRecording && !file && ( diff --git a/app/src/components/VoiceProfiles/AudioSampleUpload.tsx b/app/src/components/VoiceProfiles/AudioSampleUpload.tsx index f3c4c111..c66ded14 100644 --- a/app/src/components/VoiceProfiles/AudioSampleUpload.tsx +++ b/app/src/components/VoiceProfiles/AudioSampleUpload.tsx @@ -1,7 +1,7 @@ import { Mic, Pause, Play, Upload } from 'lucide-react'; import { useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; -import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { FormControl, FormItem, FormMessage } from '@/components/ui/form'; interface AudioSampleUploadProps { file: File | null | undefined; @@ -31,7 +31,6 @@ export function AudioSampleUpload({ return ( - Audio File
('upload'); + const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('record'); const [audioDuration, setAudioDuration] = useState(null); const [isValidatingAudio, setIsValidatingAudio] = useState(false); const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer(); @@ -282,7 +282,7 @@ export function ProfileForm() { sampleFile: undefined, referenceText: undefined, }); - setSampleMode('upload'); + setSampleMode('record'); } }, [editingProfile, profileFormDraft, open, form]); @@ -488,9 +488,10 @@ export function ProfileForm() { return ( - - - {editingProfileId ? 'Edit Voice' : 'Create Voice Profile'} + +
+ + {editingProfileId ? 'Edit Voice' : 'Clone voice'} {editingProfileId ? 'Update your voice profile details and manage samples.' @@ -513,7 +514,7 @@ export function ProfileForm() { sampleFile: undefined, referenceText: '', }); - setSampleMode('upload'); + setSampleMode('record'); }} > @@ -524,76 +525,14 @@ export function ProfileForm() {
- -
- {/* Left column: Profile info */} -
- ( - - Name - - - - - - )} - /> - - ( - - Description (Optional) - -