mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -07:00
Landing page v0.2.0 updates: multi-engine copy, star count, model cards, voice creator section, responsive ControlUI, iOS audio fix
- Replace Qwen-specific copy with multi-engine messaging across hero, meta, and features - Add GitHub star count fetched server-side via /api/stars with Spacedrive-style navbar badge - Replace 'Why Voicebox exists' section with model cards for all 4 TTS engines - Enable Linux download card (was 'Coming soon') - Update GPU support copy to include ROCm, Intel Arc, DirectML - Add Voice Creator section with animated 3-tab UI (upload, mic, system audio) and waveform background - Make ControlUI responsive: horizontal scroll cards on mobile, stacked layout, scroll-to-active profile - Fix iOS Safari audio autoplay (unlock AudioContext on user gesture) - Fix hero logo square background with mix-blend-lighten - Remove generation length green coloring, use gray with accent highlights - Comment out grain overlay (visible tile seams) - Remove player close button, stack waveform above controls on mobile - Fixed-height profile cards (143px) with space between badges and buttons
This commit is contained in:
@@ -139,7 +139,11 @@ export function AudioPlayer() {
|
||||
barRadius: 2,
|
||||
height: 80,
|
||||
normalize: true,
|
||||
backend: 'WebAudio',
|
||||
// Use MediaElement backend (default). Unlike the WebAudio backend,
|
||||
// MediaElement uses a standard <audio> element for playback which
|
||||
// benefits from the browser/webview's built-in audio session recovery.
|
||||
// This prevents audio loss when another app steals audio output or
|
||||
// the system audio session is interrupted.
|
||||
interact: true, // Enable interaction (click to seek)
|
||||
mediaControls: false, // Don't show native controls
|
||||
});
|
||||
@@ -189,15 +193,6 @@ export function AudioPlayer() {
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
wavesurfer.setVolume(currentVolume);
|
||||
|
||||
// Get the underlying audio element and ensure it's not muted
|
||||
// (unless we're using native playback, which will be set later)
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement && !isUsingNativePlaybackRef.current) {
|
||||
mediaElement.volume = currentVolume;
|
||||
mediaElement.muted = false;
|
||||
debug.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
||||
}
|
||||
|
||||
// Auto-play when ready - check if we should use native playback
|
||||
// Get current values from the store and queries at runtime (not captured closure values)
|
||||
const currentAudioUrl = usePlayerStore.getState().audioUrl;
|
||||
@@ -264,21 +259,8 @@ export function AudioPlayer() {
|
||||
debug.log('Should use native playback:', shouldUseNative);
|
||||
|
||||
if (!shouldUseNative) {
|
||||
debug.log('No custom devices assigned, falling back to WaveSurfer');
|
||||
// Reset native playback flag and unmute WaveSurfer
|
||||
debug.log('No custom devices assigned, using standard playback');
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
mediaElement.volume = currentVolume;
|
||||
mediaElement.muted = false;
|
||||
debug.log(
|
||||
'WaveSurfer unmuted for normal playback - volume:',
|
||||
mediaElement.volume,
|
||||
'muted:',
|
||||
mediaElement.muted,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
|
||||
debug.log('Device IDs to play to:', deviceIds);
|
||||
@@ -299,19 +281,10 @@ export function AudioPlayer() {
|
||||
// Mark that we're using native playback
|
||||
isUsingNativePlaybackRef.current = true;
|
||||
|
||||
// Mute WaveSurfer's audio element to prevent UI audio output
|
||||
// Keep WaveSurfer running for visualization
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.volume = 0;
|
||||
mediaElement.muted = true;
|
||||
debug.log(
|
||||
'WaveSurfer muted for native playback - volume:',
|
||||
mediaElement.volume,
|
||||
'muted:',
|
||||
mediaElement.muted,
|
||||
);
|
||||
}
|
||||
// Mute WaveSurfer's audio output — native handles the actual sound
|
||||
// Keep WaveSurfer running for waveform visualization
|
||||
wavesurfer.setVolume(0);
|
||||
wavesurfer.setMuted(true);
|
||||
|
||||
// Start WaveSurfer playback for visualization (muted)
|
||||
wavesurfer.play().catch((error) => {
|
||||
@@ -334,38 +307,15 @@ export function AudioPlayer() {
|
||||
'Native playback failed during auto-play, falling back to WaveSurfer:',
|
||||
error,
|
||||
);
|
||||
// Reset native playback flag and unmute WaveSurfer
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
mediaElement.volume = currentVolume;
|
||||
mediaElement.muted = false;
|
||||
debug.log(
|
||||
'WaveSurfer unmuted after native playback failure - volume:',
|
||||
mediaElement.volume,
|
||||
'muted:',
|
||||
mediaElement.muted,
|
||||
);
|
||||
}
|
||||
// Fall through to WaveSurfer playback
|
||||
}
|
||||
} else {
|
||||
debug.log('Not using native playback, using WaveSurfer');
|
||||
// Reset native playback flag and unmute WaveSurfer
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
mediaElement.volume = currentVolume;
|
||||
mediaElement.muted = false;
|
||||
debug.log(
|
||||
'WaveSurfer unmuted for normal playback - volume:',
|
||||
mediaElement.volume,
|
||||
'muted:',
|
||||
mediaElement.muted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Standard playback path — ensure WaveSurfer is unmuted
|
||||
if (!isUsingNativePlaybackRef.current) {
|
||||
wavesurfer.setMuted(false);
|
||||
wavesurfer.setVolume(usePlayerStore.getState().volume);
|
||||
}
|
||||
|
||||
// Only auto-play if shouldAutoPlay flag is set (user explicitly clicked to play)
|
||||
@@ -389,28 +339,6 @@ export function AudioPlayer() {
|
||||
// Handle play/pause
|
||||
wavesurfer.on('play', () => {
|
||||
setIsPlaying(true);
|
||||
// Ensure audio element volume is set correctly
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
// Double-check: if using native playback, keep WaveSurfer muted
|
||||
// Otherwise, ensure it's unmuted
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
mediaElement.volume = 0;
|
||||
mediaElement.muted = true;
|
||||
debug.log('Playing (native mode) - WaveSurfer muted for visualization only');
|
||||
} else {
|
||||
// Ensure WaveSurfer is unmuted for normal playback
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
mediaElement.volume = currentVolume;
|
||||
mediaElement.muted = false;
|
||||
debug.log(
|
||||
'Playing (normal mode) - volume:',
|
||||
mediaElement.volume,
|
||||
'muted:',
|
||||
mediaElement.muted,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
wavesurfer.on('pause', () => setIsPlaying(false));
|
||||
wavesurfer.on('finish', () => {
|
||||
@@ -492,11 +420,6 @@ export function AudioPlayer() {
|
||||
if (wavesurferRef.current) {
|
||||
debug.log('Destroying WaveSurfer instance');
|
||||
try {
|
||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.pause();
|
||||
mediaElement.src = '';
|
||||
}
|
||||
wavesurferRef.current.destroy();
|
||||
} catch (error) {
|
||||
debug.error('Error destroying WaveSurfer:', error);
|
||||
@@ -537,13 +460,10 @@ export function AudioPlayer() {
|
||||
}
|
||||
|
||||
// Reset native playback flag when loading new audio
|
||||
// Also unmute WaveSurfer if it was muted
|
||||
// Unmute WaveSurfer if it was muted for native playback
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.muted = false;
|
||||
mediaElement.volume = usePlayerStore.getState().volume;
|
||||
}
|
||||
wavesurfer.setMuted(false);
|
||||
wavesurfer.setVolume(usePlayerStore.getState().volume);
|
||||
}
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
|
||||
@@ -559,16 +479,7 @@ export function AudioPlayer() {
|
||||
wavesurfer.pause();
|
||||
}
|
||||
|
||||
// Stop the media element explicitly
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
debug.log('Stopping media element');
|
||||
mediaElement.pause();
|
||||
mediaElement.currentTime = 0;
|
||||
mediaElement.src = '';
|
||||
}
|
||||
|
||||
// Use empty() to completely destroy the waveform and media element
|
||||
// Use empty() to completely destroy the waveform and reset media
|
||||
debug.log('Calling wavesurfer.empty() to destroy audio');
|
||||
wavesurfer.empty();
|
||||
} catch (error) {
|
||||
@@ -623,20 +534,13 @@ export function AudioPlayer() {
|
||||
// Sync volume
|
||||
useEffect(() => {
|
||||
if (wavesurferRef.current) {
|
||||
wavesurferRef.current.setVolume(volume);
|
||||
// Also ensure the underlying audio element volume is set
|
||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||
if (mediaElement) {
|
||||
// If using native playback, keep WaveSurfer muted regardless of volume setting
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
mediaElement.volume = 0;
|
||||
mediaElement.muted = true;
|
||||
debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
|
||||
} else {
|
||||
mediaElement.volume = volume;
|
||||
mediaElement.muted = volume === 0;
|
||||
debug.log('Volume synced:', volume, 'muted:', mediaElement.muted);
|
||||
}
|
||||
// If using native playback, keep WaveSurfer muted regardless of volume setting
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
wavesurferRef.current.setVolume(0);
|
||||
debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
|
||||
} else {
|
||||
wavesurferRef.current.setVolume(volume);
|
||||
debug.log('Volume synced:', volume);
|
||||
}
|
||||
}
|
||||
}, [volume]);
|
||||
@@ -757,11 +661,8 @@ export function AudioPlayer() {
|
||||
isUsingNativePlaybackRef.current = true;
|
||||
|
||||
// Mute WaveSurfer and start it for visualization
|
||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.volume = 0;
|
||||
mediaElement.muted = true;
|
||||
}
|
||||
wavesurferRef.current.setVolume(0);
|
||||
wavesurferRef.current.setMuted(true);
|
||||
|
||||
// Start WaveSurfer for visualization (muted)
|
||||
wavesurferRef.current.play().catch((error) => {
|
||||
@@ -785,11 +686,8 @@ export function AudioPlayer() {
|
||||
} else {
|
||||
// Ensure WaveSurfer is not muted if not using native playback
|
||||
if (!isUsingNativePlaybackRef.current) {
|
||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.muted = false;
|
||||
mediaElement.volume = volume;
|
||||
}
|
||||
wavesurferRef.current.setMuted(false);
|
||||
wavesurferRef.current.setVolume(volume);
|
||||
}
|
||||
|
||||
wavesurferRef.current.play().catch((error) => {
|
||||
|
||||
@@ -5,6 +5,14 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -29,6 +37,11 @@ export function EffectsDetail() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// "Save as Custom" dialog state
|
||||
const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
|
||||
const [saveAsName, setSaveAsName] = useState('');
|
||||
const [saveAsDescription, setSaveAsDescription] = useState('');
|
||||
|
||||
// Preview state
|
||||
const [previewGenId, setPreviewGenId] = useState<string | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
@@ -165,8 +178,38 @@ export function EffectsDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAsNew() {
|
||||
await handleSaveNew();
|
||||
function handleSaveAsNew() {
|
||||
// Open the dialog with a suggested name based on the current preset
|
||||
setSaveAsName(`${name} (Copy)`);
|
||||
setSaveAsDescription(description);
|
||||
setSaveAsDialogOpen(true);
|
||||
}
|
||||
|
||||
async function handleSaveAsConfirm() {
|
||||
if (!saveAsName.trim()) {
|
||||
toast({ title: 'Name required', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await apiClient.createEffectPreset({
|
||||
name: saveAsName.trim(),
|
||||
description: saveAsDescription.trim() || undefined,
|
||||
effects_chain: workingChain,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||
setSaveAsDialogOpen(false);
|
||||
setSelectedPresetId(created.id);
|
||||
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to save',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
@@ -327,6 +370,53 @@ export function EffectsDetail() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save as Custom dialog */}
|
||||
<Dialog open={saveAsDialogOpen} onOpenChange={setSaveAsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save as Custom Preset</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new custom preset based on the current effects chain.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input
|
||||
value={saveAsName}
|
||||
onChange={(e) => setSaveAsName(e.target.value)}
|
||||
placeholder="My preset..."
|
||||
className="h-9"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && saveAsName.trim()) {
|
||||
handleSaveAsConfirm();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Textarea
|
||||
value={saveAsDescription}
|
||||
onChange={(e) => setSaveAsDescription(e.target.value)}
|
||||
placeholder="Describe what this preset does..."
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
|
||||
<Save className="h-3.5 w-3.5 mr-1.5" />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,12 +45,15 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{tabs.map((tab) => {
|
||||
{tabs.map((tab, index) => {
|
||||
const Icon = tab.icon;
|
||||
// For index route, use exact match; for others, use default matching
|
||||
const isActive =
|
||||
tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path });
|
||||
|
||||
// Accent fades as buttons get further from the logo
|
||||
const accentOpacity = Math.max(0.08, 0.5 - index * 0.07);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
@@ -70,7 +73,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, black, transparent 60%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black, transparent 60%)',
|
||||
border: '1px solid hsl(var(--accent) / 0.5)',
|
||||
border: `1px solid hsl(var(--accent) / ${accentOpacity})`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# Voicebox v0.2.0 -- Release Notes
|
||||
|
||||
## The story
|
||||
|
||||
Voicebox v0.1.x shipped as a single-engine voice cloning app built around Qwen3-TTS. It worked, but it was limited: one model family, 10 languages, English-centric emotion, a synchronous generation pipeline that locked the UI, and a hard ceiling on how much text you could generate at once.
|
||||
|
||||
v0.2.0 is a ground-up rethink. Voicebox is now a **multi-engine voice cloning platform**. Four TTS engines. 23 languages. Expressive paralinguistic controls. A full post-processing effects pipeline. Unlimited generation length. Asynchronous everything. And it runs on every major GPU vendor -- NVIDIA, AMD, Intel Arc, Apple Silicon -- plus Docker for headless deployment.
|
||||
|
||||
This is the release where Voicebox stops being a proof of concept and starts being a real tool.
|
||||
|
||||
---
|
||||
|
||||
## Major New Features
|
||||
|
||||
### Multi-Engine Architecture
|
||||
Voicebox now supports **four TTS engines**, each with different strengths. Switch between them per-generation from a single unified interface:
|
||||
|
||||
| Engine | Languages | Strengths |
|
||||
|--------|-----------|-----------|
|
||||
| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions ("speak slowly", "whisper") |
|
||||
| **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
|
||||
| **Chatterbox Multilingual** | 23 | Broadest language coverage -- Arabic, Danish, Finnish, Greek, Hebrew, Hindi, Malay, Norwegian, Polish, Swahili, Swedish, Turkish and more |
|
||||
| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags |
|
||||
|
||||
### Emotions and Paralinguistic Tags (Chatterbox Turbo)
|
||||
Type `/` in the text input to open an autocomplete for **9 expressive tags** that the model synthesizes inline with speech:
|
||||
|
||||
`[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
|
||||
|
||||
Tags render as inline badges in a rich text editor and serialize cleanly to the API. This makes generated speech sound natural and expressive in a way that plain TTS can't.
|
||||
|
||||
### 23 Languages via Chatterbox Multilingual
|
||||
The Chatterbox Multilingual engine brings zero-shot voice cloning to **23 languages**: Arabic, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish, and Turkish. The language dropdown dynamically filters to show only languages supported by the selected engine.
|
||||
|
||||
### Unlimited Generation Length (Auto-Chunking)
|
||||
Previously, long text would hit model context limits and degrade. Now, text is **automatically split at sentence boundaries** and each chunk is generated independently, then crossfaded back together. This is fully engine-agnostic and works with all four engines.
|
||||
|
||||
- **Auto-chunking limit slider** (100-5,000 chars, default 800) -- controls when text gets split
|
||||
- **Crossfade slider** (0-200ms, default 50ms) -- blends chunk boundaries smoothly, or set to 0 for a hard cut
|
||||
- **Max text length raised to 50,000 characters** -- generate entire scripts, chapters, or articles in one go
|
||||
- Smart splitting respects abbreviations (Dr., e.g., a.m.), CJK punctuation, and never breaks inside paralinguistic `[tags]`
|
||||
|
||||
### Asynchronous Generation Queue
|
||||
Generation is now fully **non-blocking**. Submit a generation and immediately start typing the next one -- no more frozen UI waiting for inference to complete.
|
||||
|
||||
- Serial execution queue prevents GPU contention across all backends
|
||||
- Real-time SSE status streaming (`generating` -> `completed` / `failed`)
|
||||
- Failed generations can be retried without re-entering text
|
||||
- Stale generations from crashes are auto-recovered on startup
|
||||
- Generating status pill shown inline in the story editor
|
||||
|
||||
### Post-Processing Effects Pipeline
|
||||
A full audio effects system powered by Spotify's `pedalboard` library. Apply effects after generation, preview them in real time, and build reusable presets -- all without leaving the app.
|
||||
|
||||
**8 effects available:**
|
||||
|
||||
| Effect | What it does |
|
||||
|--------|-------------|
|
||||
| **Pitch Shift** | Shift pitch up or down by up to 12 semitones |
|
||||
| **Reverb** | Room reverb with configurable size, damping, and wet/dry mix |
|
||||
| **Delay** | Echo with adjustable delay time, feedback, and mix |
|
||||
| **Chorus / Flanger** | Modulated delay -- short for metallic flanger, longer for lush chorus |
|
||||
| **Compressor** | Dynamic range compression with threshold, ratio, attack, and release |
|
||||
| **Gain** | Volume adjustment from -40 to +40 dB |
|
||||
| **High-Pass Filter** | Remove low frequencies below a configurable cutoff |
|
||||
| **Low-Pass Filter** | Remove high frequencies above a configurable cutoff |
|
||||
|
||||
**Effects presets** -- Four built-in presets ship out of the box (Robotic, Radio, Echo Chamber, Deep Voice), and you can create unlimited custom presets. Presets are drag-and-drop chains of effects with per-parameter sliders.
|
||||
|
||||
**Per-profile default effects** -- Assign an effects chain to a voice profile and it applies automatically to every generation with that voice. Override per-generation from the generate box.
|
||||
|
||||
**Live preview** -- Audition any effects chain against an existing generation before committing. The preview streams processed audio without saving anything.
|
||||
|
||||
### Generation Versions
|
||||
Every generation now supports **multiple versions** with full provenance tracking:
|
||||
|
||||
- **Original** -- the clean, unprocessed TTS output (always preserved)
|
||||
- **Effects versions** -- apply different effects chains to create new versions from any source version
|
||||
- **Takes** -- regenerate with the same text and voice but a new seed for variation
|
||||
- **Source tracking** -- each version records which version it was derived from
|
||||
- **Version pinning in stories** -- pin a specific version to a track clip in the story editor, independent of the generation's default
|
||||
- **Favorites** -- star generations to mark them for quick access
|
||||
|
||||
---
|
||||
|
||||
## New Platform Support
|
||||
|
||||
### Linux (Native)
|
||||
Full Linux support with `.deb` and `.rpm` packages. Includes PulseAudio/PipeWire audio capture for voice sample recording.
|
||||
|
||||
### AMD ROCm GPU Acceleration
|
||||
AMD GPU users now get hardware-accelerated inference via ROCm, with automatic `HSA_OVERRIDE_GFX_VERSION` configuration for GPUs not officially in the ROCm compatibility list (e.g., RX 6600).
|
||||
|
||||
### NVIDIA CUDA Backend Swap
|
||||
The CPU-only release can download and swap in a CUDA-accelerated backend binary from within the app -- no reinstall required. Handles GitHub's 2GB asset limit by downloading split parts and verifying SHA-256 checksums.
|
||||
|
||||
### Intel Arc (XPU) and DirectML
|
||||
PyTorch backend also supports Intel Arc GPUs via IPEX/XPU and Windows any-GPU via DirectML.
|
||||
|
||||
### Docker + Web Deployment
|
||||
Run Voicebox headless as a Docker container with the full web UI:
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
3-stage build, non-root runtime, health checks, persistent model cache across rebuilds. Binds to localhost only by default.
|
||||
|
||||
---
|
||||
|
||||
## Model Management
|
||||
- **Per-model unload** -- free GPU memory without deleting downloaded models
|
||||
- **Custom models directory** -- set `VOICEBOX_MODELS_DIR` to store models anywhere
|
||||
- **Model folder migration** -- move all models to a new location with progress tracking
|
||||
- **Whisper Turbo** -- added `openai/whisper-large-v3-turbo` as a transcription model option
|
||||
- **Download cancel/clear UI** -- cancel in-progress downloads, VS Code-style problems panel for errors
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
- **CORS hardening** -- replaced wildcard `*` with an explicit allowlist of local origins; extensible via `VOICEBOX_CORS_ORIGINS` env var
|
||||
- **Network access toggle** -- fully disable outbound network requests for air-gapped deployments
|
||||
|
||||
## Accessibility
|
||||
- Comprehensive screen reader support (tested with NVDA/Narrator) across all major UI surfaces
|
||||
- Keyboard navigation for voice cards, history rows, model management, and story editor
|
||||
- State-aware `aria-label` attributes on all interactive controls
|
||||
|
||||
## Reliability
|
||||
- **Atomic audio saves** -- two-phase write prevents corrupted files on crash/interrupt
|
||||
- **Filesystem health endpoint** -- proactive disk space and directory writability checks
|
||||
- **Errno-specific error messages** -- clear feedback for permission denied, disk full, missing directory
|
||||
|
||||
## UX Polish
|
||||
- Responsive layout with horizontal-scroll voice cards on mobile
|
||||
- App version shown in sidebar
|
||||
- Voice card heights normalized
|
||||
- Audio player title hidden at narrow widths to prevent overflow
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **macOS (Apple Silicon)** | `Voicebox_0.2.0_aarch64.dmg` |
|
||||
| **macOS (Intel)** | `Voicebox_0.2.0_x64.dmg` |
|
||||
| **Windows** | `Voicebox_0.2.0_x64_en-US.msi` or `x64-setup.exe` |
|
||||
| **Linux** | `.deb` / `.rpm` packages |
|
||||
| **Docker** | `docker compose up` |
|
||||
|
||||
The app includes automatic updates -- future patches will be installed automatically.
|
||||
|
||||
---
|
||||
|
||||
## Video Script Beats
|
||||
|
||||
For the marketing video, focus on these six beats:
|
||||
|
||||
1. **"Four engines, one app"** -- show the engine dropdown switching between Qwen, LuxTTS, Chatterbox, and Turbo
|
||||
2. **"23 languages"** -- generate the same voice clone in Arabic, Japanese, Hindi, etc.
|
||||
3. **"Make it expressive"** -- type `/laugh` and `/sigh` with Chatterbox Turbo, play back the result
|
||||
4. **"Shape your sound"** -- apply the Robotic or Deep Voice preset, preview it live, then build a custom effects chain with drag-and-drop
|
||||
5. **"No limits"** -- paste a long script, show it auto-chunk and generate seamlessly
|
||||
6. **"Queue and go"** -- fire off multiple generations back-to-back without waiting
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getStarCount } from '@/lib/releases';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 600;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const count = await getStarCount();
|
||||
return NextResponse.json({ count });
|
||||
} catch (error) {
|
||||
console.error('Error fetching star count:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch star count' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -116,16 +116,17 @@
|
||||
}
|
||||
|
||||
/* Noise texture overlay for hero glow */
|
||||
.hero-glow::after {
|
||||
/* .hero-glow::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.8' numOctaves='5' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
opacity: 0.3;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2048' height='2048'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.5' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
|
||||
opacity: 0.35;
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
will-change: transform;
|
||||
} */
|
||||
|
||||
/* Scrollbar hiding */
|
||||
::-webkit-scrollbar {
|
||||
|
||||
@@ -4,11 +4,11 @@ import './globals.css';
|
||||
export const metadata: Metadata = {
|
||||
title: 'Voicebox - Open Source Voice Cloning Desktop App',
|
||||
description:
|
||||
'Near-perfect voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
|
||||
'Near-perfect voice cloning with multiple TTS engines. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
|
||||
keywords: [
|
||||
'voice cloning',
|
||||
'TTS',
|
||||
'Qwen3',
|
||||
'multi-engine',
|
||||
'desktop app',
|
||||
'AI voice',
|
||||
'open source',
|
||||
@@ -32,6 +32,14 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning className="dark">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Caveat:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div className="relative min-h-screen bg-background font-sans">{children}</div>
|
||||
</body>
|
||||
|
||||
+133
-33
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Github } from 'lucide-react';
|
||||
import { Github, Globe, Languages, MessageSquare, Zap } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ControlUI } from '@/components/ControlUI';
|
||||
@@ -8,6 +8,7 @@ import { Features } from '@/components/Features';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Navbar } from '@/components/Navbar';
|
||||
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
|
||||
import { VoiceCreator } from '@/components/VoiceCreator';
|
||||
import { DOWNLOAD_LINKS, GITHUB_REPO } from '@/lib/constants';
|
||||
import type { DownloadLinks } from '@/lib/releases';
|
||||
|
||||
@@ -44,18 +45,18 @@ export default function Home() {
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="fade-in mx-auto mb-8 h-[120px] w-[120px] md:h-[160px] md:w-[160px]"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
style={{
|
||||
animationDelay: '0ms',
|
||||
filter:
|
||||
'drop-shadow(0 0 20px hsl(43 60% 50% / 0.4)) drop-shadow(0 0 60px hsl(43 60% 50% / 0.2))',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/voicebox-logo-app.webp"
|
||||
alt="Voicebox"
|
||||
width={160}
|
||||
height={160}
|
||||
className="h-full w-full object-contain"
|
||||
style={{
|
||||
filter:
|
||||
'drop-shadow(0 0 20px hsl(43 60% 50% / 0.4)) drop-shadow(0 0 60px hsl(43 60% 50% / 0.2))',
|
||||
}}
|
||||
className="h-full w-full object-contain mix-blend-lighten"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
@@ -72,8 +73,8 @@ export default function Home() {
|
||||
className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"
|
||||
style={{ animationDelay: '200ms' }}
|
||||
>
|
||||
Open source voice cloning studio powered by Qwen3-TTS. Clone any voice, generate natural
|
||||
speech, and compose multi-voice projects — all running locally.
|
||||
Open source voice cloning studio with support for multiple TTS engines. Clone any voice,
|
||||
generate natural speech, and compose multi-voice projects — all running locally.
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
@@ -116,27 +117,125 @@ export default function Home() {
|
||||
{/* ── Features ─────────────────────────────────────────────── */}
|
||||
<Features />
|
||||
|
||||
{/* ── About / Manifesto ────────────────────────────────────── */}
|
||||
{/* ── Voice Creator ────────────────────────────────────────── */}
|
||||
<VoiceCreator />
|
||||
|
||||
{/* ── Models ─────────────────────────────────────────────────── */}
|
||||
<section id="about" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<h2 className="mb-10 text-center text-3xl font-semibold tracking-tight text-foreground md:text-4xl">
|
||||
Why Voicebox exists
|
||||
</h2>
|
||||
<div className="space-y-6 text-center">
|
||||
<p className="text-base leading-relaxed text-muted-foreground">
|
||||
Cloud voice cloning services lock your voice data behind subscriptions, rate limits,
|
||||
and terms of service that can change at any time. Your voice — and the voices you
|
||||
clone — should belong to you.
|
||||
</p>
|
||||
<p className="text-lg font-medium text-foreground">
|
||||
Voicebox is a local-first voice cloning studio. Download a model, clone any voice from
|
||||
a few seconds of audio, and generate speech entirely on your machine.
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Optimized with Metal acceleration on Mac and CUDA on Windows/Linux. No Python install
|
||||
required. No cloud. No subscriptions. Free and open-source, forever.
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Multi-Engine Architecture
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Choose the right model for every job. All models run locally on your hardware —
|
||||
download once, use forever.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Qwen3-TTS */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">Qwen3-TTS</h3>
|
||||
<span className="text-xs text-muted-foreground/60">by Alibaba</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
1.7B
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
0.6B
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
High-quality multilingual voice cloning with natural prosody. The only engine with
|
||||
delivery instructions — control tone, pace, and emotion with natural language.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Globe className="h-3 w-3" />
|
||||
10 languages
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
Delivery instructions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chatterbox */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">Chatterbox</h3>
|
||||
<span className="text-xs text-muted-foreground/60">by Resemble AI</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Production-grade voice cloning with the broadest language support. 23 languages with
|
||||
zero-shot cloning and emotion exaggeration control.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Languages className="h-3 w-3" />
|
||||
23 languages
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chatterbox Turbo */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">Chatterbox Turbo</h3>
|
||||
<span className="text-xs text-muted-foreground/60">by Resemble AI</span>
|
||||
</div>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
350M
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Lightweight and fast. Supports paralinguistic tags — embed [laugh], [sigh], [gasp]
|
||||
and more directly in your text for expressive, natural speech.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Zap className="h-3 w-3" />
|
||||
350M params
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
[laugh] [sigh] tags
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LuxTTS */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">LuxTTS</h3>
|
||||
<span className="text-xs text-muted-foreground/60">by ZipVoice</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Ultra-fast, CPU-friendly voice cloning at 48kHz. Exceeds 150x realtime on CPU with
|
||||
~1GB VRAM. The fastest engine for quick iterations.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Zap className="h-3 w-3" />
|
||||
150x realtime
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
48kHz output
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -193,16 +292,17 @@ export default function Home() {
|
||||
</a>
|
||||
|
||||
{/* Linux */}
|
||||
<div
|
||||
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 opacity-50 cursor-not-allowed"
|
||||
title="Linux builds coming soon"
|
||||
<a
|
||||
href={downloadLinks.linux}
|
||||
download
|
||||
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
|
||||
>
|
||||
<LinuxIcon className="h-6 w-6 shrink-0 text-muted-foreground" />
|
||||
<LinuxIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">Linux</div>
|
||||
<div className="text-xs text-muted-foreground">Coming soon</div>
|
||||
<div className="text-xs text-muted-foreground">AppImage (x64)</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* GitHub link */}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Wand2,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { LandingAudioPlayer } from './LandingAudioPlayer';
|
||||
import { LandingAudioPlayer, unlockAudioContext } from './LandingAudioPlayer';
|
||||
|
||||
// ─── Data ───────────────────────────────────────────────────────────────────
|
||||
// Edit this section to customise all the content shown in the ControlUI demo.
|
||||
@@ -29,7 +29,7 @@ interface VoiceProfile {
|
||||
hasEffects: boolean;
|
||||
}
|
||||
|
||||
/** Voice profiles shown in the 3×3 grid. Index matters — DemoScript references profiles by index. */
|
||||
/** Voice profiles shown in the grid / scroll strip. Index matters — DemoScript references profiles by index. */
|
||||
const PROFILES: VoiceProfile[] = [
|
||||
{
|
||||
name: 'Jarvis',
|
||||
@@ -301,24 +301,27 @@ function LoadingBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
|
||||
|
||||
// ─── Profile Card ───────────────────────────────────────────────────────────
|
||||
|
||||
function ProfileCard({
|
||||
const ProfileCard = ({
|
||||
profile,
|
||||
selected,
|
||||
selecting,
|
||||
cardRef,
|
||||
}: {
|
||||
profile: VoiceProfile;
|
||||
selected: boolean;
|
||||
selecting: boolean;
|
||||
}) {
|
||||
cardRef?: React.Ref<HTMLDivElement>;
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
className={`rounded-xl border-2 bg-card p-3.5 flex flex-col aspect-square transition-all duration-200 ${
|
||||
ref={cardRef}
|
||||
className={`rounded-xl border-2 bg-card p-3.5 flex flex-col h-[143px] transition-all duration-200 ${
|
||||
selected ? 'border-accent shadow-md' : 'border-border/50 hover:shadow-sm'
|
||||
} ${selecting && !selected ? 'opacity-60' : ''}`}
|
||||
animate={selecting && selected ? { scale: [1, 1.02, 1] } : {}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="text-[15px] font-bold leading-tight">{profile.name}</div>
|
||||
<div className="text-[15px] font-bold leading-tight line-clamp-2">{profile.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground line-clamp-2 leading-relaxed mt-1">
|
||||
{profile.description}
|
||||
</div>
|
||||
@@ -335,7 +338,7 @@ function ProfileCard({
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── History Row ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -416,79 +419,79 @@ function FloatingGenerateBox({
|
||||
phase,
|
||||
typingText,
|
||||
selectedProfile,
|
||||
engine,
|
||||
effect,
|
||||
}: {
|
||||
phase: Phase;
|
||||
typingText: string;
|
||||
selectedProfile: VoiceProfile | null;
|
||||
engine: string;
|
||||
effect?: string;
|
||||
}) {
|
||||
const isFocused = phase === 'typing' || phase === 'generating';
|
||||
const isGenerating = phase === 'generating';
|
||||
|
||||
return (
|
||||
<div className="absolute left-3 right-3 z-20" style={{ bottom: 117 }}>
|
||||
<motion.div
|
||||
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[1.5rem] shadow-2xl p-2.5"
|
||||
animate={{
|
||||
borderColor: isGenerating
|
||||
? 'hsl(43 50% 45% / 0.35)'
|
||||
: isFocused
|
||||
? 'hsl(43 50% 45% / 0.25)'
|
||||
: 'hsl(43 50% 45% / 0.15)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Text area + generate button */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<motion.div
|
||||
className="overflow-hidden"
|
||||
animate={{ height: isFocused ? 100 : 32 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
<motion.div
|
||||
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[1.5rem] shadow-2xl p-2.5"
|
||||
animate={{
|
||||
borderColor: isGenerating
|
||||
? 'hsl(43 50% 45% / 0.35)'
|
||||
: isFocused
|
||||
? 'hsl(43 50% 45% / 0.25)'
|
||||
: 'hsl(43 50% 45% / 0.15)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Text area + generate button */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<motion.div
|
||||
className="overflow-hidden"
|
||||
animate={{ height: isFocused ? 100 : 32 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
>
|
||||
<div
|
||||
className="text-[12.5px] text-muted-foreground/60 px-2 py-1 leading-relaxed"
|
||||
style={{ minHeight: isFocused ? 100 : 32 }}
|
||||
>
|
||||
<div
|
||||
className="text-[12.5px] text-muted-foreground/60 px-2 py-1 leading-relaxed"
|
||||
style={{ minHeight: isFocused ? 100 : 32 }}
|
||||
>
|
||||
{phase === 'typing' ? (
|
||||
<span className="text-foreground">
|
||||
<TypewriterText text={typingText} />
|
||||
</span>
|
||||
) : phase === 'generating' ? (
|
||||
<span className="text-muted-foreground/40">{typingText}</span>
|
||||
) : (
|
||||
<span>
|
||||
{selectedProfile
|
||||
? `Generate speech using ${selectedProfile.name}...`
|
||||
: 'Select a voice profile above...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Generate button */}
|
||||
<button className="h-8 w-8 rounded-full bg-accent flex items-center justify-center shrink-0 shadow-lg">
|
||||
<Sparkles className="h-3.5 w-3.5 text-accent-foreground" />
|
||||
</button>
|
||||
{phase === 'typing' ? (
|
||||
<span className="text-foreground">
|
||||
<TypewriterText text={typingText} />
|
||||
</span>
|
||||
) : phase === 'generating' ? (
|
||||
<span className="text-muted-foreground/40">{typingText}</span>
|
||||
) : (
|
||||
<span>
|
||||
{selectedProfile
|
||||
? `Generate speech using ${selectedProfile.name}...`
|
||||
: 'Select a voice profile above...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Bottom selectors */}
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground">
|
||||
English
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground">
|
||||
Qwen3-TTS 1.7B
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1">
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
{effect || 'Effect'}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
{/* Generate button */}
|
||||
<button className="h-8 w-8 rounded-full bg-accent flex items-center justify-center shrink-0 shadow-lg">
|
||||
<Sparkles className="h-3.5 w-3.5 text-accent-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom selectors */}
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground">
|
||||
English
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground">
|
||||
{engine}
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1">
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
{effect || 'Effect'}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -505,11 +508,20 @@ export function ControlUI() {
|
||||
const [pageHidden, setPageHidden] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const phaseRef = useRef(phase);
|
||||
const profileCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
phaseRef.current = phase;
|
||||
|
||||
const step = DEMO_SCRIPT[cycle % DEMO_SCRIPT.length];
|
||||
const selectedProfile = PROFILES[selectedIndex];
|
||||
|
||||
// Scroll to selected profile card
|
||||
useEffect(() => {
|
||||
const el = profileCardRefs.current.get(selectedIndex);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Visibility detection
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting), {
|
||||
@@ -589,33 +601,83 @@ export function ControlUI() {
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative z-20 mx-auto w-full max-w-6xl px-6">
|
||||
{/* Unmute button */}
|
||||
{/* Unmute button with handwritten hint */}
|
||||
<div className="flex justify-end mb-3">
|
||||
<button
|
||||
onClick={() => setIsMuted(!isMuted)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-border bg-card/50 backdrop-blur text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isMuted ? (
|
||||
<>
|
||||
<Volume2 className="h-3.5 w-3.5" />
|
||||
<span>Unmute</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Volume2 className="h-3.5 w-3.5 text-accent" />
|
||||
<span>Mute</span>
|
||||
</>
|
||||
<div className="relative">
|
||||
{/* Handwritten hint — absolutely positioned above the button */}
|
||||
{isMuted && (
|
||||
<motion.div
|
||||
className="absolute select-none pointer-events-none"
|
||||
style={{ top: -30, right: 100 }}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 2, duration: 0.6, ease: 'easeOut' }}
|
||||
>
|
||||
<span
|
||||
className="text-xl text-accent/80 whitespace-nowrap"
|
||||
style={{
|
||||
fontFamily: "'Caveat', 'Segoe Script', 'Comic Sans MS', cursive",
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
try me!
|
||||
</span>
|
||||
{/* Curved arrow from text down-right toward the button */}
|
||||
<svg
|
||||
width="22"
|
||||
height="11"
|
||||
viewBox="0 0 80 40"
|
||||
fill="none"
|
||||
className="text-accent/70 absolute"
|
||||
style={{ top: 14, left: 60 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<title>Arrow</title>
|
||||
<path
|
||||
d="M4 4 C20 4, 40 8, 55 20 C62 26, 66 32, 70 36"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M58 42 L70 36 L64 22"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
transform="rotate(35, 70, 36)"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
unlockAudioContext();
|
||||
setIsMuted(!isMuted);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-border bg-card/50 backdrop-blur text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isMuted ? (
|
||||
<>
|
||||
<Volume2 className="h-3.5 w-3.5" />
|
||||
<span>Unmute</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Volume2 className="h-3.5 w-3.5 text-accent" />
|
||||
<span>Mute</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="overflow-hidden rounded-2xl border border-app-line bg-app-box shadow-[0_25px_60px_rgba(0,0,0,0.5),0_8px_20px_rgba(0,0,0,0.3)]"
|
||||
style={{ height: 640 }}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{/* ── Sidebar ───────────────────────────────────────────── */}
|
||||
<div className="w-16 shrink-0 border-r border-app-line bg-sidebar flex flex-col items-center py-4 gap-4">
|
||||
<div className="overflow-hidden rounded-2xl border border-app-line bg-app-box shadow-[0_25px_60px_rgba(0,0,0,0.5),0_8px_20px_rgba(0,0,0,0.3)] md:h-[640px]">
|
||||
<div className="flex flex-col md:flex-row h-full">
|
||||
{/* ── Sidebar (hidden on mobile) ─────────────────────────── */}
|
||||
<div className="hidden md:flex w-16 shrink-0 border-r border-app-line bg-sidebar flex-col items-center py-4 gap-4">
|
||||
{/* Logo */}
|
||||
<div className="mb-1">
|
||||
<div
|
||||
@@ -658,11 +720,11 @@ export function ControlUI() {
|
||||
</div>
|
||||
|
||||
{/* ── Main content ──────────────────────────────────────── */}
|
||||
<div className="flex-1 flex min-w-0 relative">
|
||||
{/* Left: Profiles */}
|
||||
<div className="flex-1 flex flex-col min-w-0 relative">
|
||||
<div className="flex-1 flex flex-col md:flex-row min-w-0 relative">
|
||||
{/* Left: Profiles + Generate box */}
|
||||
<div className="flex flex-col min-w-0 relative md:flex-1">
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-6 pb-2 flex items-center justify-between relative z-10">
|
||||
<div className="px-4 pt-4 md:pt-6 pb-2 flex items-center justify-between relative z-10">
|
||||
<h2 className="text-base font-bold">Voicebox</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button className="h-6 text-[10px] px-2.5 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1">
|
||||
@@ -675,32 +737,58 @@ export function ControlUI() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile grid */}
|
||||
<div className="flex-1 overflow-hidden px-4 pb-24">
|
||||
<div className="grid grid-cols-3 gap-2 auto-rows-auto mt-1">
|
||||
{/* Profile cards — horizontal scroll on mobile, 3-col grid on desktop */}
|
||||
<div className="px-4">
|
||||
{/* Mobile: horizontal scroll strip */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 md:hidden">
|
||||
{PROFILES.map((profile, i) => (
|
||||
<div
|
||||
key={profile.name}
|
||||
className="shrink-0 w-[140px]"
|
||||
ref={(el) => {
|
||||
if (el) profileCardRefs.current.set(i, el);
|
||||
}}
|
||||
>
|
||||
<ProfileCard
|
||||
profile={profile}
|
||||
selected={i === selectedIndex}
|
||||
selecting={phase === 'selecting'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: 3-col grid */}
|
||||
<div className="hidden md:grid grid-cols-3 gap-2 mt-1 pb-24">
|
||||
{PROFILES.map((profile, i) => (
|
||||
<ProfileCard
|
||||
key={profile.name}
|
||||
profile={profile}
|
||||
selected={i === selectedIndex}
|
||||
selecting={phase === 'selecting'}
|
||||
cardRef={(el: HTMLDivElement | null) => {
|
||||
if (el) profileCardRefs.current.set(i, el);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating generate box */}
|
||||
<FloatingGenerateBox
|
||||
phase={phase}
|
||||
typingText={step.text}
|
||||
selectedProfile={selectedProfile}
|
||||
effect={step.effect}
|
||||
/>
|
||||
{/* Floating generate box — desktop: absolute overlay, mobile: inline */}
|
||||
<div className="px-3 pt-2 pb-3 md:pt-0 md:absolute md:left-3 md:right-3 md:bottom-[117px] md:z-20 md:pb-0">
|
||||
<FloatingGenerateBox
|
||||
phase={phase}
|
||||
typingText={step.text}
|
||||
selectedProfile={selectedProfile}
|
||||
engine={step.engine}
|
||||
effect={step.effect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: History */}
|
||||
<div className="w-[48%] shrink-0 flex flex-col min-w-0">
|
||||
<div className="flex-1 overflow-hidden px-3 pt-6 pb-3">
|
||||
{/* Right/Below: History */}
|
||||
<div className="md:w-[48%] shrink-0 flex flex-col min-w-0 border-t md:border-t-0 border-app-line">
|
||||
<div className="max-h-[360px] md:max-h-none flex-1 overflow-hidden px-3 pt-3 md:pt-6 pb-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{generations.map((gen) => {
|
||||
const isThisNew = gen.id === newGenId;
|
||||
|
||||
+511
-134
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AudioLines, Cloud, Layers, MessageSquareText, Mic, Monitor } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AudioLines, Cloud, MessageSquareText, Mic, Sparkles, TextCursorInput } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// ─── Lazy load wrapper ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -111,122 +111,435 @@ function VoiceCloningAnimation() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Animation: Stories Editor ───────────────────────────────────────────────
|
||||
// ─── Mini waveform for clips ────────────────────────────────────────────────
|
||||
// Fixed-width dense waveform that overflows — the clip container clips it.
|
||||
// This way resizing a clip just reveals/hides bars instead of re-rendering.
|
||||
|
||||
function StoriesAnimation() {
|
||||
const [activeTrack, setActiveTrack] = useState(0);
|
||||
const tracks = [
|
||||
{
|
||||
name: 'Narrator',
|
||||
color: '#ac8a2b',
|
||||
clips: [
|
||||
{ w: '45%', x: '5%' },
|
||||
{ w: '30%', x: '60%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Character A',
|
||||
color: '#3b82f6',
|
||||
clips: [
|
||||
{ w: '25%', x: '15%' },
|
||||
{ w: '35%', x: '50%' },
|
||||
],
|
||||
},
|
||||
{ name: 'Character B', color: '#8b5cf6', clips: [{ w: '20%', x: '30%' }] },
|
||||
];
|
||||
const WAVEFORM_BAR_COUNT = 60;
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setActiveTrack((p) => (p + 1) % tracks.length);
|
||||
}, 2200);
|
||||
return () => clearInterval(interval);
|
||||
}, [tracks.length]);
|
||||
function MiniWaveform({ seed, color }: { seed: number; color: string }) {
|
||||
// Deterministic pseudo-random waveform that looks like real speech audio.
|
||||
// Uses layered noise at different frequencies for natural envelope + detail.
|
||||
const bars = useMemo(() => {
|
||||
// Seeded pseudo-random number generator (deterministic per seed)
|
||||
let s = seed * 9301 + 49297;
|
||||
const rand = () => {
|
||||
s = (s * 16807 + 0) % 2147483647;
|
||||
return s / 2147483647;
|
||||
};
|
||||
|
||||
// Pre-generate random values
|
||||
const r = Array.from({ length: WAVEFORM_BAR_COUNT }, () => rand());
|
||||
|
||||
return Array.from({ length: WAVEFORM_BAR_COUNT }, (_, i) => {
|
||||
const t = i / WAVEFORM_BAR_COUNT;
|
||||
|
||||
// Slow envelope — broad amplitude shape (words / phrases)
|
||||
const envelope =
|
||||
0.3 +
|
||||
0.35 *
|
||||
Math.sin(t * Math.PI * (2 + (seed % 3))) *
|
||||
Math.sin(t * Math.PI * (1.3 + seed * 0.7)) +
|
||||
0.2 * Math.sin(t * Math.PI * (4.7 + seed * 1.3));
|
||||
|
||||
// Medium variation — syllable-level bumps
|
||||
const mid = 0.15 * Math.sin(i * 0.8 + seed * 3.1) * Math.cos(i * 1.3 + seed);
|
||||
|
||||
// High-frequency noise — individual sample jitter
|
||||
const noise = (r[i] - 0.5) * 0.25;
|
||||
|
||||
// Combine and clamp
|
||||
const raw = envelope + mid + noise;
|
||||
return Math.max(0.06, Math.min(1, raw));
|
||||
});
|
||||
}, [seed]);
|
||||
|
||||
return (
|
||||
<div className="h-40 w-full flex flex-col justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4 gap-2">
|
||||
{tracks.map((track, i) => (
|
||||
<motion.div
|
||||
key={track.name}
|
||||
className="flex items-center gap-2 h-8 rounded border px-2"
|
||||
animate={{
|
||||
borderColor: i === activeTrack ? `${track.color}40` : 'rgba(255,255,255,0.06)',
|
||||
backgroundColor: i === activeTrack ? `${track.color}08` : 'rgba(255,255,255,0.02)',
|
||||
<div className="flex items-center h-full overflow-hidden">
|
||||
{bars.map((h, i) => (
|
||||
<div
|
||||
key={`w-${seed}-${i}`}
|
||||
className="shrink-0 rounded-full opacity-50"
|
||||
style={{
|
||||
width: 2,
|
||||
marginRight: 1,
|
||||
height: `${h * 100}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<span
|
||||
className="text-[8px] w-14 shrink-0 truncate"
|
||||
style={{ color: i === activeTrack ? track.color : 'rgba(255,255,255,0.3)' }}
|
||||
>
|
||||
{track.name}
|
||||
</span>
|
||||
<div className="flex-1 relative h-4">
|
||||
{track.clips.map((clip, j) => (
|
||||
<motion.div
|
||||
key={j}
|
||||
className="absolute h-full rounded-sm"
|
||||
style={{
|
||||
left: clip.x,
|
||||
width: clip.w,
|
||||
backgroundColor:
|
||||
i === activeTrack ? `${track.color}30` : 'rgba(255,255,255,0.04)',
|
||||
borderLeft: `2px solid ${i === activeTrack ? track.color : 'rgba(255,255,255,0.1)'}`,
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Animation: Multi-Sample ────────────────────────────────────────────────
|
||||
// ─── Animation: Stories Editor ───────────────────────────────────────────────
|
||||
|
||||
function MultiSampleAnimation() {
|
||||
const [active, setActive] = useState(0);
|
||||
const samples = [
|
||||
{ label: 'interview_clip.wav', dur: '12.4s', quality: 0.92 },
|
||||
{ label: 'podcast_intro.wav', dur: '8.1s', quality: 0.88 },
|
||||
{ label: 'narration_take3.wav', dur: '15.7s', quality: 0.95 },
|
||||
{ label: 'casual_chat.wav', dur: '6.2s', quality: 0.85 },
|
||||
// Clip shape: id, profile, track, left (px out of 220), width (px), waveform seed
|
||||
type DemoClip = { id: string; profile: string; track: number; x: number; w: number; seed: number };
|
||||
|
||||
const INITIAL_CLIPS: DemoClip[] = [
|
||||
{ id: 'n1', profile: 'Morgan', track: 0, x: 4, w: 70, seed: 1 },
|
||||
{ id: 'n2', profile: 'Morgan', track: 0, x: 135, w: 35, seed: 2 },
|
||||
{ id: 'a1', profile: 'Scarlett', track: 1, x: 25, w: 40, seed: 3 },
|
||||
{ id: 'a2', profile: 'Scarlett', track: 1, x: 120, w: 35, seed: 4 },
|
||||
{ id: 'b1', profile: 'Jarvis', track: 2, x: 70, w: 45, seed: 5 },
|
||||
];
|
||||
|
||||
// Timeline width the clips live inside
|
||||
const TL_W = 220;
|
||||
// Each action returns a new clips array (or modifies in place)
|
||||
type Action = { label: string; apply: (clips: DemoClip[]) => DemoClip[] };
|
||||
|
||||
const ACTIONS: Action[] = [
|
||||
// 0 — move Jarvis clip earlier
|
||||
{ label: 'Move clip', apply: (c) => c.map((cl) => (cl.id === 'b1' ? { ...cl, x: 55 } : cl)) },
|
||||
// 1 — split Morgan's first clip into two with visible gap
|
||||
{
|
||||
label: 'Split clip',
|
||||
apply: (c) => {
|
||||
// Idempotent: if n1b already exists, the split already happened
|
||||
if (c.some((cl) => cl.id === 'n1b')) return c;
|
||||
const clip = c.find((cl) => cl.id === 'n1');
|
||||
if (!clip) return c;
|
||||
const leftW = 25;
|
||||
const gap = 8;
|
||||
const rightW = clip.w - leftW - gap;
|
||||
return [
|
||||
...c.filter((cl) => cl.id !== 'n1'),
|
||||
{ ...clip, w: leftW, id: 'n1' },
|
||||
{
|
||||
id: 'n1b',
|
||||
profile: clip.profile,
|
||||
track: clip.track,
|
||||
x: clip.x + leftW + gap,
|
||||
w: rightW,
|
||||
seed: 6,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
// 2 — trim Scarlett's second clip shorter
|
||||
{ label: 'Trim clip', apply: (c) => c.map((cl) => (cl.id === 'a2' ? { ...cl, w: 25 } : cl)) },
|
||||
// 3 — duplicate Jarvis to track 0
|
||||
{
|
||||
label: 'Duplicate',
|
||||
apply: (c) => {
|
||||
// Idempotent: if b1d already exists, the duplicate already happened
|
||||
if (c.some((cl) => cl.id === 'b1d')) return c;
|
||||
const clip = c.find((cl) => cl.id === 'b1');
|
||||
if (!clip) return c;
|
||||
return [...c, { ...clip, id: 'b1d', track: 0, x: 180, w: 35, seed: 7 }];
|
||||
},
|
||||
},
|
||||
// 4 — reset
|
||||
{ label: '', apply: () => INITIAL_CLIPS },
|
||||
];
|
||||
|
||||
function StoriesAnimation() {
|
||||
const [clips, setClips] = useState<DemoClip[]>(INITIAL_CLIPS);
|
||||
const [actionIndex, setActionIndex] = useState(-1);
|
||||
const [playheadX, setPlayheadX] = useState(0);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const playheadRef = useRef<ReturnType<typeof requestAnimationFrame>>(0);
|
||||
|
||||
// Animate the playhead continuously
|
||||
useEffect(() => {
|
||||
let start: number | null = null;
|
||||
const speed = 12; // px per second
|
||||
const animate = (ts: number) => {
|
||||
if (start === null) start = ts;
|
||||
const elapsed = (ts - start) / 1000;
|
||||
setPlayheadX((elapsed * speed) % TL_W);
|
||||
playheadRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
playheadRef.current = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(playheadRef.current);
|
||||
}, []);
|
||||
|
||||
// Step through actions
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setActionIndex((prev) => {
|
||||
const next = (prev + 1) % ACTIONS.length;
|
||||
setClips((current) => ACTIONS[next].apply(current));
|
||||
// Highlight the clip being acted on
|
||||
if (next === 0) setSelectedId('b1');
|
||||
else if (next === 1) setSelectedId('n1');
|
||||
else if (next === 2) setSelectedId('a2');
|
||||
else if (next === 3) setSelectedId('b1');
|
||||
else setSelectedId(null);
|
||||
return next;
|
||||
});
|
||||
}, 2600);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const trackLabels = ['1', '0', '-1'];
|
||||
const timeMarkers = [0, 2, 4, 6, 8];
|
||||
const accentColor = 'hsl(43 50% 45%)';
|
||||
const accentFg = 'hsl(30 10% 94%)';
|
||||
|
||||
return (
|
||||
<div className="h-40 w-full flex flex-col overflow-hidden rounded-md bg-app-darkerBox/50">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-app-line bg-app-darkBox/60 shrink-0">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-ink-faint/40" />
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-4 rounded flex items-center justify-center bg-app-button">
|
||||
<div className="border-l-[4px] border-l-ink-faint border-t-[3px] border-t-transparent border-b-[3px] border-b-transparent ml-0.5" />
|
||||
</div>
|
||||
<div className="w-4 h-4 rounded flex items-center justify-center bg-app-button">
|
||||
<div className="w-2 h-2 rounded-sm bg-ink-faint/60" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[8px] text-ink-faint font-mono ml-1 tabular-nums">0:03 / 0:10</span>
|
||||
<div className="flex-1" />
|
||||
{actionIndex >= 0 && actionIndex < ACTIONS.length - 1 && (
|
||||
<motion.span
|
||||
key={actionIndex}
|
||||
className="text-[7px] font-medium px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${accentColor.replace(')', ' / 0.15)')}`,
|
||||
color: accentColor,
|
||||
}}
|
||||
initial={{ opacity: 0, y: 3 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
{ACTIONS[actionIndex].label}
|
||||
</motion.span>
|
||||
)}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-[7px] text-ink-faint">Zoom</span>
|
||||
<div className="w-3 h-3 rounded flex items-center justify-center bg-app-button text-[8px] text-ink-faint">
|
||||
-
|
||||
</div>
|
||||
<div className="w-3 h-3 rounded flex items-center justify-center bg-app-button text-[8px] text-ink-faint">
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Track labels sidebar */}
|
||||
<div className="w-7 shrink-0 border-r border-app-line bg-app-darkBox/30 flex flex-col">
|
||||
<div className="h-5 border-b border-app-line" />
|
||||
{trackLabels.map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex-1 flex items-center justify-center border-b border-app-line"
|
||||
>
|
||||
<span className="text-[7px] text-ink-faint select-none">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tracks area */}
|
||||
<div className="flex-1 relative overflow-hidden flex flex-col">
|
||||
{/* Time ruler */}
|
||||
<div className="h-5 shrink-0 border-b border-app-line bg-app-darkBox/20 relative">
|
||||
{timeMarkers.map((t) => (
|
||||
<div
|
||||
key={`tm-${t}`}
|
||||
className="absolute top-0 h-full flex flex-col justify-end pb-0.5"
|
||||
style={{ left: `${(t / 10) * 100}%` }}
|
||||
>
|
||||
<div className="h-1.5 w-px bg-app-line" />
|
||||
<span className="text-[7px] text-ink-faint ml-0.5 select-none">{`0:0${t}`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Track rows + clips — same parent so percentages match */}
|
||||
<div className="flex-1 relative min-h-0">
|
||||
{/* Track rows background */}
|
||||
{trackLabels.map((label, i) => (
|
||||
<div
|
||||
key={`bg-${label}`}
|
||||
className="border-b border-app-line absolute left-0 right-0"
|
||||
style={{
|
||||
height: `${100 / 3}%`,
|
||||
top: `${(i * 100) / 3}%`,
|
||||
backgroundColor: i % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.01)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Clips */}
|
||||
{clips.map((clip) => {
|
||||
const trackIdx = clip.track;
|
||||
const isSelected = clip.id === selectedId;
|
||||
const clipTop = `calc(${(trackIdx * 100) / 3}% + 2px)`;
|
||||
const clipHeight = `calc(${100 / 3}% - 4px)`;
|
||||
return (
|
||||
<motion.div
|
||||
key={clip.id}
|
||||
className="absolute rounded overflow-hidden"
|
||||
initial={false}
|
||||
style={{
|
||||
height: clipHeight,
|
||||
left: `${(clip.x / TL_W) * 100}%`,
|
||||
width: `${(clip.w / TL_W) * 100}%`,
|
||||
top: clipTop,
|
||||
}}
|
||||
animate={{
|
||||
left: `${(clip.x / TL_W) * 100}%`,
|
||||
width: `${(clip.w / TL_W) * 100}%`,
|
||||
top: clipTop,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 25 }}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full rounded overflow-hidden flex flex-col"
|
||||
style={{
|
||||
backgroundColor: isSelected ? 'hsl(43 50% 45%)' : 'hsl(43 45% 40%)',
|
||||
boxShadow: isSelected
|
||||
? 'inset 0 0 0 1px hsl(43 50% 55%), 0 0 0 1px hsl(30 10% 94% / 0.4)'
|
||||
: 'inset 0 0 0 1px hsl(30 10% 94% / 0.1)',
|
||||
}}
|
||||
>
|
||||
{/* Profile label — scaled to bypass browser min font size */}
|
||||
<div className="shrink-0 relative" style={{ height: 9 }}>
|
||||
<span
|
||||
className="text-[10px] font-medium leading-none absolute top-0 left-0.5 origin-top-left opacity-80 whitespace-nowrap"
|
||||
style={{ color: accentFg, transform: 'scale(0.75)' }}
|
||||
>
|
||||
{clip.profile}
|
||||
</span>
|
||||
</div>
|
||||
{/* Waveform — absolutely positioned so it never affects clip width */}
|
||||
<div className="absolute left-0 right-0 bottom-0" style={{ top: 9 }}>
|
||||
<MiniWaveform seed={clip.seed} color={accentFg} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Trim handles on selected */}
|
||||
{isSelected && (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-1 rounded-l"
|
||||
style={{ backgroundColor: 'hsl(30 10% 94% / 0.25)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 rounded-r"
|
||||
style={{ backgroundColor: 'hsl(30 10% 94% / 0.25)' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Playhead */}
|
||||
<motion.div
|
||||
className="absolute top-0 bottom-0 w-[2px] rounded-full z-20 pointer-events-none"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
animate={{ left: `${(playheadX / TL_W) * 100}%` }}
|
||||
transition={{ duration: 0.05, ease: 'linear' }}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-0.5 left-1/2 -translate-x-1/2 w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Animation: Effects Pipeline ────────────────────────────────────────────
|
||||
|
||||
function EffectsAnimation() {
|
||||
const [activeEffect, setActiveEffect] = useState(0);
|
||||
const effects = [
|
||||
{ name: 'Pitch Shift', param: '-3 semitones', color: '#3b82f6' },
|
||||
{ name: 'Reverb', param: 'Room 0.7', color: '#8b5cf6' },
|
||||
{ name: 'Compressor', param: '-15 dB', color: '#ec4899' },
|
||||
{ name: 'Low-Pass', param: '6000 Hz', color: '#14b8a6' },
|
||||
];
|
||||
|
||||
// Waveform bars — original shape
|
||||
const rawBars = [0.3, 0.6, 0.8, 0.5, 0.9, 0.4, 0.7, 0.3, 0.6, 0.5, 0.8, 0.4, 0.7, 0.9, 0.3];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setActive((p) => (p + 1) % samples.length);
|
||||
}, 2000);
|
||||
setActiveEffect((p) => (p + 1) % effects.length);
|
||||
}, 2200);
|
||||
return () => clearInterval(interval);
|
||||
}, [samples.length]);
|
||||
}, [effects.length]);
|
||||
|
||||
return (
|
||||
<div className="h-40 w-full flex flex-col justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4 gap-1.5">
|
||||
{samples.map((s, i) => (
|
||||
<motion.div
|
||||
key={s.label}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded border text-[9px]"
|
||||
animate={{
|
||||
borderColor: i === active ? 'hsl(43 50% 45% / 0.4)' : 'rgba(255,255,255,0.06)',
|
||||
backgroundColor: i === active ? 'hsl(43 50% 45% / 0.06)' : 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<span
|
||||
className={`flex-1 font-mono truncate ${i === active ? 'text-accent' : 'text-ink-faint'}`}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
<span className="text-ink-faint shrink-0">{s.dur}</span>
|
||||
<div className="w-10 h-1 rounded-full bg-app-line shrink-0 overflow-hidden">
|
||||
<div className="h-40 w-full flex flex-col items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4 gap-3">
|
||||
{/* Effects chain */}
|
||||
<div className="flex items-center gap-1">
|
||||
{effects.map((fx, i) => (
|
||||
<div key={fx.name} className="flex items-center gap-1">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-accent"
|
||||
animate={{ width: i === active ? `${s.quality * 100}%` : '0%' }}
|
||||
transition={{ duration: 0.5, delay: i === active ? 0.2 : 0 }}
|
||||
/>
|
||||
className="text-[8px] px-2 py-0.5 rounded-full border font-medium"
|
||||
animate={{
|
||||
borderColor: i <= activeEffect ? `${fx.color}60` : 'rgba(255,255,255,0.06)',
|
||||
backgroundColor: i <= activeEffect ? `${fx.color}15` : 'rgba(255,255,255,0.02)',
|
||||
color: i <= activeEffect ? fx.color : 'rgba(255,255,255,0.3)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{fx.name}
|
||||
</motion.div>
|
||||
{i < effects.length - 1 && (
|
||||
<motion.span
|
||||
className="text-[8px]"
|
||||
animate={{
|
||||
color: i < activeEffect ? 'rgba(255,255,255,0.3)' : 'rgba(255,255,255,0.08)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
→
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Waveform that morphs as effects are applied */}
|
||||
<div className="flex items-center gap-[2px] h-10 w-full max-w-[200px] justify-center">
|
||||
{rawBars.map((h, i) => {
|
||||
// Each effect stage progressively transforms the shape
|
||||
const shifted = activeEffect >= 0 ? h * (0.7 + 0.3 * Math.sin(i * 0.8)) : h;
|
||||
const dampened = activeEffect >= 1 ? shifted * (0.6 + 0.4 * Math.cos(i * 0.3)) : shifted;
|
||||
const compressed = activeEffect >= 2 ? 0.3 + dampened * 0.5 : dampened;
|
||||
const filtered = activeEffect >= 3 ? compressed * (1 - i * 0.03) : compressed;
|
||||
const finalH = Math.max(0.08, Math.min(1, filtered));
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`bar-${i}`}
|
||||
className="w-[3px] rounded-full"
|
||||
animate={{
|
||||
height: `${finalH * 100}%`,
|
||||
backgroundColor: effects[activeEffect].color,
|
||||
}}
|
||||
transition={{
|
||||
height: { duration: 0.5, delay: i * 0.02, ease: 'easeInOut' },
|
||||
backgroundColor: { duration: 0.4 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Active effect detail */}
|
||||
<motion.div
|
||||
className="text-[9px] font-mono text-ink-faint"
|
||||
key={activeEffect}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{effects[activeEffect].name}: {effects[activeEffect].param}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -332,51 +645,115 @@ function TranscriptionAnimation() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Animation: Cross-Platform ──────────────────────────────────────────────
|
||||
// ─── Animation: Unlimited Length ─────────────────────────────────────────────
|
||||
|
||||
function CrossPlatformAnimation() {
|
||||
const [active, setActive] = useState(0);
|
||||
const platforms = [
|
||||
{ name: 'macOS', icon: '🍎', detail: 'Metal acceleration' },
|
||||
{ name: 'Windows', icon: '🪟', detail: 'CUDA acceleration' },
|
||||
{ name: 'Linux', icon: '🐧', detail: 'CUDA acceleration' },
|
||||
function UnlimitedLengthAnimation() {
|
||||
const [phase, setPhase] = useState(0);
|
||||
|
||||
const chunks = [
|
||||
'The morning sun crept over the mountains, casting long shadows across the valley below.',
|
||||
'Birds stirred in the canopy, their songs weaving through the cool air like threads of gold.',
|
||||
'Far below, a river wound its way through ancient stones, carrying whispers of the night.',
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setActive((p) => (p + 1) % platforms.length);
|
||||
setPhase((p) => (p + 1) % 4); // 0-2 = processing chunks, 3 = crossfade/done
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [platforms.length]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-40 w-full flex items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4">
|
||||
<div className="flex gap-3">
|
||||
{platforms.map((p, i) => (
|
||||
<div className="h-40 w-full flex flex-col items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4 gap-2.5">
|
||||
{/* Chunk pills */}
|
||||
<div className="flex flex-col gap-1 w-full max-w-[220px]">
|
||||
{chunks.map((chunk, i) => (
|
||||
<motion.div
|
||||
key={p.name}
|
||||
className="flex flex-col items-center gap-2 px-3 py-2.5 rounded-lg border"
|
||||
key={`chunk-${i}`}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded border text-[8px]"
|
||||
animate={{
|
||||
borderColor: i === active ? 'hsl(43 50% 45% / 0.4)' : 'rgba(255,255,255,0.06)',
|
||||
backgroundColor: i === active ? 'hsl(43 50% 45% / 0.06)' : 'rgba(255,255,255,0.02)',
|
||||
scale: i === active ? 1.05 : 1,
|
||||
borderColor:
|
||||
phase === 3
|
||||
? 'hsl(43 50% 45% / 0.3)'
|
||||
: i === phase
|
||||
? 'hsl(43 50% 45% / 0.5)'
|
||||
: i < phase
|
||||
? 'rgba(255,255,255,0.12)'
|
||||
: 'rgba(255,255,255,0.06)',
|
||||
backgroundColor:
|
||||
phase === 3
|
||||
? 'hsl(43 50% 45% / 0.04)'
|
||||
: i === phase
|
||||
? 'hsl(43 50% 45% / 0.08)'
|
||||
: i < phase
|
||||
? 'rgba(255,255,255,0.04)'
|
||||
: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<span className="text-lg">{p.icon}</span>
|
||||
{/* Status indicator */}
|
||||
<motion.div
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
animate={{
|
||||
backgroundColor:
|
||||
phase === 3
|
||||
? 'hsl(43 50% 50%)'
|
||||
: i === phase
|
||||
? 'hsl(43 50% 50%)'
|
||||
: i < phase
|
||||
? 'rgba(255,255,255,0.3)'
|
||||
: 'rgba(255,255,255,0.1)',
|
||||
boxShadow:
|
||||
i === phase && phase < 3 ? '0 0 6px hsl(43 50% 50%)' : '0 0 0px transparent',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
<span
|
||||
className={`text-[9px] font-medium ${i === active ? 'text-accent' : 'text-ink-faint'}`}
|
||||
className={`truncate font-mono ${
|
||||
phase === 3 || i <= phase ? 'text-ink-dull' : 'text-ink-faint/50'
|
||||
}`}
|
||||
>
|
||||
{p.name}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[7px] font-mono ${i === active ? 'text-ink-dull' : 'text-ink-faint/50'}`}
|
||||
>
|
||||
{p.detail}
|
||||
{chunk}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Crossfade / result bar */}
|
||||
<div className="flex items-center gap-1 w-full max-w-[220px]">
|
||||
{chunks.map((_, i) => (
|
||||
<motion.div
|
||||
key={`seg-${i}`}
|
||||
className="h-1.5 flex-1 rounded-full"
|
||||
animate={{
|
||||
backgroundColor:
|
||||
phase === 3
|
||||
? 'hsl(43 50% 45%)'
|
||||
: i < phase
|
||||
? 'rgba(255,255,255,0.2)'
|
||||
: i === phase
|
||||
? 'hsl(43 50% 45% / 0.5)'
|
||||
: 'rgba(255,255,255,0.06)',
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status text */}
|
||||
<motion.div
|
||||
className="text-[9px] font-mono"
|
||||
key={phase}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<span className={phase === 3 ? 'text-accent' : 'text-ink-faint'}>
|
||||
{phase < 3
|
||||
? `generating chunk ${phase + 1} of ${chunks.length}...`
|
||||
: 'crossfaded & ready'}
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -387,7 +764,7 @@ const FEATURES = [
|
||||
{
|
||||
title: 'Near-Perfect Voice Cloning',
|
||||
description:
|
||||
'Powered by Qwen3-TTS for exceptional voice quality. Clone any voice from a few seconds of audio with natural intonation and emotion.',
|
||||
'Multiple TTS engines for exceptional voice quality. Clone any voice from a few seconds of audio with natural intonation and emotion.',
|
||||
icon: Mic,
|
||||
animation: VoiceCloningAnimation,
|
||||
},
|
||||
@@ -399,16 +776,16 @@ const FEATURES = [
|
||||
animation: StoriesAnimation,
|
||||
},
|
||||
{
|
||||
title: 'Multi-Sample Support',
|
||||
title: 'Audio Effects Pipeline',
|
||||
description:
|
||||
'Combine multiple voice samples for higher quality results. More samples means more natural-sounding speech synthesis.',
|
||||
icon: Layers,
|
||||
animation: MultiSampleAnimation,
|
||||
'Apply pitch shift, reverb, delay, compression, and more — then save as presets. Preview effects live and set defaults per voice profile.',
|
||||
icon: Sparkles,
|
||||
animation: EffectsAnimation,
|
||||
},
|
||||
{
|
||||
title: 'Local or Remote',
|
||||
description:
|
||||
'Run GPU inference locally with Metal or CUDA, or connect to a remote machine. One-click server setup with automatic discovery.',
|
||||
'Run GPU inference locally with Metal, CUDA, ROCm, Intel Arc, or DirectML — or connect to a remote machine. One-click server setup with automatic discovery.',
|
||||
icon: Cloud,
|
||||
animation: LocalRemoteAnimation,
|
||||
},
|
||||
@@ -420,11 +797,11 @@ const FEATURES = [
|
||||
animation: TranscriptionAnimation,
|
||||
},
|
||||
{
|
||||
title: 'Cross-Platform',
|
||||
title: 'Unlimited Generation Length',
|
||||
description:
|
||||
'Available for macOS, Windows, and Linux. No Python installation required — everything is bundled.',
|
||||
icon: Monitor,
|
||||
animation: CrossPlatformAnimation,
|
||||
'Generate up to 50,000 characters in one go. Text is auto-split at sentence boundaries, generated per-chunk, and crossfaded seamlessly.',
|
||||
icon: TextCursorInput,
|
||||
animation: UnlimitedLengthAnimation,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
|
||||
import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
|
||||
@@ -10,6 +10,26 @@ function formatDuration(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Unlock Web Audio on iOS Safari — must be called from a user gesture (click/tap)
|
||||
let audioContextUnlocked = false;
|
||||
export function unlockAudioContext() {
|
||||
if (audioContextUnlocked) return;
|
||||
try {
|
||||
const ctx = new (
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
||||
)();
|
||||
const buffer = ctx.createBuffer(1, 1, 22050);
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
source.start(0);
|
||||
audioContextUnlocked = true;
|
||||
} catch {
|
||||
// Silently fail — non-Safari browsers don't need this
|
||||
}
|
||||
}
|
||||
|
||||
interface LandingAudioPlayerProps {
|
||||
audioUrl: string;
|
||||
title: string;
|
||||
@@ -145,24 +165,23 @@ export function LandingAudioPlayer({
|
||||
if (!ws || !isReady) return;
|
||||
|
||||
if (playing) {
|
||||
// Resume the AudioContext first (required for iOS Safari after unlock)
|
||||
const backend = ws.getMediaElement();
|
||||
if (backend && 'context' in backend) {
|
||||
const ctx = (backend as unknown as { context: AudioContext }).context;
|
||||
if (ctx?.state === 'suspended') ctx.resume();
|
||||
}
|
||||
ws.play()
|
||||
.then(() => {
|
||||
console.log(
|
||||
'[Player] play succeeded, isPlaying:',
|
||||
ws.isPlaying(),
|
||||
'currentTime:',
|
||||
ws.getCurrentTime(),
|
||||
);
|
||||
setTimeout(() => {
|
||||
console.log(
|
||||
'[Player] 1s later, isPlaying:',
|
||||
ws.isPlaying(),
|
||||
'currentTime:',
|
||||
ws.getCurrentTime(),
|
||||
);
|
||||
}, 1000);
|
||||
console.log('[Player] play succeeded');
|
||||
})
|
||||
.catch((e) => console.error('[Player] play failed', e));
|
||||
.catch((e: Error) => {
|
||||
if (e.name === 'NotAllowedError') {
|
||||
console.warn('[Player] Autoplay blocked by browser — waiting for user gesture');
|
||||
} else {
|
||||
console.error('[Player] play failed', e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ws.pause();
|
||||
}
|
||||
@@ -180,34 +199,27 @@ export function LandingAudioPlayer({
|
||||
wavesurferRef.current.playPause();
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (wavesurferRef.current) {
|
||||
wavesurferRef.current.pause();
|
||||
}
|
||||
setIsPlaying(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 right-0 border-t border-border bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-30">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="px-4 py-3 flex flex-col md:flex-row md:items-center gap-2 md:gap-4">
|
||||
{/* Waveform — full width row on mobile, inline on desktop */}
|
||||
<div className="min-w-0 min-h-[60px] md:min-h-[80px] md:flex-1 md:order-2">
|
||||
<div ref={waveformRef} className="w-full h-full min-h-[60px] md:min-h-[80px]" />
|
||||
</div>
|
||||
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center gap-3 md:contents">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
disabled={!isReady}
|
||||
className="h-10 w-10 rounded-full flex items-center justify-center hover:bg-muted shrink-0 disabled:opacity-50"
|
||||
className="h-10 w-10 rounded-full flex items-center justify-center hover:bg-muted shrink-0 disabled:opacity-50 md:order-1"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />}
|
||||
</button>
|
||||
|
||||
{/* Waveform */}
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<div ref={waveformRef} className="w-full min-h-[80px]" />
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground shrink-0">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 md:order-3">
|
||||
<span className="font-mono text-xs">{formatDuration(currentTime)}</span>
|
||||
<span className="text-xs">/</span>
|
||||
<span className="font-mono text-xs">{formatDuration(duration)}</span>
|
||||
@@ -215,7 +227,7 @@ export function LandingAudioPlayer({
|
||||
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block">
|
||||
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block md:order-4">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
@@ -223,7 +235,7 @@ export function LandingAudioPlayer({
|
||||
{/* Loop */}
|
||||
<button
|
||||
onClick={() => setIsLooping(!isLooping)}
|
||||
className={`h-8 w-8 flex items-center justify-center rounded-sm shrink-0 hover:bg-muted ${
|
||||
className={`h-8 w-8 flex items-center justify-center rounded-sm shrink-0 hover:bg-muted md:order-5 ${
|
||||
isLooping ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
@@ -231,7 +243,7 @@ export function LandingAudioPlayer({
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="flex items-center gap-2 shrink-0 w-[120px]">
|
||||
<div className="flex items-center gap-2 shrink-0 w-[120px] md:order-6">
|
||||
<button
|
||||
onClick={() => setVolume(volume > 0 ? 0 : 0.75)}
|
||||
className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm"
|
||||
@@ -251,14 +263,6 @@ export function LandingAudioPlayer({
|
||||
className="flex-1 h-1 appearance-none bg-muted rounded-full accent-foreground cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm shrink-0"
|
||||
>
|
||||
<X className="h-5 w-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,34 @@
|
||||
|
||||
import { Github } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { GITHUB_REPO } from '@/lib/constants';
|
||||
|
||||
function formatStarCount(count: number): string {
|
||||
if (count >= 1000) {
|
||||
const k = count / 1000;
|
||||
return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`;
|
||||
}
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
export function Navbar() {
|
||||
const [starCount, setStarCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/stars')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch stars');
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (typeof data.count === 'number') setStarCount(data.count);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch star count:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-3">
|
||||
@@ -50,8 +75,12 @@ export function Navbar() {
|
||||
className="flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground hover:border-border"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Star on GitHub</span>
|
||||
<span className="sm:hidden">GitHub</span>
|
||||
<span className="text-[13px] font-medium">Star</span>
|
||||
{starCount !== null && (
|
||||
<span className="border-l border-border/60 pl-2 text-[13px] font-semibold text-foreground">
|
||||
{formatStarCount(starCount)}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Mic, Monitor, Upload } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
// ─── Waveform bars generator ────────────────────────────────────────────────
|
||||
|
||||
function generateWaveformBars(count: number, seed: number): number[] {
|
||||
const bars: number[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = i / count;
|
||||
// Speech-like envelope: ramp up, sustain, taper
|
||||
const envelope = Math.sin(x * Math.PI) * 0.8 + 0.2;
|
||||
// Layered pseudo-random noise
|
||||
const n1 = Math.sin(seed * 127.1 + i * 43.7) * 0.5 + 0.5;
|
||||
const n2 = Math.sin(seed * 269.5 + i * 17.3) * 0.3 + 0.5;
|
||||
const n3 = Math.sin(seed * 53.9 + i * 97.1) * 0.2 + 0.5;
|
||||
const noise = (n1 + n2 + n3) / 3;
|
||||
bars.push(envelope * noise);
|
||||
}
|
||||
return bars;
|
||||
}
|
||||
|
||||
// ─── Animated waveform background ───────────────────────────────────────────
|
||||
|
||||
function WaveformBackground({ active }: { active: boolean }) {
|
||||
const bars = useMemo(() => generateWaveformBars(60, 42), []);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none flex items-end justify-center overflow-hidden">
|
||||
<div className="flex items-end gap-[2px] w-full h-full px-4 pb-4">
|
||||
{bars.map((h, i) => {
|
||||
const maxH = 120; // max bar height in px
|
||||
const baseH = 4;
|
||||
const activeH = baseH + h * maxH;
|
||||
const idleH = baseH + h * maxH * 0.25;
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="flex-1 rounded-full bg-accent"
|
||||
animate={{
|
||||
opacity: active ? 0.35 : 0.1,
|
||||
height: active ? [idleH, activeH, idleH * 1.5, activeH * 0.7, idleH] : idleH,
|
||||
}}
|
||||
transition={
|
||||
active
|
||||
? {
|
||||
duration: 1.0 + (i % 5) * 0.12,
|
||||
repeat: Infinity,
|
||||
repeatType: 'mirror',
|
||||
delay: (i % 7) * 0.04,
|
||||
ease: 'easeInOut',
|
||||
}
|
||||
: { duration: 0.6 }
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab content panels ─────────────────────────────────────────────────────
|
||||
|
||||
function UploadPanel() {
|
||||
const [hasFile, setHasFile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate file drop after 2s
|
||||
const t1 = setTimeout(() => setHasFile(true), 2000);
|
||||
const t2 = setTimeout(() => setHasFile(false), 5000);
|
||||
return () => {
|
||||
clearTimeout(t1);
|
||||
clearTimeout(t2);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col items-center justify-center gap-3 p-6 border-2 rounded-lg min-h-[180px] transition-colors duration-300 ${
|
||||
hasFile ? 'border-accent bg-accent/5' : 'border-dashed border-muted-foreground/25'
|
||||
}`}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{!hasFile ? (
|
||||
<motion.div
|
||||
key="idle"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex flex-col items-center gap-3"
|
||||
>
|
||||
<div className="h-10 px-5 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
|
||||
<Upload className="h-4 w-4" />
|
||||
Choose File
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Drag and drop an audio file, or click to browse.
|
||||
<br />
|
||||
Maximum duration: 30 seconds.
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="file"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex flex-col items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium">sample-voice-clip.wav</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>0:04</span>
|
||||
</div>
|
||||
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Mic className="h-3 w-3" />
|
||||
Transcribe
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordPanel() {
|
||||
const [state, setState] = useState<'idle' | 'recording' | 'done'>('idle');
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const t1 = setTimeout(() => setState('recording'), 1500);
|
||||
const t2 = setTimeout(() => setState('done'), 5500);
|
||||
const t3 = setTimeout(() => {
|
||||
setState('idle');
|
||||
setElapsed(0);
|
||||
}, 8000);
|
||||
return () => {
|
||||
clearTimeout(t1);
|
||||
clearTimeout(t2);
|
||||
clearTimeout(t3);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Timer
|
||||
useEffect(() => {
|
||||
if (state !== 'recording') return;
|
||||
setElapsed(0);
|
||||
const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [state]);
|
||||
|
||||
const formatTime = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col items-center justify-center gap-3 p-6 border-2 rounded-lg min-h-[180px] overflow-hidden transition-colors duration-300 ${
|
||||
state === 'recording'
|
||||
? 'border-accent bg-accent/5'
|
||||
: state === 'done'
|
||||
? 'border-accent bg-accent/5'
|
||||
: 'border-dashed border-muted-foreground/25'
|
||||
}`}
|
||||
>
|
||||
<WaveformBackground active={state === 'recording'} />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{state === 'idle' && (
|
||||
<motion.div
|
||||
key="idle"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="relative z-10 flex flex-col items-center gap-3"
|
||||
>
|
||||
<div className="h-10 px-5 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
|
||||
<Mic className="h-4 w-4" />
|
||||
Start Recording
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Click to record from your microphone.
|
||||
<br />
|
||||
Maximum duration: 30 seconds.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{state === 'recording' && (
|
||||
<motion.div
|
||||
key="recording"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="relative z-10 flex flex-col items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-3 w-3 rounded-full bg-accent animate-pulse" />
|
||||
<span className="text-lg font-mono font-semibold">{formatTime(elapsed)}</span>
|
||||
</div>
|
||||
<div className="h-9 px-4 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
|
||||
<div className="h-3 w-3 rounded-sm bg-accent-foreground" />
|
||||
Stop Recording
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{formatTime(30 - elapsed)} remaining</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{state === 'done' && (
|
||||
<motion.div
|
||||
key="done"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="relative z-10 flex flex-col items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium">Recording complete</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>0:04</span>
|
||||
</div>
|
||||
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Mic className="h-3 w-3" />
|
||||
Transcribe
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemPanel() {
|
||||
const [state, setState] = useState<'idle' | 'capturing' | 'done'>('idle');
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const t1 = setTimeout(() => setState('capturing'), 1500);
|
||||
const t2 = setTimeout(() => setState('done'), 5500);
|
||||
const t3 = setTimeout(() => {
|
||||
setState('idle');
|
||||
setElapsed(0);
|
||||
}, 8000);
|
||||
return () => {
|
||||
clearTimeout(t1);
|
||||
clearTimeout(t2);
|
||||
clearTimeout(t3);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'capturing') return;
|
||||
setElapsed(0);
|
||||
const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [state]);
|
||||
|
||||
const formatTime = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col items-center justify-center gap-3 p-6 border-2 rounded-lg min-h-[180px] overflow-hidden transition-colors duration-300 ${
|
||||
state === 'capturing'
|
||||
? 'border-accent bg-accent/5'
|
||||
: state === 'done'
|
||||
? 'border-accent bg-accent/5'
|
||||
: 'border-dashed border-muted-foreground/25'
|
||||
}`}
|
||||
>
|
||||
<WaveformBackground active={state === 'capturing'} />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{state === 'idle' && (
|
||||
<motion.div
|
||||
key="idle"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="relative z-10 flex flex-col items-center gap-3"
|
||||
>
|
||||
<div className="h-10 px-5 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
|
||||
<Monitor className="h-4 w-4" />
|
||||
Start Capture
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Capture audio playing on your system.
|
||||
<br />
|
||||
Maximum duration: 30 seconds.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{state === 'capturing' && (
|
||||
<motion.div
|
||||
key="capturing"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="relative z-10 flex flex-col items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-3 w-3 rounded-full bg-accent animate-pulse" />
|
||||
<span className="text-lg font-mono font-semibold">{formatTime(elapsed)}</span>
|
||||
</div>
|
||||
<div className="h-9 px-4 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
|
||||
<div className="h-3 w-3 rounded-sm bg-accent-foreground" />
|
||||
Stop Capture
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{formatTime(30 - elapsed)} remaining</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{state === 'done' && (
|
||||
<motion.div
|
||||
key="done"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="relative z-10 flex flex-col items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium">Capture complete</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>0:04</span>
|
||||
</div>
|
||||
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Mic className="h-3 w-3" />
|
||||
Transcribe
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab selector ───────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = [
|
||||
{ id: 'upload' as const, label: 'Upload', icon: Upload },
|
||||
{ id: 'record' as const, label: 'Microphone', icon: Mic },
|
||||
{ id: 'system' as const, label: 'System Audio', icon: Monitor },
|
||||
];
|
||||
|
||||
type TabId = (typeof TABS)[number]['id'];
|
||||
|
||||
// ─── Main section ───────────────────────────────────────────────────────────
|
||||
|
||||
export function VoiceCreator() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('record');
|
||||
const [cycleKey, setCycleKey] = useState(0);
|
||||
|
||||
// Auto-cycle tabs
|
||||
useEffect(() => {
|
||||
const tabOrder: TabId[] = ['record', 'upload', 'system'];
|
||||
let idx = tabOrder.indexOf(activeTab);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
idx = (idx + 1) % tabOrder.length;
|
||||
setActiveTab(tabOrder[idx]);
|
||||
setCycleKey((k) => k + 1);
|
||||
}, 9000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<section className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-16 items-center">
|
||||
{/* Left: Copy */}
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Clone any voice in seconds
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Three ways to capture a voice sample. Upload a clip, record from your microphone, or
|
||||
capture audio playing on your system. Voicebox clones the voice from as little as 3
|
||||
seconds of audio.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Upload className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">Upload a clip</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Drag and drop any audio file — WAV, MP3, FLAC, or WebM.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Mic className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">Record from microphone</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Live waveform preview while you record. Up to 30 seconds.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Monitor className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">System audio capture</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Clone a voice from a YouTube video, podcast, or any app playing audio.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Animated UI mock */}
|
||||
<div className="rounded-xl border border-app-line bg-app-darkBox overflow-hidden">
|
||||
<div className="p-5">
|
||||
{/* Tab bar */}
|
||||
<div className="flex rounded-lg border border-border bg-card/50 p-1 mb-4">
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
setCycleKey((k) => k + 1);
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Panel */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${activeTab}-${cycleKey}`}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{activeTab === 'upload' && <UploadPanel />}
|
||||
{activeTab === 'record' && <RecordPanel />}
|
||||
{activeTab === 'system' && <SystemPanel />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,10 @@ let cachedReleaseInfo: ReleaseInfo | null = null;
|
||||
let cacheTimestamp: number = 0;
|
||||
const CACHE_DURATION = 1000 * 60 * 10; // 10 minutes
|
||||
|
||||
// Cache for star count
|
||||
let cachedStarCount: number | null = null;
|
||||
let starCacheTimestamp: number = 0;
|
||||
|
||||
/**
|
||||
* Fetches the latest release from GitHub and extracts download links
|
||||
*/
|
||||
@@ -92,3 +96,38 @@ export async function getLatestRelease(): Promise<ReleaseInfo> {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the star count for the repo from GitHub
|
||||
*/
|
||||
export async function getStarCount(): Promise<number> {
|
||||
const now = Date.now();
|
||||
if (cachedStarCount !== null && now - starCacheTimestamp < CACHE_DURATION) {
|
||||
return cachedStarCount;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${GITHUB_API_BASE}/repos/${GITHUB_REPO}`, {
|
||||
next: { revalidate: 600 },
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const repo = await response.json();
|
||||
const count = repo.stargazers_count ?? 0;
|
||||
|
||||
cachedStarCount = count;
|
||||
starCacheTimestamp = now;
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch star count:', error);
|
||||
if (cachedStarCount !== null) return cachedStarCount;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user