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:
James Pine
2026-03-15 04:53:28 -07:00
parent 8377152d86
commit f80782a90a
16 changed files with 1756 additions and 462 deletions
+511 -134
View File
@@ -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 }}
>
&rarr;
</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,
},
];