mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 07:10:40 -07:00
feat(capture): dictation, personalities, 0.5.0
Ships the Capture release end to end. Global-hotkey dictation with synthetic paste into the focused app on macOS and Windows, an on-screen pill across recording / transcribing / refining, customizable push-to- talk and toggle chords, and an accessibility-permission prompt scoped to Settings → Captures with inline re-check feedback. Voice profiles gain optional personalities that power compose / rewrite / respond actions via a local Qwen3 LLM — shared with refinement, so there is one local LLM in the app, not two. Refinement hardened with deterministic Whisper-loop collapse before the LLM sees the transcript, per-capture flag snapshots for re-runs, and a ten-transcript evaluation harness across every bundled refinement size. Version bump 0.4.5 → 0.5.0. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ed2eec591a
commit
87c582ad54
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { Github } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AgentIntegration } from '@/components/AgentIntegration';
|
||||
import { CaptureHero } from '@/components/CaptureHero';
|
||||
import { CapturesMockup } from '@/components/CapturesMockup';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Navbar } from '@/components/Navbar';
|
||||
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
|
||||
import { GITHUB_REPO } from '@/lib/constants';
|
||||
|
||||
export default function CapturePage() {
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [totalDownloads, setTotalDownloads] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/releases')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch releases');
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.version) setVersion(data.version);
|
||||
if (data.totalDownloads != null) setTotalDownloads(data.totalDownloads);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch release info:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* ── Hero ─────────────────────────────────────────────────── */}
|
||||
<CaptureHero version={version} totalDownloads={totalDownloads} />
|
||||
|
||||
{/* ── Captures mockup ─────────────────────────────────────── */}
|
||||
<section className="relative border-t border-border py-24">
|
||||
<div className="mx-auto max-w-5xl px-6 text-center mb-14">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
The Captures tab
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground mb-4">
|
||||
Every capture, paired with audio and transcript.
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Hold the shortcut, speak, release — a capture lands in the Captures tab. Replay the
|
||||
original audio, re-transcribe with a different model, refine with a local LLM, copy to
|
||||
clipboard, or send it straight to any MCP-aware agent. Nothing leaves your machine.
|
||||
</p>
|
||||
</div>
|
||||
<CapturesMockup />
|
||||
</section>
|
||||
|
||||
{/* ── Feature bullets ─────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
Four STT engines, one picker
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Whisper, Whisper Turbo, Parakeet v3, Qwen3-ASR. Pick per-capture — broad
|
||||
multilingual, speed, non-English quality, or cross-platform coverage. All local,
|
||||
all downloadable from inside the app.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
LLM refinement that respects your words
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
A local Qwen model cleans ums, self-corrections, and punctuation — without
|
||||
rephrasing. Keep raw and refined side-by-side; the original audio is always kept.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
Archived by default
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Every dictation keeps both the audio and the transcript. Search, re-run, or turn
|
||||
any capture into a voice sample for cloning. Configurable retention — auto-expire
|
||||
or keep forever.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Agent voice output ──────────────────────────────────── */}
|
||||
<AgentIntegration />
|
||||
|
||||
{/* ── Bottom CTA ──────────────────────────────────────────── */}
|
||||
<section id="download" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Install Voicebox, start dictating.
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Free, open-source, local. No account, no API keys, no per-character fees.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl mx-auto">
|
||||
<a
|
||||
href="/download?platform=macArm"
|
||||
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"
|
||||
>
|
||||
<AppleIcon 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">macOS</div>
|
||||
<div className="text-xs text-muted-foreground">Apple Silicon (ARM)</div>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="/download?platform=macIntel"
|
||||
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"
|
||||
>
|
||||
<AppleIcon 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">macOS</div>
|
||||
<div className="text-xs text-muted-foreground">Intel (x64)</div>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="/download?platform=windows"
|
||||
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"
|
||||
>
|
||||
<WindowsIcon 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">Windows</div>
|
||||
<div className="text-xs text-muted-foreground">64-bit (MSI)</div>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="/linux-install"
|
||||
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 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">Build from source</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<a
|
||||
href={`${GITHUB_REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
View all releases on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 text-center">
|
||||
<a
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground/70 hover:text-foreground transition-colors"
|
||||
>
|
||||
← See everything Voicebox can do
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+26
-260
@@ -1,20 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Github,
|
||||
Globe,
|
||||
Languages,
|
||||
MessageSquare,
|
||||
SlidersHorizontal,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {Github} from "lucide-react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {AgentIntegration} from "@/components/AgentIntegration";
|
||||
import {ApiSection} from "@/components/ApiSection";
|
||||
import {CaptureSection} from "@/components/CaptureSection";
|
||||
import {ControlUI} from "@/components/ControlUI";
|
||||
import {Features} from "@/components/Features";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {AppleIcon, LinuxIcon, WindowsIcon} from "@/components/PlatformIcons";
|
||||
import {SupportedModels} from "@/components/SupportedModels";
|
||||
import {TutorialsSection} from "@/components/TutorialsSection";
|
||||
import {VoiceCreator} from "@/components/VoiceCreator";
|
||||
import {GITHUB_REPO} from "@/lib/constants";
|
||||
@@ -64,10 +60,18 @@ export default function Home() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Kicker */}
|
||||
<div
|
||||
className="fade-in mb-6 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent"
|
||||
style={{animationDelay: "50ms"}}
|
||||
>
|
||||
The open-source AI voice studio
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<div className="fade-in relative" style={{animationDelay: "100ms"}}>
|
||||
<h1 className="text-5xl font-bold tracking-tighter leading-[0.9] text-foreground md:text-7xl lg:text-8xl">
|
||||
Clone any voice, in seconds.
|
||||
Clone, dictate and create.
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -76,10 +80,10 @@ 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 with support for multiple TTS
|
||||
engines. Clone any voice, generate natural speech, and compose
|
||||
multi-voice projects. All running{" "}
|
||||
<b className="text-white">locally on your machine.</b>
|
||||
Clone voices, generate speech across seven TTS engines, dictate into
|
||||
any app, and talk to agents in voices you own. A free and local alternative
|
||||
to ElevenLabs and WisprFlow, running{" "}
|
||||
<b className="text-white">entirely on your machine.</b>
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
@@ -131,258 +135,20 @@ export default function Home() {
|
||||
{/* ── Voice Creator ────────────────────────────────────────── */}
|
||||
<VoiceCreator />
|
||||
|
||||
{/* ── Tutorials ────────────────────────────────────────────── */}
|
||||
<TutorialsSection />
|
||||
{/* ── Capture (dictation + STT + play as voice) ───────────── */}
|
||||
<CaptureSection />
|
||||
|
||||
{/* ── Agent integration (speak primitive + MCP) ───────────── */}
|
||||
<AgentIntegration />
|
||||
|
||||
{/* ── API Section ──────────────────────────────────────────── */}
|
||||
<ApiSection />
|
||||
|
||||
{/* ── Models ─────────────────────────────────────────────────── */}
|
||||
<section id="about" className="border-t border-border py-24">
|
||||
<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>
|
||||
{/* ── Tutorials ────────────────────────────────────────────── */}
|
||||
<TutorialsSection />
|
||||
|
||||
<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>
|
||||
|
||||
{/* Qwen CustomVoice */}
|
||||
<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">
|
||||
Qwen CustomVoice
|
||||
</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">
|
||||
Nine premium preset speakers with natural-language style
|
||||
control. Tell the model how to deliver — "speak slowly with
|
||||
warmth", "authoritative and clear" — and it adapts tone,
|
||||
emotion, and pace.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
Instruct control
|
||||
</span>
|
||||
<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">
|
||||
9 preset voices
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HumeAI TADA */}
|
||||
<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">
|
||||
TADA
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
by Hume AI
|
||||
</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">
|
||||
3B
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
1B
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Speech-language model with text-acoustic dual alignment. Built
|
||||
for long-form generation — produces 700s+ of coherent audio
|
||||
without drift. Multilingual at 3B, English-focused at 1B.
|
||||
</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">
|
||||
Long-form coherent
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kokoro 82M */}
|
||||
<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">
|
||||
Kokoro
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
by hexgrad · Apache 2.0
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
82M
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Tiny 82M-parameter TTS that runs at CPU realtime with negligible
|
||||
VRAM. Pre-built voice styles instead of cloning — pick a voice,
|
||||
type, generate. Smallest footprint of any engine.
|
||||
</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" />
|
||||
CPU realtime
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
Preset voices
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* ── Supported models ─────────────────────────────────────── */}
|
||||
<SupportedModels />
|
||||
|
||||
{/* ── Download Section ─────────────────────────────────────── */}
|
||||
<section id="download" className="border-t border-border py-24">
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Eye, Sliders, Waypoints } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// ─── Scenarios (the agent console cycles through these) ────────────────────
|
||||
|
||||
type Scenario = {
|
||||
agent: string;
|
||||
voice: string;
|
||||
voiceGradient: [string, string];
|
||||
log: { prefix: string; text: string; tone: 'accent' | 'success' | 'dim' }[];
|
||||
utterance: string;
|
||||
};
|
||||
|
||||
const SCENARIOS: Scenario[] = [
|
||||
{
|
||||
agent: 'Claude Code',
|
||||
voice: 'Morgan',
|
||||
voiceGradient: ['#60a5fa', '#6366f1'],
|
||||
log: [
|
||||
{ prefix: '$', text: 'claude run', tone: 'accent' },
|
||||
{ prefix: '✓', text: 'Tests passing (42 files)', tone: 'success' },
|
||||
{ prefix: '✓', text: 'Build succeeded in 12.4s', tone: 'success' },
|
||||
{ prefix: '→', text: 'voicebox.speak({ profile: "Morgan" })', tone: 'dim' },
|
||||
],
|
||||
utterance: 'Tests passing. Ready to merge.',
|
||||
},
|
||||
{
|
||||
agent: 'Cursor',
|
||||
voice: 'Scarlett',
|
||||
voiceGradient: ['#34d399', '#14b8a6'],
|
||||
log: [
|
||||
{ prefix: '$', text: 'cursor agent:deploy', tone: 'accent' },
|
||||
{ prefix: '✓', text: 'Migration applied (4 tables)', tone: 'success' },
|
||||
{ prefix: '✓', text: 'Deploy complete', tone: 'success' },
|
||||
{ prefix: '→', text: 'voicebox.speak({ profile: "Scarlett" })', tone: 'dim' },
|
||||
],
|
||||
utterance: 'Deploy shipped. Prod is green.',
|
||||
},
|
||||
{
|
||||
agent: 'Cline',
|
||||
voice: 'Jarvis',
|
||||
voiceGradient: ['#a855f7', '#ec4899'],
|
||||
log: [
|
||||
{ prefix: '$', text: 'cline task:review', tone: 'accent' },
|
||||
{ prefix: '!', text: '3 files need attention', tone: 'dim' },
|
||||
{ prefix: '→', text: 'voicebox.speak({ profile: "Jarvis" })', tone: 'dim' },
|
||||
],
|
||||
utterance: 'Review ready. Three files to look at.',
|
||||
},
|
||||
];
|
||||
|
||||
const TONE_CLASSES: Record<Scenario['log'][number]['tone'], string> = {
|
||||
accent: 'text-accent',
|
||||
success: 'text-emerald-400/80',
|
||||
dim: 'text-ink-faint/70',
|
||||
};
|
||||
|
||||
// ─── Console mockup ─────────────────────────────────────────────────────────
|
||||
|
||||
function AgentConsole() {
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setIdx((i) => (i + 1) % SCENARIOS.length);
|
||||
}, 4200);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const scenario = SCENARIOS[idx];
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-app-line bg-app-darkerBox overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
{/* Titlebar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-app-line bg-app-darkBox/60">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-red-500/50" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/50" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500/50" />
|
||||
</div>
|
||||
<div className="flex-1 text-center">
|
||||
<span className="text-[10px] font-mono text-ink-faint/60">{scenario.agent}</span>
|
||||
</div>
|
||||
<div className="w-12" />
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5 font-mono text-[12px] leading-relaxed min-h-[280px] flex flex-col">
|
||||
{/* Log lines */}
|
||||
<div className="space-y-1.5 mb-5">
|
||||
{scenario.log.map((line, i) => (
|
||||
<motion.div
|
||||
key={`${idx}-line-${i}`}
|
||||
className="flex items-start gap-2"
|
||||
initial={{ opacity: 0, y: 2 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: i * 0.15 }}
|
||||
>
|
||||
<span className={`shrink-0 ${TONE_CLASSES[line.tone]}`}>{line.prefix}</span>
|
||||
<span
|
||||
className={
|
||||
line.tone === 'dim' ? 'text-ink-faint/70' : 'text-ink-dull'
|
||||
}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The pill in speaking state — the payoff */}
|
||||
<motion.div
|
||||
key={`pill-${idx}`}
|
||||
className="mt-auto self-start inline-flex items-center gap-2.5 px-3 h-9 rounded-full bg-black/55 backdrop-blur-sm shadow-[0_6px_20px_rgba(0,0,0,0.4)]"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, delay: 0.6 }}
|
||||
>
|
||||
<div
|
||||
className="h-4 w-4 rounded-full shrink-0 ring-1 ring-white/10"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${scenario.voiceGradient[0]}, ${scenario.voiceGradient[1]})`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-foreground/90">
|
||||
Speaking · <span className="text-accent">{scenario.voice}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-[2px] h-4">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<motion.div
|
||||
key={`bar-${scenario.voice}-${i}`}
|
||||
className="w-[2px] rounded-full bg-accent"
|
||||
animate={{ height: ['4px', '12px', '6px', '10px', '4px'] }}
|
||||
transition={{
|
||||
duration: 0.9,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.08,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* The utterance — what the agent said */}
|
||||
<motion.div
|
||||
key={`utter-${idx}`}
|
||||
className="mt-3 text-[11px] text-ink-dull"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.8 }}
|
||||
>
|
||||
“{scenario.utterance}”
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Code panel ─────────────────────────────────────────────────────────────
|
||||
|
||||
const MCP_CONFIG = `{
|
||||
"mcpServers": {
|
||||
"voicebox": {
|
||||
"command": "voicebox",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const SPEAK_EXAMPLE = `// In any MCP-aware agent:
|
||||
await voicebox.speak({
|
||||
text: "Deploy complete.",
|
||||
profile: "Morgan",
|
||||
})`;
|
||||
|
||||
function CodePanel() {
|
||||
return (
|
||||
<div className="rounded-xl border border-app-line bg-app-darkBox overflow-hidden flex flex-col">
|
||||
{/* MCP config */}
|
||||
<div className="p-5 border-b border-app-line">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[9px] font-mono text-accent font-semibold tabular-nums">
|
||||
01
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-ink-faint/70 uppercase tracking-wider">
|
||||
Add Voicebox to your MCP config
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono text-ink-dull leading-relaxed overflow-x-auto">
|
||||
{MCP_CONFIG}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Tool call */}
|
||||
<div className="p-5 flex-1">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[9px] font-mono text-accent font-semibold tabular-nums">
|
||||
02
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-ink-faint/70 uppercase tracking-wider">
|
||||
The tool is now available
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono text-ink-dull leading-relaxed overflow-x-auto">
|
||||
{SPEAK_EXAMPLE}
|
||||
</pre>
|
||||
|
||||
{/* Hint line */}
|
||||
<div className="mt-4 text-[10px] text-ink-faint/60 leading-relaxed">
|
||||
Also exposed as{' '}
|
||||
<code className="text-accent/80">POST /speak</code> for anything that
|
||||
doesn’t speak MCP — ACP, A2A, shell scripts, or custom harnesses.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Support bullets ────────────────────────────────────────────────────────
|
||||
|
||||
const BULLETS = [
|
||||
{
|
||||
icon: Sliders,
|
||||
title: 'Per-agent voice',
|
||||
description:
|
||||
'Bind each MCP client to a voice profile. Claude Code in Morgan, Cursor in Scarlett — you know which agent is talking without looking.',
|
||||
},
|
||||
{
|
||||
icon: Eye,
|
||||
title: 'Always visible',
|
||||
description:
|
||||
'Every agent-initiated speech surfaces the pill. No silent background TTS — you always see what’s coming out of your machine.',
|
||||
},
|
||||
{
|
||||
icon: Waypoints,
|
||||
title: 'Open protocols',
|
||||
description:
|
||||
'MCP ships day one. ACP, A2A, and anything else built on a tool-call primitive slots into the same endpoint.',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Section ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AgentIntegration() {
|
||||
return (
|
||||
<section id="agents" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
{/* Header */}
|
||||
<div className="max-w-3xl mx-auto text-center mb-14">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Agents
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-semibold tracking-tight text-foreground mb-5">
|
||||
Every agent gets a voice.
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base md:text-lg leading-relaxed">
|
||||
One tool call —{' '}
|
||||
<code className="text-accent font-mono text-[0.9em]">voicebox.speak</code> —
|
||||
and any MCP-aware agent can talk to you in a voice you’ve cloned. Claude Code,
|
||||
Cursor, Cline, or anything that speaks MCP.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Code + console split */}
|
||||
<div className="grid md:grid-cols-2 gap-6 mb-12">
|
||||
<CodePanel />
|
||||
<AgentConsole />
|
||||
</div>
|
||||
|
||||
{/* Bullets */}
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{BULLETS.map((bullet) => {
|
||||
const Icon = bullet.icon;
|
||||
return (
|
||||
<div
|
||||
key={bullet.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-5"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-[14px] font-semibold text-foreground">
|
||||
{bullet.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[13px] leading-relaxed text-muted-foreground">
|
||||
{bullet.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { Github } from 'lucide-react';
|
||||
import { GITHUB_REPO } from '@/lib/constants';
|
||||
import { DictationHero } from './CaptureSection';
|
||||
|
||||
export function CaptureHero({
|
||||
version,
|
||||
totalDownloads,
|
||||
}: {
|
||||
version: string | null;
|
||||
totalDownloads: number | null;
|
||||
}) {
|
||||
return (
|
||||
<section className="relative pt-32 pb-16">
|
||||
{/* Background glow */}
|
||||
<div className="hero-glow hero-glow-fade pointer-events-none absolute inset-0 -top-32">
|
||||
<div className="absolute left-1/2 top-0 -translate-x-1/2 w-[900px] h-[500px] rounded-full bg-accent/12 blur-[140px]" />
|
||||
<div className="absolute left-1/2 top-16 -translate-x-1/2 w-[520px] h-[360px] rounded-full bg-accent/8 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-5xl px-6 text-center">
|
||||
{/* Kicker */}
|
||||
<div
|
||||
className="fade-in mb-6 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent"
|
||||
style={{ animationDelay: '50ms' }}
|
||||
>
|
||||
Voice dictation · for humans and AI agents
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<div className="fade-in relative" style={{ animationDelay: '100ms' }}>
|
||||
<h1 className="text-5xl font-bold tracking-tighter leading-[0.9] text-foreground md:text-7xl lg:text-[96px]">
|
||||
Just talk to your computer.
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p
|
||||
className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"
|
||||
style={{ animationDelay: '200ms' }}
|
||||
>
|
||||
Hold a key anywhere on your machine, speak, release — your words land in the focused
|
||||
text field. A free, open-source, entirely-local alternative to{' '}
|
||||
<b className="text-white">WisprFlow</b>. And because Voicebox clones voices too, any
|
||||
AI agent can speak back in a voice you own.
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div
|
||||
className="fade-in mt-10 flex flex-row items-center justify-center gap-3 sm:gap-4"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
>
|
||||
<a
|
||||
href="/download"
|
||||
className="rounded-full bg-accent px-8 py-3.5 text-sm font-semibold uppercase tracking-wider text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3),inset_0_2px_0_rgba(255,255,255,0.2),inset_0_-2px_0_rgba(0,0,0,0.1)] transition-all hover:bg-accent-faint active:shadow-[0_2px_10px_hsl(43_60%_50%/0.3),inset_0_4px_8px_rgba(0,0,0,0.3)]"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_REPO}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-6 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-border"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Version + downloads */}
|
||||
<p
|
||||
className="fade-in mt-4 text-xs text-muted-foreground/50"
|
||||
style={{ animationDelay: '400ms' }}
|
||||
>
|
||||
{version ?? ''}
|
||||
{version && totalDownloads != null ? ' · ' : ''}
|
||||
{totalDownloads != null ? `${totalDownloads.toLocaleString()} downloads` : ''}
|
||||
{version || totalDownloads != null ? ' · ' : ''}
|
||||
macOS, Windows, Linux
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hero visual — the pill itself */}
|
||||
<div className="mt-20 px-6">
|
||||
<DictationHero />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Bot, Mic2, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// ─── Hero: Hotkey Pill ──────────────────────────────────────────────────────
|
||||
// Ported from app/src/components/ServerTab/CapturesPage.tsx HotkeyPillPreview.
|
||||
// Scaled up and retuned for the landing page — larger grid field, stretched
|
||||
// aspect, longer rest phase so the loop reads as intentional.
|
||||
|
||||
type PillState = 'recording' | 'transcribing' | 'refining' | 'rest';
|
||||
|
||||
const PILL_SEQUENCE: PillState[] = ['recording', 'transcribing', 'refining', 'rest'];
|
||||
const PILL_DURATIONS: Record<PillState, number> = {
|
||||
recording: 2800,
|
||||
transcribing: 1600,
|
||||
refining: 1600,
|
||||
rest: 1400,
|
||||
};
|
||||
const PILL_LABELS: Record<Exclude<PillState, 'rest'>, string> = {
|
||||
recording: 'Recording',
|
||||
transcribing: 'Transcribing',
|
||||
refining: 'Refining',
|
||||
};
|
||||
|
||||
function PillAudioBars({ mode }: { mode: 'live' | 'thinking' }) {
|
||||
return (
|
||||
<div className="flex items-center gap-[3px] h-6 shrink-0">
|
||||
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<motion.div
|
||||
key={`${mode}-${i}`}
|
||||
className="w-[3.5px] rounded-full bg-accent"
|
||||
animate={
|
||||
mode === 'live'
|
||||
? { height: ['10px', '18px', '6px', '16px', '10px'] }
|
||||
: { height: ['8px', '20px', '8px'] }
|
||||
}
|
||||
transition={
|
||||
mode === 'live'
|
||||
? { duration: 1.1, repeat: Infinity, delay: i * 0.12, ease: 'easeInOut' }
|
||||
: { duration: 0.7, repeat: Infinity, delay: i * 0.09, ease: 'easeInOut' }
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KbdKey({ children }: { children: string }) {
|
||||
return (
|
||||
<kbd className="inline-flex items-center justify-center h-7 min-w-[1.75rem] px-2 rounded-md border border-app-line bg-app-darkBox/80 font-mono text-[12px] font-medium text-foreground shadow-[inset_0_-2px_0_rgba(0,0,0,0.2)]">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
export function DictationHero() {
|
||||
const [state, setState] = useState<PillState>('recording');
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
const next = PILL_SEQUENCE[(PILL_SEQUENCE.indexOf(state) + 1) % PILL_SEQUENCE.length];
|
||||
setState(next);
|
||||
}, PILL_DURATIONS[state]);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'recording') return;
|
||||
setTick(0);
|
||||
const iv = window.setInterval(() => setTick((n) => n + 1), 90);
|
||||
return () => window.clearInterval(iv);
|
||||
}, [state]);
|
||||
|
||||
const elapsedSec = Math.floor((tick * 90) / 1000);
|
||||
const elapsedLabel = `0:${String(elapsedSec).padStart(2, '0')}`;
|
||||
const pillVisible = state !== 'rest';
|
||||
const barMode: 'live' | 'thinking' = state === 'recording' ? 'live' : 'thinking';
|
||||
const labelText = state === 'rest' ? PILL_LABELS.recording : PILL_LABELS[state];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl">
|
||||
{/* Shortcut hint above the field */}
|
||||
<div className="mt-10 mb-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-1.5 text-[13px] text-muted-foreground">
|
||||
<span>Hold</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<KbdKey>⌘</KbdKey>
|
||||
<KbdKey>⌥</KbdKey>
|
||||
</div>
|
||||
<span>on macOS,</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<KbdKey>Ctrl</KbdKey>
|
||||
<KbdKey>Alt</KbdKey>
|
||||
</div>
|
||||
<span>on Windows — from anywhere on your machine.</span>
|
||||
</div>
|
||||
|
||||
{/* The stage — gridded field with the pill floating in the middle */}
|
||||
<div
|
||||
className="relative rounded-2xl border border-app-line bg-app-darkerBox/60 overflow-hidden aspect-[5/1]"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, hsl(30 10% 94% / 0.04) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, hsl(30 10% 94% / 0.04) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
>
|
||||
{/* Soft accent glow behind the pill */}
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-[420px] h-[160px] rounded-full bg-accent/10 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
{/* Floating pill */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className={`inline-flex items-center gap-4 px-6 h-14 rounded-full bg-black/55 backdrop-blur-md text-accent shadow-[0_12px_40px_rgba(0,0,0,0.45)] transition-opacity duration-500 ease-out ${
|
||||
pillVisible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
{/* Gold dot — pings during recording */}
|
||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||
{state === 'recording' && (
|
||||
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-70" />
|
||||
)}
|
||||
<span className="relative rounded-full h-2.5 w-2.5 bg-accent" />
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="text-[15px] font-medium shrink-0"
|
||||
style={{ minWidth: '120px' }}
|
||||
>
|
||||
{labelText}
|
||||
</span>
|
||||
|
||||
<PillAudioBars mode={barMode} />
|
||||
|
||||
<span className="text-[13px] tabular-nums text-accent/70 font-medium shrink-0 -ml-1">
|
||||
{elapsedLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card: Multi-Engine STT ─────────────────────────────────────────────────
|
||||
|
||||
type EngineRow = { name: string; size: string; langs: string };
|
||||
|
||||
const STT_ENGINES: EngineRow[] = [
|
||||
{ name: 'Whisper', size: '1.5B', langs: '99 langs' },
|
||||
{ name: 'Whisper Turbo', size: '809M', langs: '99 langs' },
|
||||
{ name: 'Parakeet v3', size: '600M', langs: '25 langs' },
|
||||
{ name: 'Qwen3-ASR', size: '600M', langs: '50+ langs' },
|
||||
];
|
||||
|
||||
function MultiEngineSTTAnimation() {
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setActiveIdx((i) => (i + 1) % STT_ENGINES.length);
|
||||
}, 1600);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-40 w-full flex items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4">
|
||||
<div className="w-full max-w-[240px] space-y-1.5">
|
||||
{STT_ENGINES.map((engine, i) => {
|
||||
const active = i === activeIdx;
|
||||
return (
|
||||
<motion.div
|
||||
key={engine.name}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-md border"
|
||||
animate={{
|
||||
borderColor: active ? 'hsl(43 50% 45% / 0.5)' : 'rgba(255,255,255,0.06)',
|
||||
backgroundColor: active ? 'hsl(43 50% 45% / 0.08)' : 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<motion.div
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
animate={{
|
||||
backgroundColor: active ? 'hsl(43 50% 50%)' : 'rgba(255,255,255,0.15)',
|
||||
boxShadow: active ? '0 0 8px hsl(43 50% 50%)' : '0 0 0 transparent',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
<span
|
||||
className="text-[10px] font-medium flex-1 truncate"
|
||||
style={{ color: active ? 'hsl(43 50% 55%)' : 'rgba(255,255,255,0.55)' }}
|
||||
>
|
||||
{engine.name}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-ink-faint/70 tabular-nums">
|
||||
{engine.size}
|
||||
</span>
|
||||
<span className="text-[9px] text-ink-faint/60">{engine.langs}</span>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card: LLM Refinement ───────────────────────────────────────────────────
|
||||
|
||||
const REFINEMENT_PAIRS = [
|
||||
{
|
||||
raw: 'um so like i think we should ship it on friday, actually no wait, tuesday',
|
||||
clean: 'I think we should ship it on Tuesday.',
|
||||
},
|
||||
{
|
||||
raw: 'could you uh run the migration real quick, and then, yeah, check the logs',
|
||||
clean: 'Could you run the migration, then check the logs?',
|
||||
},
|
||||
];
|
||||
|
||||
function RefinementAnimation() {
|
||||
const [pairIdx, setPairIdx] = useState(0);
|
||||
const [showClean, setShowClean] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const step = () => {
|
||||
if (!mounted) return;
|
||||
setShowClean(false);
|
||||
window.setTimeout(() => mounted && setShowClean(true), 1400);
|
||||
window.setTimeout(() => {
|
||||
if (!mounted) return;
|
||||
setPairIdx((i) => (i + 1) % REFINEMENT_PAIRS.length);
|
||||
}, 4000);
|
||||
};
|
||||
step();
|
||||
const iv = window.setInterval(step, 4000);
|
||||
return () => {
|
||||
mounted = false;
|
||||
window.clearInterval(iv);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const pair = REFINEMENT_PAIRS[pairIdx];
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="w-full max-w-[260px] space-y-2">
|
||||
{/* Raw line — always visible, dims when refined */}
|
||||
<motion.div
|
||||
key={`raw-${pairIdx}`}
|
||||
className="text-[10px] font-mono leading-relaxed"
|
||||
initial={{ opacity: 0, y: 2 }}
|
||||
animate={{
|
||||
opacity: showClean ? 0.35 : 1,
|
||||
y: 0,
|
||||
color: showClean ? 'rgba(255,255,255,0.35)' : 'rgba(255,255,255,0.6)',
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<span className="text-ink-faint/50 mr-1.5">raw</span>
|
||||
{pair.raw}
|
||||
</motion.div>
|
||||
|
||||
{/* Refined line — fades in */}
|
||||
<motion.div
|
||||
key={`clean-${pairIdx}`}
|
||||
className="text-[10px] leading-relaxed"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{
|
||||
opacity: showClean ? 1 : 0,
|
||||
y: showClean ? 0 : 4,
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<span className="text-accent/70 mr-1.5 font-mono">clean</span>
|
||||
<span className="text-foreground">{pair.clean}</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Activity indicator */}
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-mono text-ink-faint mt-1">
|
||||
<Sparkles className="h-2.5 w-2.5 text-accent" />
|
||||
<span>{showClean ? 'refined' : 'Qwen3 · refining...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card: Agent voice output ───────────────────────────────────────────────
|
||||
|
||||
type AgentSpeaker = {
|
||||
agent: string;
|
||||
voice: string;
|
||||
gradient: [string, string];
|
||||
message: string;
|
||||
};
|
||||
|
||||
const AGENT_SPEAKERS: AgentSpeaker[] = [
|
||||
{
|
||||
agent: 'Claude Code',
|
||||
voice: 'Morgan',
|
||||
gradient: ['#60a5fa', '#6366f1'],
|
||||
message: 'Tests passing. Ready to merge.',
|
||||
},
|
||||
{
|
||||
agent: 'Cursor',
|
||||
voice: 'Scarlett',
|
||||
gradient: ['#34d399', '#14b8a6'],
|
||||
message: 'Build finished in 42s.',
|
||||
},
|
||||
{
|
||||
agent: 'Cline',
|
||||
voice: 'Jarvis',
|
||||
gradient: ['#a855f7', '#ec4899'],
|
||||
message: 'Deploy complete.',
|
||||
},
|
||||
];
|
||||
|
||||
function AgentVoiceAnimation() {
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setIdx((i) => (i + 1) % AGENT_SPEAKERS.length);
|
||||
}, 2600);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const current = AGENT_SPEAKERS[idx];
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Which agent called speak() */}
|
||||
<motion.div
|
||||
key={`agent-${idx}`}
|
||||
className="text-[9px] font-mono"
|
||||
initial={{ opacity: 0, y: 2 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<span className="text-ink-faint/50">via MCP</span>
|
||||
<span className="mx-1.5 text-ink-faint/30">·</span>
|
||||
<span className="text-ink-dull">{current.agent}</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Pill in speaking state */}
|
||||
<motion.div
|
||||
key={`pill-${idx}`}
|
||||
className="inline-flex items-center gap-2.5 px-3 h-8 rounded-full bg-black/55 backdrop-blur-sm shadow-[0_6px_20px_rgba(0,0,0,0.35)]"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div
|
||||
className="h-4 w-4 rounded-full shrink-0 ring-1 ring-white/10"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${current.gradient[0]}, ${current.gradient[1]})`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] font-medium text-foreground/90">
|
||||
Speaking · <span className="text-accent">{current.voice}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-[2px] h-3.5">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<motion.div
|
||||
key={`${current.voice}-${i}`}
|
||||
className="w-[2px] rounded-full bg-accent"
|
||||
animate={{ height: ['4px', '11px', '5px', '9px', '4px'] }}
|
||||
transition={{
|
||||
duration: 0.9,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.08,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* The line the agent is saying */}
|
||||
<motion.div
|
||||
key={`msg-${idx}`}
|
||||
className="text-[10px] font-mono text-ink-dull max-w-[220px] text-center leading-relaxed"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.15 }}
|
||||
>
|
||||
“{current.message}”
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Feature data + card ────────────────────────────────────────────────────
|
||||
|
||||
const CAPTURE_FEATURES = [
|
||||
{
|
||||
title: 'Multi-Engine STT',
|
||||
description:
|
||||
'Whisper, Whisper Turbo, Parakeet v3, Qwen3-ASR. Pick the model that fits your accent, language, or speed — all running on your hardware.',
|
||||
icon: Mic2,
|
||||
animation: MultiEngineSTTAnimation,
|
||||
},
|
||||
{
|
||||
title: 'Refined transcripts',
|
||||
description:
|
||||
'A local LLM cleans ums, self-corrections, and punctuation without rephrasing. Optional, toggleable, and never leaves your machine.',
|
||||
icon: Sparkles,
|
||||
animation: RefinementAnimation,
|
||||
},
|
||||
{
|
||||
title: 'Agents speak in voices you own',
|
||||
description:
|
||||
'Any MCP-aware agent — Claude Code, Cursor, Cline — gets a voice with one tool call. The pill surfaces when an agent is speaking, so you always see what’s coming out of your machine.',
|
||||
icon: Bot,
|
||||
animation: AgentVoiceAnimation,
|
||||
},
|
||||
];
|
||||
|
||||
function CaptureCard({ feature }: { feature: (typeof CAPTURE_FEATURES)[number] }) {
|
||||
const Icon = feature.icon;
|
||||
const Animation = feature.animation;
|
||||
return (
|
||||
<div className="rounded-lg border border-app-line bg-app-darkBox overflow-hidden">
|
||||
<div className="pointer-events-none select-none">
|
||||
<Animation />
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-[15px] font-medium text-foreground">{feature.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CaptureSection() {
|
||||
return (
|
||||
<section id="capture" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
{/* Kicker + headline */}
|
||||
<div className="text-center mb-14">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Capture
|
||||
</div>
|
||||
<h2 className="text-4xl font-semibold tracking-tight text-foreground md:text-5xl mb-5">
|
||||
Dictate anywhere. Paste into any app.
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto text-base md:text-lg leading-relaxed">
|
||||
Hold a shortcut anywhere on your machine, speak, release.
|
||||
The transcript lands in a focused text field in any app, or your clipboard. Agents speak
|
||||
back through the same pill in any cloned voice.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hero pill animation */}
|
||||
<div className="mb-16">
|
||||
<DictationHero />
|
||||
</div>
|
||||
|
||||
{/* Feature cards */}
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{CAPTURE_FEATURES.map((f) => (
|
||||
<CaptureCard key={f.title} feature={f} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
AudioLines,
|
||||
Box,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Copy,
|
||||
FileAudio,
|
||||
Mic,
|
||||
Play,
|
||||
Send,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Subtitles,
|
||||
Users,
|
||||
Volume2,
|
||||
Wand2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
// ─── Sidebar (matches ControlUI exactly) ───────────────────────────────────
|
||||
|
||||
const SIDEBAR_ITEMS = [
|
||||
{ icon: Volume2, label: 'Generate' },
|
||||
{ icon: AudioLines, label: 'Stories' },
|
||||
{ icon: Mic, label: 'Captures', active: true },
|
||||
{ icon: Users, label: 'Voices' },
|
||||
{ icon: Wand2, label: 'Effects' },
|
||||
{ icon: Box, label: 'Models' },
|
||||
{ icon: Settings, label: 'Settings' },
|
||||
];
|
||||
|
||||
function Sidebar() {
|
||||
return (
|
||||
<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
|
||||
className="w-9 h-9 rounded-lg overflow-hidden"
|
||||
style={{
|
||||
filter:
|
||||
'drop-shadow(0 0 6px hsl(43 50% 45% / 0.5)) drop-shadow(0 0 14px hsl(43 50% 45% / 0.35))',
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/voicebox-logo-app.webp"
|
||||
alt=""
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{SIDEBAR_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center transition-all duration-200 ${
|
||||
item.active
|
||||
? 'bg-white/[0.07] text-foreground shadow-lg backdrop-blur-sm border border-white/[0.08]'
|
||||
: 'text-muted-foreground/60'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<div className="mt-auto text-[8px] text-muted-foreground/40">v0.5.0</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── FakeWaveform (ported from CapturesTab.tsx) ────────────────────────────
|
||||
|
||||
function FakeWaveform({
|
||||
seed,
|
||||
active,
|
||||
className,
|
||||
}: {
|
||||
seed: number;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const bars = useMemo(() => {
|
||||
return Array.from({ length: 72 }).map((_, i) => {
|
||||
const h =
|
||||
28 +
|
||||
Math.sin(i * 0.35 + seed) * 22 +
|
||||
Math.cos(i * 0.81 + seed * 2) * 14 +
|
||||
Math.sin(i * 1.7 + seed * 3) * 8;
|
||||
return Math.max(6, Math.min(96, h));
|
||||
});
|
||||
}, [seed]);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-[2px] h-10 ${className ?? ''}`}>
|
||||
{bars.map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[3px] rounded-full"
|
||||
style={{
|
||||
height: `${h}%`,
|
||||
backgroundColor: active ? 'hsl(43 50% 50% / 0.85)' : 'hsl(var(--foreground) / 0.25)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Data ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Capture = {
|
||||
id: string;
|
||||
seed: number;
|
||||
transcriptRaw: string;
|
||||
transcriptRefined: string;
|
||||
durationMs: number;
|
||||
ago: string;
|
||||
createdAtLabel: string;
|
||||
source: 'dictation' | 'recording' | 'file';
|
||||
sttModel: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
const CAPTURES: Capture[] = [
|
||||
{
|
||||
id: 'c1',
|
||||
seed: 11,
|
||||
transcriptRaw:
|
||||
"okay so the pitch for voicebox is basically this it's a local first voice studio everything runs on your machine you clone voices from a few seconds of audio generate speech across seven TTS engines and now with the captures tab you can dictate into any app no cloud no API keys no per character fees your voice data never leaves your device privacy isn't a feature here it's the architecture",
|
||||
transcriptRefined:
|
||||
"Okay, so the pitch for Voicebox is basically this: it's a local-first voice studio. Everything runs on your machine. You clone voices from a few seconds of audio, generate speech across seven TTS engines, and now with the Captures tab, you can dictate into any app. No cloud, no API keys, no per-character fees. Your voice data never leaves your device. Privacy isn't a feature here — it's the architecture.",
|
||||
durationMs: 38000,
|
||||
ago: '4 min ago',
|
||||
createdAtLabel: 'Apr 22, 3:47 PM',
|
||||
source: 'dictation',
|
||||
sttModel: 'turbo',
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
seed: 23,
|
||||
transcriptRaw:
|
||||
"draft an update for the blog about the agent voice feature the key point is one MCP tool call and any agent on your machine gets a voice claude code finishes a long task calls voicebox dot speak and you hear it in a voice you've cloned morgan scarlett whatever you set up same pill that shows when you're dictating also shows when an agent is speaking so you always know what's coming out of your machine closes the whole voice IO loop for agents",
|
||||
transcriptRefined:
|
||||
"Draft an update for the blog about the agent voice feature. The key point: one MCP tool call, and any agent on your machine gets a voice. Claude Code finishes a long task, calls voicebox.speak, and you hear it in a voice you've cloned — Morgan, Scarlett, whatever you've set up. The same pill that shows when you're dictating also shows when an agent is speaking, so you always know what's coming out of your machine. It closes the full voice I/O loop for agents.",
|
||||
durationMs: 41000,
|
||||
ago: '22 min ago',
|
||||
createdAtLabel: 'Apr 22, 3:29 PM',
|
||||
source: 'dictation',
|
||||
sttModel: 'parakeet-v3',
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
id: 'c3',
|
||||
seed: 37,
|
||||
transcriptRaw:
|
||||
"tech overview for the readme seven TTS engines qwen3 kokoro chatterbox luxtts customvoice tada and chatterbox turbo four STT whisper whisper turbo parakeet v3 qwen3 ASR one local LLM qwen 3.5 shared runtime across all of them one model directory one GPU story no fragmented caches pick the right model per job speed on CPU laptops quality on an M series mac all switchable per generation",
|
||||
transcriptRefined:
|
||||
"Tech overview for the README: seven TTS engines — Qwen3, Kokoro, Chatterbox, LuxTTS, CustomVoice, TADA, and Chatterbox Turbo. Four STT — Whisper, Whisper Turbo, Parakeet v3, Qwen3-ASR. One local LLM, Qwen 3.5, with a shared runtime across all of them. One model directory, one GPU story, no fragmented caches. Pick the right model per job — speed on CPU laptops, quality on an M-series Mac, switchable per-generation.",
|
||||
durationMs: 34000,
|
||||
ago: '1 hr ago',
|
||||
createdAtLabel: 'Apr 22, 2:51 PM',
|
||||
source: 'dictation',
|
||||
sttModel: 'turbo',
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
id: 'c4',
|
||||
seed: 53,
|
||||
transcriptRaw:
|
||||
"okay the real magic is this you speak to voicebox your transcript gets cleaned up by a local LLM it pastes into whatever you're focused on then the agent you're talking to responds and it replies with voice in a voice you cloned through the same pill that's the loop elevenlabs has TTS wisprflow has dictation but neither runs locally and neither does both halves voicebox is full voice IO for humans and AI agents entirely on your machine",
|
||||
transcriptRefined:
|
||||
"Okay, the real magic: you speak to Voicebox, your transcript gets cleaned up by a local LLM, and it pastes into whatever you're focused on. Then the agent you're talking to responds — and it replies with voice, in a voice you've cloned, through the same pill. That's the loop. ElevenLabs has TTS, WisprFlow has dictation, but neither runs locally and neither does both halves. Voicebox is full voice I/O for humans and AI agents, entirely on your machine.",
|
||||
durationMs: 42000,
|
||||
ago: 'Yesterday',
|
||||
createdAtLabel: 'Apr 21, 11:14 PM',
|
||||
source: 'dictation',
|
||||
sttModel: 'large',
|
||||
language: 'en',
|
||||
},
|
||||
];
|
||||
|
||||
const PROFILES = [
|
||||
{ id: 'p1', name: 'Morgan', description: 'Warm, measured', gradient: 'from-blue-400 to-indigo-500' },
|
||||
{ id: 'p2', name: 'Scarlett', description: 'Bright, conversational', gradient: 'from-emerald-400 to-teal-500' },
|
||||
{ id: 'p3', name: 'Jarvis', description: 'Dry, composed', gradient: 'from-purple-500 to-fuchsia-500' },
|
||||
];
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const total = Math.round(ms / 1000);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source: Capture['source'] }) {
|
||||
const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio;
|
||||
const label =
|
||||
source === 'dictation' ? 'Dictation' : source === 'recording' ? 'Recording' : 'File';
|
||||
return (
|
||||
<span className="inline-flex items-center h-5 px-1.5 gap-1 rounded-md text-[10px] font-medium bg-muted/60 text-muted-foreground border border-transparent">
|
||||
<Icon className="h-2.5 w-2.5" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RefinedBadge() {
|
||||
return (
|
||||
<span className="inline-flex items-center h-5 px-1.5 gap-1 rounded-md text-[10px] font-medium bg-accent/10 text-accent border border-accent/20">
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
Refined
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BetaBadge() {
|
||||
return (
|
||||
<span className="inline-flex items-center h-5 px-1.5 rounded-md text-[10px] font-medium text-accent bg-accent/10 border border-accent/20">
|
||||
Beta
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Capture list row ───────────────────────────────────────────────────────
|
||||
|
||||
function CaptureRow({
|
||||
capture,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
capture: Capture;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`w-full text-left p-3 rounded-lg transition-colors block ${
|
||||
selected
|
||||
? 'bg-muted/70 border border-border'
|
||||
: 'border border-transparent hover:bg-muted/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[11px] text-muted-foreground font-medium">{capture.ago}</span>
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
|
||||
{formatDuration(capture.durationMs)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
|
||||
{capture.transcriptRefined}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<SourceBadge source={capture.source} />
|
||||
<RefinedBadge />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Detail view ────────────────────────────────────────────────────────────
|
||||
|
||||
function DetailView({ capture }: { capture: Capture }) {
|
||||
const [showRefined, setShowRefined] = useState(true);
|
||||
const [profileIdx, setProfileIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setShowRefined(true);
|
||||
}, [capture.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setProfileIdx((i) => (i + 1) % PROFILES.length);
|
||||
}, 2600);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const playAs = PROFILES[profileIdx];
|
||||
const transcript = showRefined ? capture.transcriptRefined : capture.transcriptRaw;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col px-8 pt-4 pb-5 overflow-hidden">
|
||||
{/* Compact top row — date + language + source, inline */}
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground/80 mb-4 shrink-0">
|
||||
<span>{capture.createdAtLabel}</span>
|
||||
{capture.language && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span>{capture.language.toUpperCase()}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<SourceBadge source={capture.source} />
|
||||
</div>
|
||||
|
||||
{/* Audio player card */}
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-4 mb-5 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-full border border-border bg-background flex items-center justify-center shrink-0">
|
||||
<Play className="h-4 w-4 ml-0.5 fill-current text-foreground" />
|
||||
</div>
|
||||
<FakeWaveform seed={capture.seed} active className="flex-1" />
|
||||
<span className="text-xs tabular-nums text-muted-foreground font-medium">
|
||||
{formatDuration(capture.durationMs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript header */}
|
||||
<div className="flex items-center gap-3 mb-3 shrink-0">
|
||||
<div className="inline-flex rounded-md bg-muted/40 p-0.5 border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRefined(true)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||
showRefined
|
||||
? 'bg-background shadow-sm text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-3 w-3 inline-block mr-1 -translate-y-px" />
|
||||
Refined
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRefined(false)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||
!showRefined
|
||||
? 'bg-background shadow-sm text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Subtitles className="h-3 w-3 inline-block mr-1 -translate-y-px" />
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{showRefined
|
||||
? 'Refined with Qwen3 · 1.7B'
|
||||
: `Whisper ${capture.sttModel}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Transcript body — focal point, fills remaining height */}
|
||||
<div className="flex-1 min-h-0 rounded-xl border border-border bg-muted/10 p-6 overflow-y-auto mb-4">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${capture.id}-${showRefined}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="text-[15px] leading-relaxed text-foreground/90"
|
||||
>
|
||||
{transcript}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Action row — matches CapturesTab bottom row */}
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<div className="inline-flex">
|
||||
<div className="inline-flex items-center justify-center gap-2 whitespace-nowrap h-9 rounded-full rounded-tr-none rounded-br-none border border-r-0 border-input bg-background pl-2 pr-3 text-sm font-medium transition-colors">
|
||||
<div
|
||||
className={`h-5 w-5 rounded-full bg-gradient-to-br shrink-0 ring-1 ring-white/10 ${playAs.gradient}`}
|
||||
/>
|
||||
<Volume2 className="h-4 w-4 shrink-0" />
|
||||
Play as {playAs.name}
|
||||
</div>
|
||||
<div className="inline-flex items-center justify-center gap-2 whitespace-nowrap h-9 px-2 rounded-full rounded-tl-none rounded-bl-none border border-input bg-background text-sm font-medium transition-colors">
|
||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-70" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 h-9 px-3 rounded-full border border-input bg-background text-sm font-medium text-foreground whitespace-nowrap">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 h-9 px-3 rounded-full border border-input bg-background text-sm font-medium text-foreground whitespace-nowrap">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Re-refine
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 h-9 px-3 rounded-full border border-input bg-background text-sm font-medium text-foreground whitespace-nowrap">
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
Send to
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main mockup ────────────────────────────────────────────────────────────
|
||||
|
||||
export function CapturesMockup() {
|
||||
const [selectedId, setSelectedId] = useState<string>(CAPTURES[0].id);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setSelectedId((current) => {
|
||||
const idx = CAPTURES.findIndex((c) => c.id === current);
|
||||
return CAPTURES[(idx + 1) % CAPTURES.length].id;
|
||||
});
|
||||
}, 4200);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const selected = CAPTURES.find((c) => c.id === selectedId) ?? CAPTURES[0];
|
||||
|
||||
return (
|
||||
<div className="relative z-20 mx-auto w-full max-w-5xl px-6">
|
||||
<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] pointer-events-none select-none">
|
||||
<div className="flex flex-col md:flex-row h-full">
|
||||
<Sidebar />
|
||||
|
||||
{/* ── Main area: two-panel Captures tab ─────────────────── */}
|
||||
<div className="flex-1 flex flex-col md:flex-row min-w-0 relative">
|
||||
{/* ── Left: capture list (w-[340px]) ──────────────────── */}
|
||||
<div
|
||||
style={{ width: 300, flex: '0 0 300px' }}
|
||||
className="flex flex-col overflow-hidden border-r border-app-line"
|
||||
>
|
||||
{/* Header — normal flow */}
|
||||
<div className="shrink-0 pl-4 pr-4 pt-4 pb-2">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<h1 className="text-2xl px-4 font-bold">Captures</h1>
|
||||
<BetaBadge />
|
||||
</div>
|
||||
<div className="h-9 flex items-center rounded-full border border-input bg-background px-4 text-sm text-muted-foreground">
|
||||
Search transcripts…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="px-4 pt-2 pb-6 space-y-1">
|
||||
{CAPTURES.map((capture) => (
|
||||
<CaptureRow
|
||||
key={capture.id}
|
||||
capture={capture}
|
||||
selected={selectedId === capture.id}
|
||||
onSelect={() => setSelectedId(capture.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: capture detail (flex-1) ───────────────────── */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={selectedId}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex-1 overflow-hidden"
|
||||
>
|
||||
<DetailView capture={selected} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,19 +48,34 @@ export function Navbar() {
|
||||
{/* Nav links - centered */}
|
||||
<div className="hidden sm:flex items-center gap-1 justify-self-center">
|
||||
<a
|
||||
href="#features"
|
||||
href="/#features"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Features
|
||||
</a>
|
||||
<a
|
||||
href="#about"
|
||||
href="/capture"
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Capture
|
||||
<span className="rounded-full bg-accent/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-accent">
|
||||
New
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/#agents"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Agents
|
||||
</a>
|
||||
<a
|
||||
href="/#about"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Models
|
||||
</a>
|
||||
<a
|
||||
href="#api"
|
||||
href="/#api"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
API
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Brain,
|
||||
Globe,
|
||||
Languages,
|
||||
type LucideIcon,
|
||||
MessageSquare,
|
||||
Mic2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Volume2,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
type Tag = { icon: LucideIcon; label: string };
|
||||
|
||||
type Model = {
|
||||
name: string;
|
||||
author: string;
|
||||
sizes?: string[];
|
||||
description: string;
|
||||
tags?: Tag[];
|
||||
};
|
||||
|
||||
type ModelGroup = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
models: Model[];
|
||||
};
|
||||
|
||||
const MODEL_GROUPS: ModelGroup[] = [
|
||||
{
|
||||
title: 'TTS Engines',
|
||||
subtitle: 'Text → speech. Voice cloning, preset voices, and delivery control.',
|
||||
models: [
|
||||
{
|
||||
name: 'Qwen3-TTS',
|
||||
author: 'Alibaba',
|
||||
sizes: ['1.7B', '0.6B'],
|
||||
description:
|
||||
'High-quality multilingual cloning with natural prosody. The only engine with delivery instructions — control tone, pace, and emotion with natural language.',
|
||||
tags: [
|
||||
{ icon: Globe, label: '10 langs' },
|
||||
{ icon: MessageSquare, label: 'Delivery instructions' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Chatterbox',
|
||||
author: 'Resemble AI',
|
||||
description:
|
||||
'Production-grade voice cloning with the broadest language support. 23 languages with zero-shot cloning and emotion exaggeration control.',
|
||||
tags: [{ icon: Languages, label: '23 langs' }],
|
||||
},
|
||||
{
|
||||
name: 'Chatterbox Turbo',
|
||||
author: 'Resemble AI',
|
||||
sizes: ['350M'],
|
||||
description:
|
||||
'Lightweight and fast. Supports paralinguistic tags — embed [laugh], [sigh], [gasp] directly in your text for expressive speech.',
|
||||
tags: [
|
||||
{ icon: Zap, label: 'Fast' },
|
||||
{ icon: MessageSquare, label: '[tag] support' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'LuxTTS',
|
||||
author: 'ZipVoice',
|
||||
description:
|
||||
'Ultra-fast, CPU-friendly cloning at 48kHz. Exceeds 150x realtime on CPU with ~1GB VRAM. The fastest engine for quick iterations.',
|
||||
tags: [
|
||||
{ icon: Zap, label: '150x realtime' },
|
||||
{ icon: Volume2, label: '48kHz' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Qwen CustomVoice',
|
||||
author: 'Alibaba',
|
||||
sizes: ['1.7B', '0.6B'],
|
||||
description:
|
||||
'Nine premium preset speakers with natural-language style control. "Speak slowly with warmth", "authoritative and clear" — tone and pace adapt.',
|
||||
tags: [
|
||||
{ icon: SlidersHorizontal, label: 'Instruct control' },
|
||||
{ icon: Globe, label: '10 langs' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'TADA',
|
||||
author: 'Hume AI',
|
||||
sizes: ['3B', '1B'],
|
||||
description:
|
||||
'Speech-language model with text-acoustic dual alignment. Built for long-form — 700s+ coherent audio without drift. Multilingual at 3B.',
|
||||
tags: [
|
||||
{ icon: Globe, label: '10 langs' },
|
||||
{ icon: MessageSquare, label: 'Long-form' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Kokoro',
|
||||
author: 'hexgrad · Apache 2.0',
|
||||
sizes: ['82M'],
|
||||
description:
|
||||
'Tiny 82M-parameter TTS that runs at CPU realtime with negligible VRAM. Pre-built voice styles — pick a voice, type, generate.',
|
||||
tags: [
|
||||
{ icon: Zap, label: 'CPU realtime' },
|
||||
{ icon: Volume2, label: 'Preset voices' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Transcription',
|
||||
subtitle: 'Speech → text. Multi-language STT for dictation and captures.',
|
||||
models: [
|
||||
{
|
||||
name: 'Whisper',
|
||||
author: 'OpenAI',
|
||||
sizes: ['1.5B', '769M', '244M', '74M'],
|
||||
description:
|
||||
'The default. Mature multilingual ASR across a wide size range — pick Tiny for speed or Large for best accuracy.',
|
||||
tags: [{ icon: Languages, label: '99 langs' }],
|
||||
},
|
||||
{
|
||||
name: 'Whisper Turbo',
|
||||
author: 'OpenAI',
|
||||
sizes: ['809M'],
|
||||
description:
|
||||
'Pruned Whisper Large v3. Near-best quality at roughly 8x the speed — the right default for real-time dictation.',
|
||||
tags: [
|
||||
{ icon: Languages, label: '99 langs' },
|
||||
{ icon: Zap, label: '8x faster' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Parakeet v3',
|
||||
author: 'NVIDIA',
|
||||
sizes: ['600M'],
|
||||
description:
|
||||
'Current quality leader for non-English local STT. Very fast, with strong accuracy on European and Asian languages.',
|
||||
tags: [
|
||||
{ icon: Languages, label: '25 langs' },
|
||||
{ icon: Zap, label: 'Fast' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Qwen3-ASR',
|
||||
author: 'Alibaba',
|
||||
sizes: ['600M'],
|
||||
description:
|
||||
'int8 quantized for cross-platform use. Highest multilingual coverage of any engine — 50+ languages with strong accuracy.',
|
||||
tags: [{ icon: Languages, label: '50+ langs' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Language Models',
|
||||
subtitle: 'Transcript refinement, persona replies, and on-device reasoning.',
|
||||
models: [
|
||||
{
|
||||
name: 'Qwen 3.5',
|
||||
author: 'Alibaba',
|
||||
sizes: ['4B', '2B', '0.8B'],
|
||||
description:
|
||||
'Powers transcript cleanup, persona voice replies, and the voice I/O loop. Shares its runtime with the TTS/STT stack — one model cache, one GPU story.',
|
||||
tags: [
|
||||
{ icon: Sparkles, label: 'Refinement' },
|
||||
{ icon: Brain, label: 'Persona replies' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function ModelCard({ model }: { model: Model }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-4 transition-colors hover:border-accent/30 flex flex-col">
|
||||
<div className="flex items-start justify-between gap-2 mb-1.5">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground truncate">{model.name}</h3>
|
||||
<span className="text-[11px] text-muted-foreground/60">by {model.author}</span>
|
||||
</div>
|
||||
{model.sizes && model.sizes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 justify-end shrink-0 max-w-[55%]">
|
||||
{model.sizes.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="text-[9px] px-1.5 py-0.5 rounded-full border border-border bg-background text-muted-foreground whitespace-nowrap tabular-nums"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mb-3 flex-1">
|
||||
{model.description}
|
||||
</p>
|
||||
{model.tags && model.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{model.tags.map((tag) => {
|
||||
const Icon = tag.icon;
|
||||
return (
|
||||
<span
|
||||
key={tag.label}
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground/70"
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" />
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelGroupSection({ group }: { group: ModelGroup }) {
|
||||
return (
|
||||
<div>
|
||||
{/* Group header */}
|
||||
<div className="flex items-baseline justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">{group.title}</h3>
|
||||
<p className="text-sm text-muted-foreground/80">{group.subtitle}</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-ink-faint/60 tabular-nums shrink-0">
|
||||
{String(group.models.length).padStart(2, '0')} model
|
||||
{group.models.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{group.models.map((model) => (
|
||||
<ModelCard key={model.name} model={model} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SupportedModels() {
|
||||
return (
|
||||
<section id="about" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Supported models
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Pick the right model for every job — TTS, transcription, refinement. All models run
|
||||
locally on your hardware. Download once, use forever.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-14">
|
||||
{MODEL_GROUPS.map((group) => (
|
||||
<ModelGroupSection key={group.title} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -384,10 +384,10 @@ export function VoiceCreator() {
|
||||
{/* Left: Copy */}
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Clone any voice in seconds
|
||||
Any clip becomes a voice.
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Three ways to capture a voice sample. Upload a clip, record from your microphone, or
|
||||
Three ways to get a sample in. 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>
|
||||
|
||||
Reference in New Issue
Block a user