mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-27 06:05:14 -07:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bdc18f278 | ||
|
|
0b8fd31c89 | ||
|
|
376afad852 | ||
|
|
c1814a2870 | ||
|
|
7d9a384ee4 | ||
|
|
c6a59f4477 | ||
|
|
4f13123b95 | ||
|
|
21c7e373d3 | ||
|
|
45b64e0233 | ||
|
|
b35b90961d | ||
|
|
7df366d0c8 |
@@ -28,6 +28,10 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/21213" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21213" alt="jamiepine%2Fvoicebox | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://voicebox.sh">voicebox.sh</a> •
|
||||
<a href="https://docs.voicebox.sh">Docs</a> •
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Responsible Use
|
||||
|
||||
Voicebox is a local-first AI voice studio. It can clone voices from short audio samples, generate speech, and make AI agents speak through voice profiles. That capability is useful for accessibility, creative production, prototyping, game development, and personal tools, but it can also be misused.
|
||||
|
||||
Voicebox does not and cannot independently verify who owns a voice sample. You are responsible for making sure you have the right to use every voice you clone, import, or generate with.
|
||||
|
||||
## Allowed Uses
|
||||
|
||||
- Cloning your own voice.
|
||||
- Cloning a voice with explicit permission from the speaker.
|
||||
- Using licensed, public-domain, or otherwise legally authorized voice material.
|
||||
- Building accessibility tools, creative projects, games, podcasts, prototypes, and local workflows where the speaker's rights are respected.
|
||||
|
||||
## Prohibited Uses
|
||||
|
||||
- Impersonating someone without permission.
|
||||
- Fraud, scams, phishing, social engineering, or bypassing voice authentication.
|
||||
- Harassment, threats, intimidation, or non-consensual sexual content.
|
||||
- Misleading political, legal, financial, medical, or emergency communications.
|
||||
- Commercial use of a person's voice without the legal right to do so.
|
||||
- Removing or bypassing responsible-use acknowledgements in order to misuse the software.
|
||||
|
||||
## Disclosure And Compliance
|
||||
|
||||
If you publish or distribute synthetic audio, disclose that it is AI-generated where required by law, platform policy, or audience expectations. Developers building products on top of Voicebox should treat consent records, disclosure, and jurisdiction-specific requirements as part of their own application design.
|
||||
|
||||
Voicebox runs locally to protect user privacy. That privacy model does not remove your responsibility to respect other people's voices.
|
||||
@@ -3,7 +3,6 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { SPONSORS } from '@/lib/sponsors';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
function FadeIn({ delay = 0, children }: { delay?: number; children: ReactNode }) {
|
||||
@@ -117,36 +116,6 @@ export function AboutPage() {
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
{SPONSORS.length > 0 && (
|
||||
<FadeIn delay={400}>
|
||||
<div className="pt-4 flex flex-col items-center gap-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/60">
|
||||
Sponsored by
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
{SPONSORS.map((sponsor) => (
|
||||
<a
|
||||
key={sponsor.name}
|
||||
href={sponsor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={sponsor.name}
|
||||
className="group flex h-12 min-w-[120px] items-center justify-center rounded-lg border border-border/60 bg-card/50 px-4 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<img
|
||||
src={sponsor.logoSrc}
|
||||
alt={sponsor.logoAlt ?? sponsor.name}
|
||||
className={`h-5 w-auto max-w-[100px] object-contain opacity-80 transition-opacity group-hover:opacity-100 ${
|
||||
sponsor.invertOnDark ? 'dark:brightness-0 dark:invert' : ''
|
||||
}`}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
)}
|
||||
|
||||
<FadeIn delay={480}>
|
||||
<p className="text-xs text-muted-foreground/40 pt-4">
|
||||
<Trans
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Cloud, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
// "Log in with browser" device pairing. The backend opens the system browser
|
||||
// and completes the code exchange; here we just kick it off and poll status
|
||||
// until the link goes live. The API key never touches the frontend.
|
||||
export function CloudSection() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [polling, setPolling] = useState(false);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['cloud-status'],
|
||||
queryFn: () => apiClient.getCloudStatus(),
|
||||
refetchInterval: polling ? 2000 : false,
|
||||
});
|
||||
|
||||
const connected = status?.connected ?? false;
|
||||
|
||||
// Once the browser flow completes, stop polling and celebrate.
|
||||
useEffect(() => {
|
||||
if (connected && polling) {
|
||||
setPolling(false);
|
||||
toast({
|
||||
title: 'Connected to Voicebox Cloud',
|
||||
description: `Linked as ${status?.device_name ?? 'this device'}.`,
|
||||
});
|
||||
}
|
||||
}, [connected, polling, status?.device_name, toast]);
|
||||
|
||||
// Give up after two minutes so an abandoned browser flow doesn't leave the
|
||||
// button stuck on "Waiting for browser…". The backend state stays valid for
|
||||
// ten, so the user can simply start again.
|
||||
useEffect(() => {
|
||||
if (!polling) return;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setPolling(false);
|
||||
toast({
|
||||
title: 'Sign-in timed out',
|
||||
description: 'The browser sign-in was not completed. Try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}, 120_000);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [polling, toast]);
|
||||
|
||||
const startLogin = useMutation({
|
||||
mutationFn: () => apiClient.startCloudLogin(),
|
||||
onSuccess: () => {
|
||||
setPolling(true);
|
||||
toast({
|
||||
title: 'Continue in your browser',
|
||||
description: 'Authorize this device, then return here.',
|
||||
});
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast({
|
||||
title: 'Could not start sign-in',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
}),
|
||||
});
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: () => apiClient.disconnectCloud(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-status'] });
|
||||
toast({
|
||||
title: 'Disconnected',
|
||||
description:
|
||||
'This device is no longer linked. The key stays valid until revoked in your account.',
|
||||
});
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast({ title: 'Could not disconnect', description: error.message, variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const busy = startLogin.isPending || polling;
|
||||
|
||||
return (
|
||||
<SettingSection
|
||||
title="Voicebox Cloud"
|
||||
description="End-to-end encrypted backup & sync across your devices."
|
||||
>
|
||||
<SettingRow
|
||||
title={connected ? 'Connected' : 'Account'}
|
||||
description={
|
||||
connected
|
||||
? `Linked as ${status?.device_name ?? 'this device'}${
|
||||
status?.key_prefix ? ` · ${status.key_prefix}…` : ''
|
||||
}`
|
||||
: 'Log in to back up and sync your captures and generations.'
|
||||
}
|
||||
action={
|
||||
connected ? (
|
||||
<Button
|
||||
disabled={disconnect.isPending}
|
||||
onClick={() => disconnect.mutate()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{disconnect.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
Disconnecting…
|
||||
</>
|
||||
) : (
|
||||
'Disconnect'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={busy} onClick={() => startLogin.mutate()} size="sm">
|
||||
{busy ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
{polling ? 'Waiting for browser…' : 'Opening…'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Cloud className="h-3.5 w-3.5 mr-1.5" />
|
||||
Log in with browser
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{connected && (
|
||||
<SettingRow
|
||||
title="Manage"
|
||||
description="Revoke this device, add API keys, or manage billing from your account."
|
||||
>
|
||||
<a
|
||||
className="text-sm text-accent hover:underline"
|
||||
href={status?.dashboard_url ?? 'https://voicebox.sh/account'}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Open account dashboard ↗
|
||||
</a>
|
||||
</SettingRow>
|
||||
)}
|
||||
</SettingSection>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { CloudSection } from './CloudSection';
|
||||
import { LanguageSelect } from './LanguageSelect';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
import { ThemeSelect } from './ThemeSelect';
|
||||
@@ -207,6 +208,8 @@ export function GeneralPage() {
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<CloudSection />
|
||||
|
||||
<ApiReferenceCard serverUrl={serverUrl} />
|
||||
|
||||
{platform.metadata.isTauri && <UpdatesSection />}
|
||||
|
||||
@@ -50,6 +50,8 @@ import type {
|
||||
MCPClientBinding,
|
||||
MCPClientBindingListResponse,
|
||||
MCPClientBindingUpsert,
|
||||
CloudLoginStartResponse,
|
||||
CloudStatus,
|
||||
} from './types';
|
||||
|
||||
function formatErrorDetail(detail: unknown, fallback: string): string {
|
||||
@@ -920,6 +922,21 @@ class ApiClient {
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// Cloud (backup & sync) — browser-based device login. startCloudLogin opens
|
||||
// the system browser server-side; the UI then polls getCloudStatus until the
|
||||
// backend completes the exchange and the link goes live.
|
||||
async getCloudStatus(): Promise<CloudStatus> {
|
||||
return this.request<CloudStatus>('/cloud/status');
|
||||
}
|
||||
|
||||
async startCloudLogin(): Promise<CloudLoginStartResponse> {
|
||||
return this.request<CloudLoginStartResponse>('/cloud/login/start', { method: 'POST' });
|
||||
}
|
||||
|
||||
async disconnectCloud(): Promise<CloudStatus> {
|
||||
return this.request<CloudStatus>('/cloud/disconnect', { method: 'POST' });
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
@@ -521,3 +521,18 @@ export interface MCPClientBindingUpsert {
|
||||
export interface MCPClientBindingListResponse {
|
||||
items: MCPClientBinding[];
|
||||
}
|
||||
|
||||
/* ─── Cloud (backup & sync) ───────────────────────────────────────────── */
|
||||
|
||||
export interface CloudLoginStartResponse {
|
||||
authorize_url: string;
|
||||
}
|
||||
|
||||
export interface CloudStatus {
|
||||
connected: boolean;
|
||||
device_name: string | null;
|
||||
account_user_id: string | null;
|
||||
key_prefix: string | null;
|
||||
connected_at: string | null;
|
||||
dashboard_url: string;
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export type Sponsor = {
|
||||
name: string;
|
||||
url: string;
|
||||
logoSrc: string;
|
||||
logoAlt?: string;
|
||||
/** Set true for solid-black logos that need to flip white in dark mode. */
|
||||
invertOnDark?: boolean;
|
||||
};
|
||||
|
||||
export const SPONSORS: Sponsor[] = [];
|
||||
@@ -138,3 +138,17 @@ def get_models_dir() -> Path:
|
||||
path = _data_dir / "models"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
# Voicebox Cloud (backup & sync). Two hosts: the web app owns auth + device
|
||||
# pairing (voicebox.sh), the API owns sync + account endpoints
|
||||
# (api.voicebox.sh). Override both for local development, e.g.
|
||||
# VOICEBOX_CLOUD_URL=http://localhost:17592 VOICEBOX_CLOUD_API_URL=http://localhost:17593
|
||||
def get_cloud_web_url() -> str:
|
||||
"""Base URL of the Voicebox Cloud web app (auth + /connect + exchange)."""
|
||||
return os.environ.get("VOICEBOX_CLOUD_URL", "https://voicebox.sh").rstrip("/")
|
||||
|
||||
|
||||
def get_cloud_api_url() -> str:
|
||||
"""Base URL of the Voicebox Cloud API (bearer-authenticated sync/account)."""
|
||||
return os.environ.get("VOICEBOX_CLOUD_API_URL", "https://api.voicebox.sh").rstrip("/")
|
||||
|
||||
@@ -11,6 +11,7 @@ from .models import (
|
||||
Capture,
|
||||
CaptureSettings,
|
||||
ChannelDeviceMapping,
|
||||
CloudSettings,
|
||||
EffectPreset,
|
||||
Generation,
|
||||
GenerationSettings,
|
||||
@@ -32,6 +33,7 @@ __all__ = [
|
||||
"Capture",
|
||||
"CaptureSettings",
|
||||
"ChannelDeviceMapping",
|
||||
"CloudSettings",
|
||||
"EffectPreset",
|
||||
"Generation",
|
||||
"GenerationSettings",
|
||||
|
||||
@@ -234,6 +234,28 @@ class GenerationSettings(Base):
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class CloudSettings(Base):
|
||||
"""Singleton row holding the link to a Voicebox Cloud account.
|
||||
|
||||
Populated by the "Log in with browser" pairing flow (see services/cloud.py):
|
||||
the browser hands back a one-time code, which the backend exchanges for an
|
||||
``api_key`` it stores here. The key is a bearer credential for
|
||||
api.voicebox.sh — auth only, never an encryption key (E2E key material lives
|
||||
elsewhere). Stored in the local app database alongside the user's other data;
|
||||
moving it to the OS keychain is a future hardening step. The ``id`` is
|
||||
always 1; a null ``api_key`` means "not connected".
|
||||
"""
|
||||
|
||||
__tablename__ = "cloud_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, default=1)
|
||||
api_key = Column(String, nullable=True)
|
||||
device_name = Column(String, nullable=True)
|
||||
account_user_id = Column(String, nullable=True)
|
||||
connected_at = Column(DateTime, nullable=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class MCPClientBinding(Base):
|
||||
"""Per-MCP-client settings (voice profile, engine, personality default).
|
||||
|
||||
|
||||
@@ -793,3 +793,24 @@ class AvailableEffectsResponse(BaseModel):
|
||||
"""Response listing all available effect types."""
|
||||
|
||||
effects: List[AvailableEffect]
|
||||
|
||||
|
||||
# ─── Cloud (backup & sync) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class CloudLoginStartResponse(BaseModel):
|
||||
"""Returned when the desktop kicks off browser login. The backend has
|
||||
already opened the browser; the URL is included for fallback/debugging."""
|
||||
|
||||
authorize_url: str
|
||||
|
||||
|
||||
class CloudStatusResponse(BaseModel):
|
||||
"""Current link between this device and a Voicebox Cloud account."""
|
||||
|
||||
connected: bool
|
||||
device_name: Optional[str] = None
|
||||
account_user_id: Optional[str] = None
|
||||
key_prefix: Optional[str] = None
|
||||
connected_at: Optional[datetime] = None
|
||||
dashboard_url: str
|
||||
|
||||
@@ -23,6 +23,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
from .speak import router as speak_router
|
||||
from .mcp_bindings import router as mcp_bindings_router
|
||||
from .events import router as events_router
|
||||
from .cloud import router as cloud_router
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(profiles_router)
|
||||
@@ -42,3 +43,4 @@ def register_routers(app: FastAPI) -> None:
|
||||
app.include_router(speak_router)
|
||||
app.include_router(mcp_bindings_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(cloud_router)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Voicebox Cloud device login routes.
|
||||
|
||||
The browser-based pairing flow:
|
||||
1. POST /cloud/login/start — opens the browser to the cloud authorize page.
|
||||
2. GET /cloud/callback — the browser lands here with a one-time code;
|
||||
the backend exchanges it for an API key.
|
||||
3. GET /cloud/status — the UI polls this to learn when it connected.
|
||||
4. POST /cloud/disconnect — forget the local credential.
|
||||
"""
|
||||
|
||||
import socket
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..database import get_db
|
||||
from ..services import cloud as cloud_service
|
||||
|
||||
router = APIRouter(prefix="/cloud", tags=["cloud"])
|
||||
|
||||
|
||||
def _callback_url(request: Request) -> str:
|
||||
# Always loopback — the cloud only redirects codes to 127.0.0.1/localhost.
|
||||
port = request.url.port or 17493
|
||||
return f"http://127.0.0.1:{port}/cloud/callback"
|
||||
|
||||
|
||||
@router.post("/login/start", response_model=models.CloudLoginStartResponse)
|
||||
async def start_cloud_login(request: Request):
|
||||
device_name = socket.gethostname() or "Desktop"
|
||||
authorize_url = cloud_service.start_login(_callback_url(request), device_name)
|
||||
return models.CloudLoginStartResponse(authorize_url=authorize_url)
|
||||
|
||||
|
||||
@router.get("/callback", response_class=HTMLResponse)
|
||||
async def cloud_callback(
|
||||
request: Request,
|
||||
code: str = "",
|
||||
state: str = "",
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
ok, message = await cloud_service.handle_callback(db, code=code, state=state)
|
||||
heading = "You're connected" if ok else "Couldn't connect"
|
||||
accent = "#16a34a" if ok else "#dc2626"
|
||||
sub = (
|
||||
"Voicebox is now linked to your account. You can close this tab and return to the app."
|
||||
if ok
|
||||
else message
|
||||
)
|
||||
html = f"""<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Voicebox Cloud</title>
|
||||
<style>
|
||||
body {{ margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background:#0b0b0d; color:#e7e7ea; }}
|
||||
.card {{ max-width:28rem; padding:2.5rem; text-align:center; }}
|
||||
h1 {{ font-size:1.5rem; margin:0 0 .5rem; color:{accent}; }}
|
||||
p {{ color:#a1a1aa; line-height:1.5; }}
|
||||
</style></head>
|
||||
<body><div class="card"><h1>{heading}</h1><p>{sub}</p></div></body></html>"""
|
||||
return HTMLResponse(content=html, status_code=200 if ok else 400)
|
||||
|
||||
|
||||
@router.get("/status", response_model=models.CloudStatusResponse)
|
||||
async def cloud_status(db: Session = Depends(get_db)):
|
||||
return models.CloudStatusResponse(**cloud_service.get_status(db))
|
||||
|
||||
|
||||
@router.post("/disconnect", response_model=models.CloudStatusResponse)
|
||||
async def cloud_disconnect(db: Session = Depends(get_db)):
|
||||
cloud_service.disconnect(db)
|
||||
return models.CloudStatusResponse(**cloud_service.get_status(db))
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Voicebox Cloud device login — the "Log in with browser" flow.
|
||||
|
||||
The desktop opens the browser to ``{web}/connect``; the user authorizes while
|
||||
signed in; the cloud redirects a single-use code back to this backend's loopback
|
||||
callback. We exchange that code (server-to-server, over TLS) for a ``voicebox_…``
|
||||
API key, verify the key against the API, and store it locally. The key never
|
||||
travels through a browser URL, and an unfinished flow leaves nothing behind.
|
||||
|
||||
The ``state`` we mint and round-trip prevents login-CSRF: a callback whose state
|
||||
we didn't issue (e.g. an attacker tricking the user into hitting the loopback
|
||||
callback with their own code) is rejected.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import webbrowser
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config
|
||||
from ..database import CloudSettings as DBCloudSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SINGLETON_ID = 1
|
||||
PENDING_TTL_SECONDS = 600 # the whole browser flow must finish within 10 min
|
||||
|
||||
# state -> expiry epoch. In-memory: a single backend process owns the flow, and a
|
||||
# dropped pairing should simply be restarted.
|
||||
_pending: dict[str, float] = {}
|
||||
|
||||
|
||||
def _prune() -> None:
|
||||
now = time.time()
|
||||
for state, expiry in list(_pending.items()):
|
||||
if expiry < now:
|
||||
_pending.pop(state, None)
|
||||
|
||||
|
||||
def _json_dict(response: httpx.Response) -> dict | None:
|
||||
"""Parsed JSON body, or None when it isn't a JSON object."""
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def _consume_state(state: str) -> bool:
|
||||
"""Validate and single-use-consume a pending state."""
|
||||
_prune()
|
||||
expiry = _pending.pop(state, None)
|
||||
return expiry is not None and expiry >= time.time()
|
||||
|
||||
|
||||
def start_login(callback_url: str, device_name: str) -> str:
|
||||
"""Mint a state, build the authorize URL, and open the browser.
|
||||
|
||||
Returns the authorize URL (also opened here) so the caller can surface it as
|
||||
a fallback if the browser didn't open.
|
||||
"""
|
||||
state = secrets.token_urlsafe(24)
|
||||
_prune()
|
||||
_pending[state] = time.time() + PENDING_TTL_SECONDS
|
||||
|
||||
params = urlencode({"redirect_uri": callback_url, "state": state, "name": device_name})
|
||||
authorize_url = f"{config.get_cloud_web_url()}/connect?{params}"
|
||||
|
||||
try:
|
||||
webbrowser.open(authorize_url)
|
||||
except Exception: # pragma: no cover - platform dependent
|
||||
logger.exception("failed to open browser for cloud login")
|
||||
|
||||
return authorize_url
|
||||
|
||||
|
||||
async def handle_callback(db: Session, code: str, state: str) -> tuple[bool, str]:
|
||||
"""Exchange the code for an API key and store it. Returns (ok, message)."""
|
||||
if not _consume_state(state):
|
||||
return False, "This sign-in link is invalid or has expired. Start again from the app."
|
||||
if not code:
|
||||
return False, "Missing authorization code."
|
||||
|
||||
web = config.get_cloud_web_url()
|
||||
api = config.get_cloud_api_url()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
exchanged = await client.post(f"{web}/api/connect/exchange", json={"code": code})
|
||||
if exchanged.status_code != 200:
|
||||
logger.warning("cloud exchange rejected code: %s", exchanged.status_code)
|
||||
return False, "Could not complete sign-in — the code was rejected."
|
||||
payload = _json_dict(exchanged)
|
||||
if payload is None:
|
||||
logger.warning("cloud exchange returned a non-JSON payload")
|
||||
return False, "Voicebox Cloud returned an unexpected response."
|
||||
api_key = payload.get("key")
|
||||
device_name = payload.get("label")
|
||||
if not api_key:
|
||||
return False, "Voicebox Cloud did not return a key."
|
||||
|
||||
# Confirm the freshly minted key actually authenticates the API.
|
||||
me = await client.get(
|
||||
f"{api}/v1/account/me",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
if me.status_code != 200:
|
||||
logger.warning("minted key failed verification: %s", me.status_code)
|
||||
return False, "Sign-in succeeded but the key could not be verified."
|
||||
# The 200 above proves the key works; the user id is best-effort.
|
||||
data = (_json_dict(me) or {}).get("data")
|
||||
account_user_id = data.get("userId") if isinstance(data, dict) else None
|
||||
except httpx.HTTPError:
|
||||
logger.exception("network error during cloud exchange")
|
||||
return False, "Could not reach Voicebox Cloud. Check your connection and try again."
|
||||
|
||||
_store_key(db, api_key=api_key, device_name=device_name, account_user_id=account_user_id)
|
||||
logger.info("connected to Voicebox Cloud as device %r", device_name)
|
||||
return True, "Connected"
|
||||
|
||||
|
||||
def _get_or_create_row(db: Session) -> DBCloudSettings:
|
||||
row = db.query(DBCloudSettings).filter(DBCloudSettings.id == SINGLETON_ID).first()
|
||||
if row is None:
|
||||
row = DBCloudSettings(id=SINGLETON_ID)
|
||||
db.add(row)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# Another request created the singleton concurrently.
|
||||
db.rollback()
|
||||
row = db.query(DBCloudSettings).filter(DBCloudSettings.id == SINGLETON_ID).one()
|
||||
else:
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def _store_key(db: Session, *, api_key: str, device_name: str | None, account_user_id: str | None):
|
||||
from datetime import datetime
|
||||
|
||||
row = _get_or_create_row(db)
|
||||
row.api_key = api_key
|
||||
row.device_name = device_name
|
||||
row.account_user_id = account_user_id
|
||||
row.connected_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_status(db: Session) -> dict:
|
||||
"""Local view of the cloud link — never returns the full key."""
|
||||
row = _get_or_create_row(db)
|
||||
connected = bool(row.api_key)
|
||||
# Prefix only: "voicebox_" (9) + 8 chars, matching the cloud's key_prefix.
|
||||
key_prefix = row.api_key[:17] if row.api_key else None
|
||||
return {
|
||||
"connected": connected,
|
||||
"device_name": row.device_name if connected else None,
|
||||
"account_user_id": row.account_user_id if connected else None,
|
||||
"key_prefix": key_prefix,
|
||||
"connected_at": row.connected_at if connected else None,
|
||||
"dashboard_url": f"{config.get_cloud_web_url()}/account",
|
||||
}
|
||||
|
||||
|
||||
def disconnect(db: Session) -> None:
|
||||
"""Forget the local credential. The key remains valid on the server until
|
||||
revoked from the account dashboard — surface that in the UI."""
|
||||
row = _get_or_create_row(db)
|
||||
row.api_key = None
|
||||
row.device_name = None
|
||||
row.account_user_id = None
|
||||
row.connected_at = None
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_api_key(db: Session) -> str | None:
|
||||
"""The stored bearer key, for the (future) sync client. None if not linked."""
|
||||
row = _get_or_create_row(db)
|
||||
return row.api_key
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"app": {
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"landing": {
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@fontsource/space-grotesk": "^5.2.10",
|
||||
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||
@@ -85,7 +85,9 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.36.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.316.0",
|
||||
"marked": "^18.0.5",
|
||||
"next": "^16.1.3",
|
||||
"postcss": "^8.4.33",
|
||||
"react": "^18.2.0",
|
||||
@@ -104,7 +106,7 @@
|
||||
},
|
||||
"tauri": {
|
||||
"name": "@voicebox/tauri",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
@@ -127,7 +129,7 @@
|
||||
},
|
||||
"web": {
|
||||
"name": "@voicebox/web",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"react": "^18.3.0",
|
||||
@@ -663,7 +665,7 @@
|
||||
|
||||
"arg": ["[email protected]", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"aria-hidden": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
@@ -759,6 +761,8 @@
|
||||
|
||||
"espree": ["[email protected]", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="],
|
||||
|
||||
"esprima": ["[email protected]", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"esquery": ["[email protected]", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrecurse": ["[email protected]", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
@@ -767,6 +771,8 @@
|
||||
|
||||
"esutils": ["[email protected]", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"extend-shallow": ["[email protected]", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
|
||||
|
||||
"fast-deep-equal": ["[email protected]", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
@@ -815,6 +821,8 @@
|
||||
|
||||
"graphemer": ["[email protected]", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
|
||||
|
||||
"gray-matter": ["[email protected]", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
|
||||
|
||||
"has-flag": ["[email protected]", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
@@ -839,6 +847,8 @@
|
||||
|
||||
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-extendable": ["[email protected]", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
||||
|
||||
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
@@ -855,7 +865,7 @@
|
||||
|
||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
"js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="],
|
||||
|
||||
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
@@ -869,6 +879,8 @@
|
||||
|
||||
"keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"kind-of": ["[email protected]", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"levn": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||
@@ -913,6 +925,8 @@
|
||||
|
||||
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
|
||||
|
||||
"merge2": ["[email protected]", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
@@ -1033,6 +1047,8 @@
|
||||
|
||||
"scheduler": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
"section-matter": ["[email protected]", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
|
||||
|
||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"seroval": ["[email protected]", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
|
||||
@@ -1051,8 +1067,12 @@
|
||||
|
||||
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sprintf-js": ["[email protected]", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-bom-string": ["[email protected]", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
|
||||
|
||||
"strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"styled-jsx": ["[email protected]", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
@@ -1131,6 +1151,8 @@
|
||||
|
||||
"zustand": ["[email protected]", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||
|
||||
"@eslint/eslintrc/js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/[email protected]", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
|
||||
@@ -1185,6 +1207,8 @@
|
||||
|
||||
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"eslint/js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"motion/framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.29.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg=="],
|
||||
@@ -1195,8 +1219,12 @@
|
||||
|
||||
"tinyglobby/picomatch": ["[email protected]", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"@eslint/eslintrc/js-yaml/argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"eslint/js-yaml/argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"motion/framer-motion/motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.27.2" } }, "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA=="],
|
||||
|
||||
"motion/framer-motion/motion-utils": ["[email protected]", "", {}, "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q=="],
|
||||
|
||||
+3
-2
@@ -5,8 +5,9 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
# Bind to localhost only for security
|
||||
- "127.0.0.1:17493:17493"
|
||||
# Host-side moved to 17600 so the dev/installed Voicebox can keep 17493.
|
||||
# Container still listens on its native port internally.
|
||||
- "127.0.0.1:17600:17493"
|
||||
|
||||
volumes:
|
||||
# Bind-mount for generated audio (customize the host path as needed)
|
||||
|
||||
+181
-59
@@ -1,6 +1,6 @@
|
||||
# Voicebox Project Status & Roadmap
|
||||
|
||||
> Last updated: 2026-04-18 | Current version: **v0.4.1** | 232 open issues | 12 open PRs
|
||||
> Last updated: 2026-06-27 | Current version: **v0.5.0** | 402 open issues | 88 open PRs | 1.3M downloads · 34.8k stars
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +86,46 @@ POST /generate
|
||||
|
||||
## Current State
|
||||
|
||||
### Since v0.5.0 — Two-Month Pulse (2026-04-25 → 2026-06-27)
|
||||
|
||||
**The repo went quiet while demand kept climbing.** 0.5.0 (the Capture release) shipped 2026-04-25. In the two months since, only **2 PRs merged** (#544 the release itself, #550 a remote-URL fix) while **150 new issues** were opened and the open-PR queue more than tripled to **88**. The community kept contributing — translations, new engines, GPU fixes — but nothing's been reviewed or merged. This is a review-and-merge backlog, not a build backlog.
|
||||
|
||||
| Metric | At v0.4.1 (2026-04-18) | Now (2026-06-27) | Δ |
|
||||
|--------|------------------------|------------------|---|
|
||||
| Open issues | 232 | 402 | +170 |
|
||||
| Open PRs | 12 | 88 | +76 |
|
||||
| GitHub stars | ~28k | 34.8k | +~7k |
|
||||
| Downloads | — | 1.3M | — |
|
||||
|
||||
**What the two months actually produced (all unmerged):**
|
||||
- **A flood of community translations** — pt-BR, de-DE, Russian (+docs), Arabic+RTL, Spanish (+docs site), French, Cantonese. ~12 i18n PRs sitting on the i18next foundation that landed in 0.5.0.
|
||||
- **GPU coverage PRs** — AMD ROCm on Windows (#538), Intel XPU (#539), DirectML for Intel iGPU (#674), Blackwell diagnostic (#653), MLX threading fix (#789).
|
||||
- **A 17-PR hardening dump** from one contributor (@neuron-tech-ai, all 2026-05-14): CI pipeline, Biome, OpenAI-compatible `/v1/audio/speech`, SQLite WAL + indexes, LIKE-injection / upload-limit / N+1 fixes, platform gating. High value, entirely unreviewed.
|
||||
- **New engine PRs** — MiniMax cloud, MOSS-TTS-Nano, Fun-CosyVoice3, Parakeet STT.
|
||||
- **Two giant Linux PRs** — vendored `tao` patch for the Wayland startup panic (#748), and an SRT2Voice workflow (#673).
|
||||
|
||||
**0.5.0 regressions worth triaging first:**
|
||||
- macOS Apple Silicon — all TTS models crash the server on load (#606, #615), MLX falls back to CPU on M4/M5 (#706, #650).
|
||||
- Capture cutoffs at 30s for imported audio (#609, #626); paste broken in 0.5.0 (#762).
|
||||
- MCP rough edges — dotted tool names violate Claude Desktop's name pattern (#790), audio scrambled over MCP (#780).
|
||||
- Refinement silently translates non-English transcripts to English (#603).
|
||||
|
||||
**Funding model — `$VOICEBOX` token (#806):** the two-month gap was a solo-dev decision about long-term sustainability, not neglect or a compromise. `$VOICEBOX` (Solana) is the **official, dev-controlled** token and the chosen revenue path — donations/sponsors didn't cover full-time work. The app stays 100% free, open-source, local-first, no subscriptions. Dev supply is being bought back and burned (done twice), liquidity locked. It has funded ~2–3 months of full-time work, so the cadence resumes this week. The #806 thread was a community concern, addressed transparently and resolved amicably; keep an eye out for actual impersonator/community tokens, which are a separate thing.
|
||||
|
||||
**Other trust/security signals:** macOS malware-flag reports continue (#369); a DNS-rebinding / Host-header exposure on the local API+MCP server was reported with fixes attached (#778).
|
||||
|
||||
---
|
||||
|
||||
### What's Shipped (v0.5.0 — the Capture release)
|
||||
|
||||
Shipped 2026-04-25 (PR #544). Voicebox went from a voice-cloning studio to a full voice studio — dictation in, agent speech out, a local LLM in the middle.
|
||||
|
||||
- **Dictation** — global hotkey capture (push-to-talk + toggle chords), on-screen pill with live state, auto-paste into the focused field with clipboard save/restore, chord-picker UI. Scoped Accessibility permission (transcripts still land if paste is denied).
|
||||
- **MCP server** at `http://127.0.0.1:17493/mcp` — `voicebox.speak` / `.transcribe` / `.list_captures` / `.list_profiles`. Streamable HTTP primary transport, stdio sidecar shim, per-client voice binding via `X-Voicebox-Client-Id`. Speaking pill always shows agent-initiated output.
|
||||
- **Personality** — voice profiles carry an optional ≤2000-char persona. Compose (shuffle an in-character line) and Speak-in-character (rewrite input before TTS), both on a local Qwen3 LLM that doubles as the refinement model.
|
||||
- **Refinement** — on-device Qwen3 strips fillers, fixes punctuation, optional self-correction rewrites; Whisper hallucination-loop stripping at a 6-token threshold; per-capture flag snapshots; model picker (0.6B / 1.7B / 4B).
|
||||
- **`POST /speak` REST wrapper** and **i18next foundation** (English + zh-CN) also landed.
|
||||
|
||||
### What's Shipped (v0.4.x)
|
||||
|
||||
**New since v0.3.0:**
|
||||
@@ -231,64 +271,144 @@ POST /generate
|
||||
|
||||
## Open PRs — Triage & Analysis
|
||||
|
||||
### Recently Merged (Since Last Update — 2026-03-18 → 2026-04-18)
|
||||
**88 open PRs, only 2 merged since 0.5.0.** The queue is the single biggest lever right now — a lot of finished community work is waiting on review. Clustered below by theme. Counts are approximate; a PR can span clusters.
|
||||
|
||||
### Merged since 0.5.0
|
||||
|
||||
| PR | Title | Merged |
|
||||
|----|-------|--------|
|
||||
| **#481** | fix(build): pin transformers in MLX requirements to prevent 5.x upgrade | 2026-04-19 |
|
||||
| **#470** | fix(api-client): declare moved + errors on migrateModels response type | 2026-04-18 |
|
||||
| **#457** | fix(linux): use pactl to detect PipeWire/PulseAudio monitor | 2026-04-18 |
|
||||
| **#450** | docs: clarify paralinguistic tag support in quick start | 2026-04-18 |
|
||||
| **#447** | fix: delete version rows and files in delete_generations_by_profile | 2026-04-18 |
|
||||
| **#444** | Fix generation cancellation flow | 2026-04-18 |
|
||||
| **#440** | fix(paths): strip legacy "data/" prefix when resolving stored paths | 2026-04-18 |
|
||||
| **#439** | Fix migration dialog hanging when no models are present | 2026-04-18 |
|
||||
| **#438** | fix(build): repair frozen-binary imports for kokoro/chatterbox-multilingual/scipy/transformers | 2026-04-18 |
|
||||
| **#433** | fix: warn user when no models to migrate during storage change | 2026-04-18 |
|
||||
| **#425** | Add NUMBA_CACHE_DIR environment variable | 2026-04-16 |
|
||||
| **#424** | fix: avoid ScreenCaptureKit launch crash on macOS 11 | 2026-04-16 |
|
||||
| **#418** | Frontend quality gates + TypeScript hardening | 2026-04-18 |
|
||||
| **#416** | fix(deps): relax PyTorch requirement for macOS Intel (x86_64) | 2026-04-16 |
|
||||
| **#412** | feat(history): add "Clear failed" button | 2026-04-16 |
|
||||
| **#405** | fix: keep cpal Stream alive until playback completes | 2026-04-16 |
|
||||
| **#403** | fix: prevent intermittent clip splitting failures | 2026-04-16 |
|
||||
| **#402** | fix: reliably keep server alive after GUI close on Windows | 2026-04-16 |
|
||||
| **#401** | feat: add Blackwell GPU (sm_120) CUDA support | 2026-04-16 |
|
||||
| **#394** | fix(history): populate status/error/engine fields from DB row | 2026-04-16 |
|
||||
| **#384** | Fix: Resolve ModuleNotFoundError in effects service | 2026-04-16 |
|
||||
| **#361** | fix: torch.from_numpy crash with numpy 2.x in frozen binary | 2026-04-16 |
|
||||
| **#345** | Fix: "Failed to Save" preset error by resolving backend import path | 2026-03-22 |
|
||||
| **#344** | fix: include changelog in docker web build | 2026-03-27 |
|
||||
| **#332** | Fix links in Get Started section of index.mdx | 2026-03-21 |
|
||||
| **#328** | feat: add Qwen CustomVoice preset engine | 2026-03-27 |
|
||||
| **#325** | feat: Kokoro 82M TTS engine + voice profile type system | 2026-03-20 |
|
||||
| **#321** | fix: allows deletion of failed generations | 2026-03-19 |
|
||||
| **#320** | feat: Intel Arc (XPU) GPU support | 2026-03-21 |
|
||||
| **#319** | fix: GUI startup with external server + data refresh on server switch | 2026-03-27 |
|
||||
| **#318** | fix: force offline mode when loading cached models (Qwen TTS & Whisper) | 2026-03-21 |
|
||||
| **#316** | Upgrade CUDA backend from cu126 to cu128, fix GPU settings UI | 2026-03-18 |
|
||||
| **#550** | Fix web API URL for remote access | 2026-04-25 |
|
||||
| **#544** | feat: 0.5.0 Capture release — dictation, MCP, personalities | 2026-04-25 |
|
||||
|
||||
### Currently Open (12 PRs)
|
||||
### i18n / translations (~12) — easy wins, unblock a large user segment
|
||||
|
||||
| PR | Title | Status | Notes |
|
||||
|----|-------|--------|-------|
|
||||
| **#465** | docs: define tier-1 and tier-2 platform support targets | Community PR | Pairs with issue #420. Important for scoping. |
|
||||
| **#463** | feat(actions): add docker-registry.yml for automatic ghcr.io publishing | Community PR | Pairs with issue #453. Low risk. |
|
||||
| **#443** | fix: prevent infinite retry loop in offline mode (#434) | Community PR | Fixes reported bug. |
|
||||
| **#430** | feat: add MiniMax TTS provider support | Community PR | Cloud TTS provider — new direction (external API). Superset of #331? |
|
||||
| **#331** | feat: add MiniMax Cloud TTS as a built-in engine | Community PR | Likely superseded by #430. Dedupe. |
|
||||
| **#311** | feat: add CosyVoice2/3 TTS engine | **Close** | Abandoned — output quality too poor. |
|
||||
| **#253** | Enhance speech tokenizer with 48kHz version | Community PR | Qwen tokenizer upgrade. Still worth reviewing. |
|
||||
| **#227** | fix: harden input validation & file safety | Community PR | Coupled to #225 (custom models). |
|
||||
| **#225** | feat: custom HuggingFace voice model support | Community PR | Needs rework for multi-engine arch. |
|
||||
| **#195** | feat: per-profile LoRA fine-tuning | Draft | Complex. 15 new endpoints. |
|
||||
| **#154** | feat: Audiobook tab | Community PR | Chunked generation now shipped (#266). |
|
||||
| **#91** | fix: CoreAudio device enumeration | Draft | macOS audio device handling. |
|
||||
The i18next + zh-CN foundation shipped in 0.5.0; these stack on it. Triage as a batch.
|
||||
|
||||
| PR | Locale / scope |
|
||||
|----|----------------|
|
||||
| #528 | pt-BR translation |
|
||||
| #571 | de-DE translation |
|
||||
| #599 / #600 / #601 | Russian — app, landing routes, docs |
|
||||
| #569 | Arabic + RTL layout fixes |
|
||||
| #798 / #799 / #801 | Spanish — locale, README/CONTRIBUTING/SECURITY, docs site (see #800 for approach alignment) |
|
||||
| #802 | French translation |
|
||||
| #776 | Cantonese language option |
|
||||
| #688 | Compose follows the selected language |
|
||||
|
||||
### New engines / models (~7)
|
||||
|
||||
| PR | Engine | Notes |
|
||||
|----|--------|-------|
|
||||
| #507 | MOSS-TTS-Nano (0.1B, 20 langs, CPU realtime) | Matches our cross-platform criteria — top engine candidate |
|
||||
| #331 / #430 | MiniMax Cloud TTS | Two PRs, same provider — **dedupe**. External-API direction. |
|
||||
| #777 | Fun-CosyVoice3 (draft) | We abandoned CosyVoice2/3 once on quality — re-evaluate output before reviving |
|
||||
| #766 | Parakeet as STT model | Whisper alternative |
|
||||
| #563 | 4-bit quantized Qwen + Russian abbreviations | Smaller/faster Qwen |
|
||||
| #225 | Custom HuggingFace voice models | Long-lived; needs rework for multi-engine arch |
|
||||
| #195 | Per-profile LoRA fine-tuning (draft, +6.2k) | Complex, 15 endpoints — addresses #185/#224 demand |
|
||||
|
||||
### GPU / hardware (~9)
|
||||
|
||||
| PR | Scope |
|
||||
|----|-------|
|
||||
| #538 | Native AMD ROCm on Windows (+2.8k, resolves #531) |
|
||||
| #539 | Optional IPEX + native Intel XPU detection for PyTorch 2.9+ |
|
||||
| #674 | DirectML for Intel iGPU (Iris/UHD/Arc) — pairs with demand in #676, #759 |
|
||||
| #653 | Blackwell GPU arch-mismatch diagnostic — directly targets the sm_120 cluster |
|
||||
| #789 | Run MLX load+inference on one thread (fixes #699) — likely fixes the M-series load crashes |
|
||||
| #560 / #561 | Linux NVIDIA auto-detect + Linux CUDA backend build |
|
||||
| #736 / #785 / #769 | ROCm `HSA_OVERRIDE_GFX_VERSION` cleanup (resolves #469) |
|
||||
| #770 | Fix CUDA downloads on unsupported platforms |
|
||||
|
||||
### Capture / transcription / refinement (~6) — fixes 0.5.0 regressions
|
||||
|
||||
| PR | Fix |
|
||||
|----|-----|
|
||||
| #602 | Re-encode uploaded audio as PCM WAV before Whisper — likely fixes the 30s import cutoff (#609/#626) |
|
||||
| #616 | Enable long-form Whisper on the PyTorch path |
|
||||
| #629 | Preserve source language in refinement (fixes #603 English translation) |
|
||||
| #637 | MCP/REST generations incorrectly trigger autoplay |
|
||||
| #712 | Personality LLM respects the selected refinement model |
|
||||
| #796 | Capture preview placement setting (#698) |
|
||||
|
||||
### Long-form / stories / streaming (~5)
|
||||
|
||||
| PR | Scope |
|
||||
|----|-------|
|
||||
| #154 | Audiobook tab with chunked generation (predates shipped chunking — reconcile) |
|
||||
| #673 | SRT2Voice workflow (+14k) — large, subtitle-driven generation |
|
||||
| #787 | m4b/mp3 story export with auto chapter markers |
|
||||
| #642 | `stream=true` immediate-audio mode for `GET /tts` |
|
||||
| #804 | Stream MLX TTS audio chunks (draft) |
|
||||
|
||||
### Linux / Wayland (~5)
|
||||
|
||||
| PR | Scope |
|
||||
|----|-------|
|
||||
| #748 | Vendor-patch tao 0.34.8 for Wayland startup panic (+39k — large vendored diff, verify approach) |
|
||||
| #624 | Linux tauri schema + build deps (+6.2k) |
|
||||
| #747 | Avoid abort when hiding the dictate pill |
|
||||
| #622 / #768 | Linux audio monitor selection / thread-unsafe `PULSE_SOURCE` |
|
||||
| #677 | HF cache permissions + startup fallback |
|
||||
|
||||
### Hardening / CI / perf — the @neuron-tech-ai batch (all 2026-05-14, ~17 PRs)
|
||||
|
||||
One contributor opened a large, coherent quality suite in a single day. Review as a group; #662 is the headline.
|
||||
|
||||
| PR | Scope |
|
||||
|----|-------|
|
||||
| #662 | LIKE injection, upload-size enforcement, N+1 queries, SSE reconnect, memory leaks, model display names (+6.8k) |
|
||||
| #654 | CI pipeline + pre-commit hooks + Biome config + test suite |
|
||||
| #656 | OpenAI-compatible `/v1/audio/speech` + `/v1/models` (addresses #10) |
|
||||
| #657 | Platform gating on `ModelConfig` + UI (addresses bottleneck #6 / issue #419) |
|
||||
| #666 / #667 | DB indexes on hot FKs + SQLite WAL + busy timeout |
|
||||
| #659 / #660 / #661 / #663 / #665 / #668 | datetime.utcnow→UTC, MediaRecorder crash + sample reorder, fail-fast on missing model, batch story counts, Metal warmup, drop debug logs |
|
||||
| #652 / #655 / #658 / #664 | AGENTS.md, docs GitHub Pages, avatar size limit, non-fatal actool |
|
||||
|
||||
### Build / dev tooling / docker (~6)
|
||||
|
||||
#764 uv for backend env · #632 docker GPU build + cache + fastmcp (+7.9k) · #630 ROCm docker overlay · #463 ghcr.io auto-publish · #543 / #681 setup-script fixes · #584 docker permission fix
|
||||
|
||||
### Smaller fixes worth grabbing
|
||||
|
||||
#786 remove 50k char limit (#464) · #621 broken-pipe crashes on model load · #743 harden mac generation status + MLX threading · #788 missing male Mandarin Kokoro voices · #794 build mcp shim on Windows · #527 Chatterbox exaggeration + CFG sliders · #253 48kHz speech tokenizer
|
||||
|
||||
### Stale / low-signal — close or request changes
|
||||
|
||||
#91 (draft, Feb, CoreAudio, +6.2k unrebased) · #649 ("fix this errors") · #623 / #782 (badges / package tweaks) · #311-style abandoned engines — verify before merging anything older than ~April against the 0.5.0 codebase.
|
||||
|
||||
---
|
||||
|
||||
## Open Issues — Categorized
|
||||
|
||||
**402 open, +150 in the two months since 0.5.0.** Demand snapshot from a keyword sweep over all open titles (buckets overlap):
|
||||
|
||||
| Theme | ~Open | Signal |
|
||||
|-------|-------|--------|
|
||||
| New model / engine requests | ~79 | Largest category. Voxtral, OmniVoice, VibeVoice, VoxCPM2, CosyVoice3, Dramabox, Parakeet, GGUF, ONNX/Piper export |
|
||||
| CUDA / GPU / Blackwell | ~53 | Still the #1 *bug* driver — sm_120 "no kernel image", ROCm, DirectML, Intel Arc, VRAM/load times |
|
||||
| Model download / server startup | ~42 | Stuck downloads, "server process ended unexpectedly", `loading_model` hangs |
|
||||
| Capture / dictation / transcribe | ~31 | New surface from 0.5.0 — 30s cutoffs, paste, mic permission, refinement translation |
|
||||
| Language / locale requests | ~27 | Bengali, Ukrainian, Filipino, Indonesian, Cantonese, zh-TW; plus UI localization |
|
||||
| Fine-tune / clone quality | ~22 | #185 (top-engagement issue), accent leakage, "finetunes not working" |
|
||||
| Long-form / chunking / export | ~16 | Pause control, speed control, audiobook export, >50k chars |
|
||||
| Linux / Wayland | ~11 | Build failures, Wayland panics, CUDA-on-Linux packaging |
|
||||
| MCP / agent / API | ~9 | Dotted tool names (#790), scrambled audio (#780), OpenAI compat (#10) |
|
||||
| Security / trust | ~4 | DNS-rebinding (#778), malware flag (#369); funding via official $VOICEBOX token (#806) |
|
||||
|
||||
**Highest-engagement open issues:** #185 Fine-tune instructions (32c) · #98 Connecting to Download (16c) · #301 CUDA generation failure (18c) · #20 Model download failed (13c) · #364 Voxtral-TTS FR (11r) · #341 Arch Linux build · #513 server startup failed (12c) · #138 ONNX/Piper export (9r) · #10 OpenAI API compat.
|
||||
|
||||
### New since 0.5.0 — clusters to triage first
|
||||
|
||||
- **macOS Apple Silicon load crashes (regression):** #606, #615 — all TTS models crash the server on load; #706, #650 — MLX falls back to CPU / 7-min VRAM load on M4/M5. PR #789 (single-thread MLX, fixes #699) and #743 are the candidate fixes. **Highest priority — breaks the primary platform.**
|
||||
- **Capture cutoffs & paste:** #609, #626 — transcription stops at 30s for imported audio (PR #602 re-encodes WAV); #762 — paste broken in 0.5.0; #698, #577 — capture/output folder locations.
|
||||
- **MCP integration:** #790 — dotted tool names violate Claude Desktop's `^[a-zA-Z0-9_-]{1,64}$`; #780 — audio scrambled over MCP; #728 — CUDA re-downloads on cold start.
|
||||
- **Refinement:** #603 — silently translates non-English transcripts to English (PR #629 preserves source language).
|
||||
- **GPU expansion requests:** #676 DirectML (AMD/Intel), #759 Intel Arc, #684 RTX 5060 Ti CUDA 13, #774 CUDA 11.x for older cards, #767 Linux CUDA installs Windows `.exe`.
|
||||
- **New engines/langs:** #791 OmniVoice, #633 VoxCPM2, #690 Dramabox, #638 Bengali, #754 zh-TW, #761 Filipino.
|
||||
- **Open plugin interface (#771):** request for a community engine/provider plugin API — ties into engine-sprawl (#419) and platform-gating work.
|
||||
- **Trust/security:** #806 — `$VOICEBOX` is the official dev-backed funding token (concern raised and resolved on-thread; see funding note above); #778 DNS-rebinding/Host-header exposure on local API+MCP (fixes attached) — genuine security item; #369 macOS malware flag (ongoing).
|
||||
|
||||
### GPU / Hardware Detection — still the top category
|
||||
|
||||
**RTX 50-series (Blackwell / sm_120) cluster — NEW:** #417, #400, #396, #395, #390, #362 all report `cudaErrorNoKernelImageForDevice` / "no kernel image available." sm_120 support shipped in PR #401 + cu128 in PR #316, but users on upgraded installs still hit it — likely stale CUDA binary. Needs a diagnostic that detects binary/GPU-arch mismatch and prompts re-download.
|
||||
@@ -523,19 +643,21 @@ Seven TTS engines shipped, more candidates queued. Issue #419 asks for a first-c
|
||||
|
||||
## Recommended Priorities
|
||||
|
||||
### Tier 1 — Ship Now
|
||||
### Tier 1 — Ship Now (the next release is mostly a merge-and-fix pass)
|
||||
|
||||
The two-month gap means the highest-leverage work isn't new code — it's reviewing the 88-PR queue and shipping the 0.5.0 regression fixes that are already written.
|
||||
|
||||
| Priority | PR/Item | Impact | Effort |
|
||||
|----------|---------|--------|--------|
|
||||
| 1 | **RTX 50-series / Blackwell diagnostic** — detect stale CUDA binary vs GPU arch, prompt re-download (#417, #400, #396, #395, #390, #362) | Large cluster of user-blocking errors | Medium |
|
||||
| 2 | **CustomVoice download failures** (#475, #445) | New engine blocked on MAC/Win — regression triage | Medium |
|
||||
| 3 | **50k char limit on GPU** (#464) | Regression — chunking should handle this | Medium |
|
||||
| 4 | Close PR #311 (CosyVoice) and dedupe #331/#430 (MiniMax) | Housekeeping | None |
|
||||
| 5 | **PR #443** — infinite offline retry loop | Bug fix, reviewable | Low |
|
||||
| 6 | **PR #465** — define tier-1 / tier-2 platforms | Unblocks engine-sprawl decision (#419) | Low |
|
||||
| 7 | **PR #463** — docker registry auto-publish | Community PR, low risk | Low |
|
||||
| 8 | **#253** — 48kHz speech tokenizer | Quality improvement for Qwen | Medium |
|
||||
| 9 | **Kokoro profile UX** (#360) — partially addressed by auto-switch | Polish | Low |
|
||||
| 1 | **macOS Apple Silicon load crash** (#606, #615, #706, #650) — review/merge PR #789 (single-thread MLX) + #743 | Breaks the primary platform on 0.5.0 | Low (PRs exist) |
|
||||
| 2 | **Capture 30s import cutoff** (#609, #626) — review PR #602; paste-broken #762 | Core 0.5.0 feature degraded | Low–Medium |
|
||||
| 3 | **Refinement translates to English** (#603) — merge PR #629 | Silent data loss for non-English users | Low |
|
||||
| 4 | **MCP dotted tool names** (#790) — breaks Claude Desktop; scrambled audio #780 | Flagship integration broken for some clients | Low–Medium |
|
||||
| 5 | **Blackwell / sm_120 diagnostic** — review PR #653; stale-binary re-download path | Largest GPU bug cluster | Medium |
|
||||
| 6 | **Drain the i18n batch** (#528, #571, #599–601, #569, #798–801, #802, #776) | ~12 finished PRs, large user segment | Low (review-bound) |
|
||||
| 7 | **Review the @neuron-tech-ai hardening batch** — start with #662, #657 (platform gating), #656 (OpenAI API), #654 (CI) | Security + perf + bottleneck #6 in one sweep | Medium (review-bound) |
|
||||
| 8 | **Remove 50k char limit** (#464) — merge PR #786; tune chunk boundaries | Long-standing regression | Low |
|
||||
| 9 | Housekeeping — dedupe MiniMax #331/#430, re-evaluate CosyVoice #777, close spam/empty issues (#805, #775) | Triage hygiene | Low |
|
||||
|
||||
### Tier 2 — Feature Work
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Voicebox Cloud Roadmap
|
||||
|
||||
The post-mobile commercial trajectory. Captures the strategic arc beyond `mobile/PLAN.md` — what Voicebox becomes once the mobile companion ships and we start layering optional cloud services on top of the local-first base.
|
||||
|
||||
The desktop app stays free. Paid surface is the cloud layer, gated behind a Voicebox account, designed so the server sees as little as possible.
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0 — Mobile companion (in progress)
|
||||
|
||||
See [`mobile/PLAN.md`](../../mobile/PLAN.md). Entirely local: paired-device keys live on the iPhone, traffic goes over Tailscale or LAN, no cloud account required. This is the wedge — it establishes the device-key primitive that every later phase reuses.
|
||||
|
||||
### Phase 1 — Backup & Sync (next big feature)
|
||||
|
||||
First introduction of a Voicebox cloud account. Server stores **only encrypted blobs**.
|
||||
|
||||
- **E2E encryption keyed off the device key** from the mobile pairing flow. Audio + transcript blobs are encrypted client-side before upload; the server never has the plaintext or the key.
|
||||
- **Quota by number of generations**, not by storage GB. Avoids "how many GB do you offer" framing and keeps tiering legible. (Word-count quotas are an alternative — closer to the ElevenLabs model — but generations are simpler to communicate.)
|
||||
- **What's synced:** captures (audio + transcripts), generations, voice profiles **as ciphertext**, settings.
|
||||
- **What's NOT synced:** voice profile audio in plaintext, refinement LLM context, anything that would let us reconstruct what a user said or who they sound like.
|
||||
- **Multi-device read:** the same paired-device key on a second device decrypts the backup. Recovery via printable key on first pairing.
|
||||
|
||||
The privacy framing is load-bearing. "We see encrypted blobs and that's it" is the commitment the rest of the cloud story rests on.
|
||||
|
||||
### Phase 2 — Private Voice Inference ("the OpenRouter for voice")
|
||||
|
||||
The big bet. Today there is no major neutral voice-inference provider — every cloud TTS service ships its own proprietary models. Open-source TTS models exist and keep getting better, but nobody runs them as a paid hosted catalog at scale.
|
||||
|
||||
Voicebox already has the distribution. The thesis is: the same users who chose local-first specifically to avoid sending voice data to ElevenLabs will pay a fair markup to run open-source voices on hosted GPUs **when they don't have local hardware** (mobile-only users, low-end laptops, "I just don't want to manage CUDA"), provided the privacy story stays consistent.
|
||||
|
||||
- **Catalog-first positioning.** Cloud can offer more voices than the desktop binary bundles (the bundle is already 500MB without CUDA, ~3GB with — there's a hard ceiling on what we can ship locally). Catalog grows over time.
|
||||
- **Pricing tiers (rough first cut):** $5 / $15 / $25 / month, plus Enterprise. Final numbers depend on benchmarking — see below.
|
||||
- **Unit economics work to do:** benchmark every open-source TTS engine in the lineup (Qwen3-TTS, Chatterbox Multilingual + Turbo, TADA, Kokoro, LuxTTS, plus future additions) for cost-per-generation on candidate hardware. Find the engines where our markup is comfortably below ElevenLabs's per-character cost.
|
||||
- **Privacy ceiling:** server-side inference cannot be cryptographically verified the way E2E backup can. The honest framing is "we don't log inputs, we don't train on your data, audited" — not "we mathematically can't see it." That's a real step down from Phase 1's guarantee, and the product has to be clear about it.
|
||||
- **Mobile + OS integrations.** Once cloud inference exists, the mobile app unlocks the same OS-level surfaces ElevenLabs has (keyboard-tied dictation, share-sheet TTS, Siri-equivalent). Local-first users still get them via paired desktop; cloud users get them without needing a desktop at all.
|
||||
|
||||
#### Inference architecture
|
||||
|
||||
**Compute layer.** Modal as the v1 platform — per-second billing, scale-to-zero, volume mounts for model weights, runs our existing Python code with a thin decorator. The ~30-50% premium over raw GPU cost is irrelevant at launch scale and small (less than one DevOps hire) at $1M ARR. Migrate engine-by-engine to bare-metal (Lambda Labs, Crusoe, CoreWeave) once any single engine has predictable demand. Hyperscalers (AWS / GCP) only for enterprise contracts that require it.
|
||||
|
||||
**Topology.**
|
||||
|
||||
```
|
||||
Client (desktop / mobile / API user)
|
||||
│
|
||||
▼ HTTPS, bearer auth
|
||||
Gateway ← R2: encrypted profile blobs
|
||||
│ ← D1 / Postgres: users, billing, quotas, profile metadata
|
||||
▼ internal RPC
|
||||
Per-engine Modal apps (Kokoro / Chatterbox / TADA / Whisper / …)
|
||||
```
|
||||
|
||||
**Gateway.** Cloudflare Workers + R2 + D1 for v1 — Workers handle auth and routing, R2 has no egress fees which matters when the payload is audio, D1 handles small relational state (users, quotas, profile metadata). Auth, billing, rate limits, profile resolution, engine routing, and log redaction all live in the gateway. Workers stay dumb: they receive a request with the profile envelope already in hand, run inference, stream audio back. The gateway is what makes engine migration painless — moving TADA to bare-metal later is a routing config change, not a client change.
|
||||
|
||||
**Model packaging.** Each `backend/backends/<engine>.py` class becomes a Modal `@app.cls` wrapper. Same inference code as desktop. Weights download on container build, live on a Modal Volume, get reused by warm containers. The PyInstaller-specific runtime hooks from 0.4.x (scipy / transformers / `torch._dynamo` workarounds for the frozen binary) factor into a `frozen.py` runtime hook the desktop build imports — cloud doesn't. Single source of truth for inference logic; two entry points for two runtimes.
|
||||
|
||||
**Streaming.** SSE over HTTPS, base64-encoded audio frames, interleaved status events (`queued` / `generating` / `done`), usage event at the end with `characters_consumed` and `seconds_generated`. Wire format identical to the desktop SSE pattern from 0.2.x — cloud is the same shape at a different URL.
|
||||
|
||||
**Latency budgets** (first audio chunk, warm / cold):
|
||||
|
||||
| Engine | Hardware | Warm | Cold | Pool strategy |
|
||||
| ---------------------------------- | --------- | ---- | ---- | ------------------------------ |
|
||||
| Kokoro, LuxTTS | CPU | <1s | ~5s | Scale-to-zero |
|
||||
| Chatterbox Turbo, Whisper Turbo | A10g / L4 | 1-3s | ~15s | Small warm pool, p95 sizing |
|
||||
| Qwen3-TTS, Chatterbox Multilingual | A10g / L4 | 2-5s | ~30s | Larger warm pool |
|
||||
| TADA-3B | A100 | ~5s | ~60s | Premium tier only, capped pool |
|
||||
|
||||
Scale-to-zero where cold start fits the budget. Hot engines need warm pools sized to p95 demand — that's where unit economics get sensitive. Reserved capacity only after a quarterly demand baseline.
|
||||
|
||||
**Profile pipeline.** Cloned voice → encrypted blob with user-account-key → uploaded to R2 cold storage → fetched into worker memory at job start → decrypted in memory only, never written to worker disk → discarded on worker idle. TTL applies at the R2 layer (cold storage retention); worker hot-path retention is bounded by warmup window. Embedding-only caching where the engine exposes a stable embedding interface; raw-audio caching is the fallback. Per-engine audit needed before launch — see open questions.
|
||||
|
||||
**Hybrid routing on the client.** Desktop, mobile, and MCP clients already speak `127.0.0.1:17493`. Add `VOICEBOX_API_URL` + `VOICEBOX_API_KEY` plus a routing function:
|
||||
|
||||
```
|
||||
if local_backend_reachable() and engine in local_engines:
|
||||
→ 127.0.0.1:17493
|
||||
else:
|
||||
→ api.voicebox.sh/v1
|
||||
```
|
||||
|
||||
Mobile-without-paired-desktop falls through to cloud automatically. Desktop without a usable GPU falls through for big engines, stays local for Kokoro. Same `voicebox.speak()` MCP call works either way. This is the differentiator versus ElevenLabs (cloud-only) and pure local-first competitors (no fallback).
|
||||
|
||||
#### Cloud-cached voice profiles
|
||||
|
||||
Inference latency makes it untenable to re-upload reference samples per call. Cloud caches the user's own voice profiles for the user's own inference, under tight guardrails:
|
||||
|
||||
- **Per-profile opt-in.** Profiles are local-only by default. A "Cloud-enabled" toggle (per-profile, never global, never automatic) is what triggers upload on first cloud generation. Mobile-without-paired-desktop is the main upgrade path here — without cached profiles, mobile cloud is preset-voices only.
|
||||
- **User-controlled TTL.** `Session only` / `24h` / `7d` / `30d` / `Never expire`. Conservative default (24h). Auto-purge on inactivity regardless of ceiling.
|
||||
- **Encrypted at rest under a user-account-key envelope.** Inference workers decrypt in memory only. Keys derived from the same identity primitive that backs Phase 1.
|
||||
- **Cache embeddings, not raw audio, where the engine supports it.** Speaker embeddings (Chatterbox-style) are derived vectors — cache *those* instead of the .wav. Smaller blast radius, not reconstructible to original speech. Per-engine audit needed before launch (Qwen3-TTS, Chatterbox Multilingual + Turbo, TADA all do speaker conditioning differently); raw-audio caching is the fallback when the engine doesn't expose a stable embedding interface.
|
||||
- **Consent attestation logged at upload.** "I have rights to this voice." Timestamped, retained. Doesn't shield from claims but it's the legal posture.
|
||||
- **Verifiable deletion.** `DELETE /v2/profiles/{id}/cloud-cache` from day one, enforced across replicas, surfaced in-app as a one-click action.
|
||||
- **Trust tier:** audited-no-log, encrypted at rest, user-controlled TTL — *not* the cryptographic guarantee Phase 1 backup carries. The product has to communicate this difference clearly so cached profiles don't bleed into the Phase 1 framing. The TTL control is the marketable differentiator versus ElevenLabs, which doesn't expose retention as a user lever at all.
|
||||
- **Legal line items.** GDPR Article 9 (biometrics are special category), BIPA ($1k–5k statutory damages per violation), Texas CUBI, Washington MHMD. Real consent flow, retention controls, deletion rights, breach notification, signed DPAs for enterprise. SOC 2 + pen test before this surface goes public — not optional.
|
||||
|
||||
### Phase 3 — Voice Marketplace (much later)
|
||||
|
||||
A marketplace where voice owners license their cloned voices for others to use, with revenue sharing. Possibly: "rent out your AI voice."
|
||||
|
||||
This is the only phase that requires hosting voice profiles, and it requires real licensing infrastructure first — consent verification, takedown flow, identity claims, revenue accounting. Until that exists, **Voicebox does not host voice profiles in cloud at all** (see constraint below). Marketplace is the long-term endgame, not the next quarter.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting constraints
|
||||
|
||||
### Voice profiles in cloud: owner-only, opt-in, time-bound
|
||||
|
||||
Three rules, in increasing strictness depending on phase:
|
||||
|
||||
- **Phase 1 (backup & sync):** profiles travel as ciphertext the server cannot decrypt. The server has no path to plaintext for any reason.
|
||||
- **Phase 2 (inference):** the user's own profiles can be cached for the user's own inference, but only with per-profile opt-in, user-controlled TTL, encryption at rest, and verifiable deletion. The server holds plaintext (or derived embeddings) under audited-no-log terms — a real downshift from Phase 1's cryptographic guarantee, and one the product has to communicate honestly.
|
||||
- **Phase 3 (marketplace):** hosting other users' voices for non-owners is gated on consent verification, licensing, takedown, and revenue accounting infrastructure. Until those exist, no profile is served to anyone but its owner. No shortcuts.
|
||||
|
||||
This protects two things at once:
|
||||
- **Legal posture.** Biometric voice data triggers GDPR Article 9, BIPA, Texas CUBI, Washington MHMD. The trust hierarchy above maps to the consent and retention story we can defend at each phase.
|
||||
- **Privacy positioning.** Phase 1 is "cryptographically can't see." Phase 2 is "audited won't see, with a timer you control." Both are honest, both sit above ElevenLabs's posture, and both have to be communicated as distinct trust tiers — not blurred together.
|
||||
|
||||
### Privacy is the moat, not a feature
|
||||
|
||||
The "private LLM users → ElevenLabs voice" workflow is incoherent: people pay to keep their text private and then hand their speech to a cloud vendor that trains on it. Voicebox is the consistent answer for that audience. Every cloud feature should be designed so a privacy-conscious user can adopt it without breaking that internal consistency — which is why Phase 1 is fully E2E and Phase 2 is "audited no-log" rather than "we have your audio but trust us."
|
||||
|
||||
### Revenue stack is multi-source
|
||||
|
||||
Subscriptions are not the only line. The full picture:
|
||||
|
||||
- **Subscriptions** — Phase 1 quotas + Phase 2 inference
|
||||
- **Corporate sponsorship** — `landing/src/app/sponsors/page.tsx`, $500/mo tier live in 0.5
|
||||
- **Individual donations** — Buy Me a Coffee
|
||||
- **Marketplace revenue share** — Phase 3, far off
|
||||
|
||||
Diversification matters because the desktop app stays free forever. Subscriptions never have to carry the whole product.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing & "ease it onto them"
|
||||
|
||||
The deliberate ordering is privacy-additive: each phase introduces the next layer of cloud only after the user has had time to trust the previous one.
|
||||
|
||||
1. **Mobile (entirely local)** — no account, no cloud, just a companion to the desktop you already trust.
|
||||
2. **Backup & sync (cloud, fully E2E)** — first cloud account. Server sees nothing. Trust is bootstrapped on "we built the math so we can't see your data even if we wanted to."
|
||||
3. **Private inference (cloud, audited no-log)** — second cloud surface. Honest about the ceiling: server-side inference can't carry the same cryptographic guarantee, but the operational commitment is no logs, no training, audited.
|
||||
4. **Marketplace (cloud, profiles hosted with consent)** — only after licensing infra. The most invasive surface, gated behind real verification.
|
||||
|
||||
Skipping ahead breaks the trust ladder. Don't ship marketplace before backup & sync is mature; don't ship hosted inference before users are comfortable holding accounts at all.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Quota unit.** Generations vs. words vs. characters. Generations is the cleanest to communicate; words/characters maps onto how ElevenLabs prices and might be required for inference billing. Could be different units per phase (generations for backup, characters for inference).
|
||||
2. **Recovery key UX.** First pairing in Phase 1 needs to print a recovery key. How prominent? Force-display vs. hide-behind-link?
|
||||
3. **Inference billing model.** Per-character (ElevenLabs-style), per-generation (simpler), per-second-of-output (closest to GPU cost). Pick before pricing tiers are finalized.
|
||||
4. **Bring-your-own-key for inference?** Some privacy-conscious users may prefer to provide their own GPU credits / API keys to a third-party host through us. Worth considering for Enterprise.
|
||||
5. **Marketplace consent verification.** What's the bar? Notarized release? Real-time liveness check? Out of scope for Phase 1-2 but informs how the device key is structured today.
|
||||
6. **Default cloud-cache TTL.** 24h is the proposed conservative default. Worth A/B testing against `Session only` for first-time users — the "auto-purge after this session" framing might be a stronger trust signal than any number.
|
||||
7. **Embedding vs. raw-audio caching, per engine.** Chatterbox produces stable speaker embeddings; Qwen3-TTS, TADA, and others use different conditioning strategies. Audit needed before launch — embedding-only caching shrinks the legal/privacy surface meaningfully, but only where the engine exposes a clean embedding interface.
|
||||
8. **Single gateway region or multi-region?** Cloudflare is global by default, but Modal apps are primarily us-east / us-west. EU users hitting US compute = +100ms first-token latency, and GDPR pushes toward EU compute regardless. v1 single-region or hold launch for EU?
|
||||
9. **SSE vs WebSocket for streaming.** SSE works through any proxy and is what desktop already uses, so the wire format is shared for free. WebSocket is bidirectional and unlocks "interrupt mid-generation" and live duplex features later. Default: SSE for v1, WS as a follow-on.
|
||||
10. **Cloud Whisper in the v1 bundle?** Phase 2 was framed as TTS-only ("OpenRouter for voice"), but mobile dictation hitting cloud Whisper instead of a paired desktop is the obvious mobile-only feature. Same launch bundle, or hold for Phase 2.5?
|
||||
11. **Billing integration.** Stripe Metered + customer portal (~2 weeks of work, 2.9% fee) vs self-hosted (saves the fee, adds significant ongoing work). Default: Stripe.
|
||||
|
||||
---
|
||||
|
||||
## How this connects to mobile V1
|
||||
|
||||
The encryption story starts with the device key minted during mobile pairing (`mobile/PLAN.md` → "Pairing & transport"). That same key — or a key derived from it — is what encrypts cloud blobs in Phase 1. Don't treat the mobile pairing key as a one-off; design it as the root of the user's lifetime encryption identity, with rotation + multi-device-add flows in mind even if those don't ship until Phase 1.
|
||||
@@ -17,7 +17,9 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.36.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.316.0",
|
||||
"marked": "^18.0.5",
|
||||
"next": "^16.1.3",
|
||||
"postcss": "^8.4.33",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 1180 320" xmlns="http://www.w3.org/2000/svg"><path d="m367.44 153.84c0 52.32 33.6 88.8 80.16 88.8s80.16-36.48 80.16-88.8-33.6-88.8-80.16-88.8-80.16 36.48-80.16 88.8zm129.6 0c0 37.44-20.4 61.68-49.44 61.68s-49.44-24.24-49.44-61.68 20.4-61.68 49.44-61.68 49.44 24.24 49.44 61.68z"/><path d="m614.27 242.64c35.28 0 55.44-29.76 55.44-65.52s-20.16-65.52-55.44-65.52c-16.32 0-28.32 6.48-36.24 15.84v-13.44h-28.8v169.2h28.8v-56.4c7.92 9.36 19.92 15.84 36.24 15.84zm-36.96-69.12c0-23.76 13.44-36.72 31.2-36.72 20.88 0 32.16 16.32 32.16 40.32s-11.28 40.32-32.16 40.32c-17.76 0-31.2-13.2-31.2-36.48z"/><path d="m747.65 242.64c25.2 0 45.12-13.2 54-35.28l-24.72-9.36c-3.84 12.96-15.12 20.16-29.28 20.16-18.48 0-31.44-13.2-33.6-34.8h88.32v-9.6c0-34.56-19.44-62.16-55.92-62.16s-60 28.56-60 65.52c0 38.88 25.2 65.52 61.2 65.52zm-1.44-106.8c18.24 0 26.88 12 27.12 25.92h-57.84c4.32-17.04 15.84-25.92 30.72-25.92z"/><path d="m823.98 240h28.8v-73.92c0-18 13.2-27.6 26.16-27.6 15.84 0 22.08 11.28 22.08 26.88v74.64h28.8v-83.04c0-27.12-15.84-45.36-42.24-45.36-16.32 0-27.6 7.44-34.8 15.84v-13.44h-28.8z"/><path d="m1014.17 67.68-65.28 172.32h30.48l14.64-39.36h74.4l14.88 39.36h30.96l-65.28-172.32zm16.8 34.08 27.36 72h-54.24z"/><path d="m1163.69 68.18h-30.72v172.32h30.72z"/><path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,104 @@
|
||||
import {readFileSync} from "node:fs";
|
||||
import {join} from "node:path";
|
||||
import {ImageResponse} from "next/og";
|
||||
import {formatDate, getPost, loadAllPosts} from "@/lib/blog";
|
||||
|
||||
// Per-post Open Graph image, generated with Satori at build time (static export
|
||||
// of each post route) and served as PNG. Note: this runs in the Satori renderer,
|
||||
// which only understands inline styles + flexbox and a subset of CSS — no
|
||||
// Tailwind classes, no `filter: blur()`. Glows are done with radial gradients.
|
||||
|
||||
export const size = {width: 1200, height: 630};
|
||||
export const contentType = "image/png";
|
||||
export const alt = "Voicebox Blog";
|
||||
|
||||
// Pre-build an image for every post route (mirrors the page's static params).
|
||||
export function generateStaticParams() {
|
||||
return loadAllPosts().map((post) => ({slug: post.slug}));
|
||||
}
|
||||
|
||||
// 8-bit PNG decodes reliably in Satori; the 1024px logos are 16-bit and don't.
|
||||
const logo = `data:image/png;base64,${readFileSync(
|
||||
join(process.cwd(), "public/apple-touch-icon.png"),
|
||||
).toString("base64")}`;
|
||||
|
||||
function titleFontSize(title: string): number {
|
||||
if (title.length <= 38) return 76;
|
||||
if (title.length <= 64) return 60;
|
||||
return 48;
|
||||
}
|
||||
|
||||
export default async function OgImage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{slug: string}>;
|
||||
}) {
|
||||
const {slug} = await params;
|
||||
const post = getPost(slug);
|
||||
const title = post?.title ?? "Voicebox Blog";
|
||||
const meta = post
|
||||
? `${post.author} · ${formatDate(post.date)}`
|
||||
: "Open source voice cloning. Local-first.";
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
padding: 80,
|
||||
background:
|
||||
"radial-gradient(ellipse 80% 70% at 30% 30%, hsla(43,60%,50%,0.14) 0%, hsla(43,60%,50%,0.04) 40%, transparent 70%), linear-gradient(180deg, hsl(30,4%,6%) 0%, hsl(30,4%,4%) 100%)",
|
||||
}}
|
||||
>
|
||||
{/* Top: logo + eyebrow */}
|
||||
<div style={{display: "flex", alignItems: "center", gap: 24}}>
|
||||
{/* biome-ignore lint/performance/noImgElement: Satori only renders <img> */}
|
||||
<img src={logo} width={88} height={88} alt="" />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 26,
|
||||
letterSpacing: 6,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
color: "hsl(43, 60%, 58%)",
|
||||
}}
|
||||
>
|
||||
Voicebox Blog
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: titleFontSize(title),
|
||||
lineHeight: 1.1,
|
||||
fontWeight: 700,
|
||||
letterSpacing: -1,
|
||||
color: "hsl(30, 10%, 94%)",
|
||||
maxWidth: 1000,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Footer meta */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 28,
|
||||
color: "hsl(30, 5%, 55%)",
|
||||
}}
|
||||
>
|
||||
{meta}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {notFound} from "next/navigation";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {formatDate, getPost, loadAllPosts} from "@/lib/blog";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return loadAllPosts().map((post) => ({slug: post.slug}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{slug: string}>;
|
||||
}): Promise<Metadata> {
|
||||
const {slug} = await params;
|
||||
const post = getPost(slug);
|
||||
if (!post) return {title: "Post not found — Voicebox"};
|
||||
return {
|
||||
title: `${post.title} — Voicebox`,
|
||||
description: post.excerpt,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
type: "article",
|
||||
url: `https://voicebox.sh/blog/${post.slug}`,
|
||||
// og:image / twitter:image come from the colocated opengraph-image.tsx
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{slug: string}>;
|
||||
}) {
|
||||
const {slug} = await params;
|
||||
const post = getPost(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
<main className="mx-auto w-full max-w-3xl px-6 pt-32 pb-20">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="font-mono text-sm text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
← Back to blog
|
||||
</Link>
|
||||
|
||||
<header className="mt-8 border-b border-border pb-10">
|
||||
{post.tags.length > 0 ? (
|
||||
<div className="mb-5 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-border/60 bg-card/40 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tighter text-foreground">
|
||||
{post.title}
|
||||
</h1>
|
||||
<p className="mt-5 font-mono text-sm text-muted-foreground">
|
||||
by <span className="text-foreground">{post.author}</span> ·{" "}
|
||||
{formatDate(post.date)} · {post.readingMinutes} min read
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<article
|
||||
className="blog-prose mt-10"
|
||||
// Content is authored markdown from this repo, not user input.
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: trusted local markdown
|
||||
dangerouslySetInnerHTML={{__html: post.html}}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {formatDate, listPosts} from "@/lib/blog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog — Voicebox",
|
||||
description: "Notes from building Voicebox — the open-source AI voice studio.",
|
||||
openGraph: {
|
||||
title: "Voicebox Blog",
|
||||
description: "Notes from building Voicebox — the open-source AI voice studio.",
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/blog",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
export default function BlogIndexPage() {
|
||||
const posts = listPosts();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
<main className="mx-auto w-full max-w-3xl px-6 pt-32 pb-20">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Blog
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tighter text-foreground">
|
||||
Notes from building Voicebox.
|
||||
</h1>
|
||||
<p className="mt-5 max-w-2xl text-lg text-muted-foreground">
|
||||
The story behind the project, what's shipping next, and the occasional
|
||||
look under the hood.
|
||||
</p>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<p className="mt-16 border-t border-border pt-10 text-muted-foreground">
|
||||
Nothing published yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-16 border-t border-border">
|
||||
{posts.map((post) => (
|
||||
<li key={post.slug}>
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="group grid gap-4 border-b border-border py-10 md:grid-cols-[11rem_1fr] md:gap-10"
|
||||
>
|
||||
<div className="font-mono text-sm text-muted-foreground md:pt-1.5">
|
||||
<p>{formatDate(post.date)}</p>
|
||||
<p className="mt-1">{post.readingMinutes} min read</p>
|
||||
</div>
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground transition-colors group-hover:text-accent">
|
||||
{post.title}
|
||||
</h2>
|
||||
{post.excerpt ? (
|
||||
<p className="mt-3 leading-7 text-muted-foreground">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
) : null}
|
||||
{post.tags.length > 0 ? (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-border/60 bg-card/40 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {ArrowRight, Cloud, KeyRound, Lock, ShieldCheck} from "lucide-react";
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {CLOUD_FEATURES, CLOUD_NOTIFY_URL} from "@/lib/pricing";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Cloud Backup & Sync — Voicebox",
|
||||
description:
|
||||
"End-to-end encrypted backup and sync for your Voicebox library. We can't read your data — only your devices can. Optional, local-first, free for $VOICEBOX holders.",
|
||||
openGraph: {
|
||||
title: "Voicebox Cloud — encrypted backup & sync",
|
||||
description:
|
||||
"End-to-end encrypted backup and sync across desktop and mobile. The server is blind — only your devices can decrypt.",
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/cloud",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
icon: Lock,
|
||||
title: "Encrypted on your device",
|
||||
body: "Profiles, generations, and captures are encrypted locally with keys only you hold — before anything is uploaded.",
|
||||
},
|
||||
{
|
||||
icon: Cloud,
|
||||
title: "Stored as opaque blobs",
|
||||
body: "The server keeps your encrypted objects and a sync feed. It can route and store them, but never decrypt them.",
|
||||
},
|
||||
{
|
||||
icon: KeyRound,
|
||||
title: "Only your devices decrypt",
|
||||
body: "Each device unwraps your master key on pairing. A recovery phrase you control lets you restore everything to a new one.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CloudPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* ── Hero ─────────────────────────────────────────────────── */}
|
||||
<section className="relative pt-32 pb-16">
|
||||
<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>
|
||||
|
||||
<div className="relative mx-auto max-w-4xl px-6 text-center">
|
||||
<div className="fade-in mb-6 inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 px-3 py-1">
|
||||
<Cloud className="h-3.5 w-3.5 text-accent" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Voicebox Cloud · coming soon
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="fade-in text-5xl font-bold tracking-tighter leading-[0.95] text-foreground md:text-6xl lg:text-7xl">
|
||||
Your studio, backed up and in sync.
|
||||
</h1>
|
||||
|
||||
<p className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
|
||||
Optional, end-to-end encrypted backup and sync for your entire
|
||||
Voicebox library. We can't read a byte of it — only your devices
|
||||
can. Free for{" "}
|
||||
<Link href="/token" className="text-foreground underline-offset-4 hover:underline">
|
||||
$VOICEBOX
|
||||
</Link>{" "}
|
||||
holders.
|
||||
</p>
|
||||
|
||||
<div className="fade-in mt-10 flex flex-row items-center justify-center gap-3 sm:gap-4">
|
||||
<Link
|
||||
href="/pricing"
|
||||
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"
|
||||
>
|
||||
See pricing
|
||||
</Link>
|
||||
<a
|
||||
href={CLOUD_NOTIFY_URL}
|
||||
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"
|
||||
>
|
||||
Get notified
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Features ─────────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{CLOUD_FEATURES.map((f) => (
|
||||
<div
|
||||
key={f.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{f.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{f.body}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── How it works ─────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
How it works
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Zero-knowledge by design.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{STEPS.map((step, i) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<div
|
||||
key={step.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Icon className="h-5 w-5 text-accent" />
|
||||
<span className="font-mono text-xs text-muted-foreground/60">
|
||||
0{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{step.body}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Trust callout ────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="rounded-2xl border-2 border-accent/40 bg-card/60 backdrop-blur-sm p-8 md:p-10 text-center shadow-[0_8px_40px_hsl(43_60%_50%/0.08)]">
|
||||
<ShieldCheck className="h-7 w-7 text-accent mx-auto mb-4" />
|
||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3">
|
||||
We can't see your data. That's the point.
|
||||
</h2>
|
||||
<p className="text-muted-foreground leading-relaxed max-w-2xl mx-auto">
|
||||
Voicebox is local-first and privacy-first. The cloud keeps that
|
||||
promise: your library is encrypted before it leaves your device,
|
||||
the server stores only ciphertext, and the keys never leave your
|
||||
control. Same philosophy as the app — just backed up.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="rounded-full bg-accent px-6 py-3 text-sm font-semibold text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3)] transition-all hover:bg-accent-faint"
|
||||
>
|
||||
See pricing
|
||||
</Link>
|
||||
<Link
|
||||
href="/token"
|
||||
className="rounded-full border border-border/60 bg-card/40 px-6 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-border"
|
||||
>
|
||||
Free for holders →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -142,6 +142,99 @@
|
||||
will-change: transform;
|
||||
} */
|
||||
|
||||
/* Blog post typography (rendered markdown via marked) */
|
||||
.blog-prose {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.blog-prose > * + * {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.blog-prose h2 {
|
||||
margin-top: 2.25em;
|
||||
margin-bottom: 0.75em;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.blog-prose h3 {
|
||||
margin-top: 1.75em;
|
||||
margin-bottom: 0.5em;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.blog-prose p,
|
||||
.blog-prose ul,
|
||||
.blog-prose ol,
|
||||
.blog-prose blockquote {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.blog-prose strong {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 600;
|
||||
}
|
||||
.blog-prose a {
|
||||
color: hsl(var(--foreground));
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: hsl(var(--accent) / 0.5);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.blog-prose a:hover {
|
||||
color: hsl(var(--accent));
|
||||
}
|
||||
.blog-prose ul,
|
||||
.blog-prose ol {
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
.blog-prose ul {
|
||||
list-style: disc;
|
||||
}
|
||||
.blog-prose ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.blog-prose li + li {
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
.blog-prose blockquote {
|
||||
border-left: 2px solid hsl(var(--accent) / 0.5);
|
||||
padding-left: 1.25em;
|
||||
font-style: italic;
|
||||
}
|
||||
.blog-prose code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.875em;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.blog-prose pre {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.1em 1.25em;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.blog-prose pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.blog-prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
margin: 2.5em 0;
|
||||
}
|
||||
.blog-prose img {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Scrollbar hiding */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
|
||||
@@ -11,8 +11,9 @@ import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {Personalities} from "@/components/Personalities";
|
||||
import {AppleIcon, LinuxIcon, WindowsIcon} from "@/components/PlatformIcons";
|
||||
import {SponsorPromo} from "@/components/SponsorPromo";
|
||||
import {SupportedModels} from "@/components/SupportedModels";
|
||||
import {Testimonials} from "@/components/Testimonials";
|
||||
import {TokenTeaser} from "@/components/TokenTeaser";
|
||||
import {TutorialsSection} from "@/components/TutorialsSection";
|
||||
import {VoiceCreator} from "@/components/VoiceCreator";
|
||||
import {GITHUB_REPO} from "@/lib/constants";
|
||||
@@ -131,9 +132,6 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Sponsor promo ────────────────────────────────────────── */}
|
||||
<SponsorPromo />
|
||||
|
||||
{/* ── Features ─────────────────────────────────────────────── */}
|
||||
<Features />
|
||||
|
||||
@@ -158,6 +156,9 @@ export default function Home() {
|
||||
{/* ── Supported models ─────────────────────────────────────── */}
|
||||
<SupportedModels />
|
||||
|
||||
{/* ── Testimonials ─────────────────────────────────────────── */}
|
||||
<Testimonials />
|
||||
|
||||
{/* ── Download Section ─────────────────────────────────────── */}
|
||||
<section id="download" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
@@ -241,6 +242,9 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── $VOICEBOX token (teaser → /token) ─────────────────────── */}
|
||||
<TokenTeaser />
|
||||
|
||||
{/* ── Footer ───────────────────────────────────────────────── */}
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import {Coins} from "lucide-react";
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {PricingTiers} from "@/components/PricingTiers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing — Voicebox",
|
||||
description:
|
||||
"Voicebox is free and open source forever. Optional, end-to-end encrypted cloud backup & sync — free for $VOICEBOX holders.",
|
||||
openGraph: {
|
||||
title: "Voicebox Pricing",
|
||||
description:
|
||||
"The app is free forever. Cloud backup & sync is an optional add-on — free for $VOICEBOX holders.",
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/pricing",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
const FAQ = [
|
||||
{
|
||||
q: "Is the app really free?",
|
||||
a: "Yes — Voicebox is free and open source, forever. Cloning, dictation, every TTS engine, MCP, personalities: all of it runs locally with no account. The paid plans only add optional cloud backup & sync.",
|
||||
},
|
||||
{
|
||||
q: "What's encrypted in the cloud?",
|
||||
a: "Everything. Your profiles, generations, and captures are end-to-end encrypted on your device before upload. The server stores only ciphertext and can never read your data.",
|
||||
},
|
||||
{
|
||||
q: "Do $VOICEBOX holders really get Cloud free?",
|
||||
a: "Yes. Holding the token unlocks the Cloud tier at no cost. The app itself is free regardless — the token is an optional way to support the project.",
|
||||
},
|
||||
{
|
||||
q: "What counts toward storage?",
|
||||
a: "Your encrypted objects — generated audio, the original audio kept with each capture, and profile data. Plans differ mainly on storage, device count, and version-history length.",
|
||||
},
|
||||
{
|
||||
q: "Can I cancel anytime?",
|
||||
a: "Yes. Cloud is a subscription you can cancel whenever you like; your local library always stays on your machine and keeps working.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* ── Hero ─────────────────────────────────────────────────── */}
|
||||
<section className="relative pt-32 pb-12">
|
||||
<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-[460px] rounded-full bg-accent/12 blur-[140px]" />
|
||||
</div>
|
||||
<div className="relative mx-auto max-w-4xl px-6 text-center">
|
||||
<div className="fade-in mb-4 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent">
|
||||
Pricing
|
||||
</div>
|
||||
<h1 className="fade-in text-5xl font-bold tracking-tighter text-foreground md:text-6xl">
|
||||
The app is free. Forever.
|
||||
</h1>
|
||||
<p className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">
|
||||
Everything that makes Voicebox great runs locally at no cost. Pay
|
||||
only if you want optional, encrypted cloud backup & sync — and
|
||||
holders get that free.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Tiers (with monthly/annual toggle) ───────────────────── */}
|
||||
<section className="pb-8">
|
||||
<PricingTiers />
|
||||
</section>
|
||||
|
||||
{/* ── Holder callout ───────────────────────────────────────── */}
|
||||
<section className="py-12">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<Link
|
||||
href="/token"
|
||||
className="group flex flex-col items-center gap-3 rounded-2xl border border-accent/30 bg-card/40 backdrop-blur-sm px-6 py-8 text-center transition-colors hover:border-accent/50"
|
||||
>
|
||||
<Coins className="h-6 w-6 text-accent" />
|
||||
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground">
|
||||
Hold $VOICEBOX, get Cloud free.
|
||||
</h2>
|
||||
<p className="max-w-xl text-sm text-muted-foreground">
|
||||
The token is an optional way to back the project — and holders get
|
||||
the Cloud tier at no cost. Learn how it works and verify everything
|
||||
on-chain.
|
||||
</p>
|
||||
<span className="mt-1 text-sm font-medium text-accent group-hover:underline underline-offset-4">
|
||||
View the token →
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FAQ ──────────────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Questions
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{FAQ.map((item) => (
|
||||
<div
|
||||
key={item.q}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{item.q}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{item.a}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground/70 mt-10 max-w-2xl mx-auto">
|
||||
Cloud pricing and limits are not final — they'll be confirmed at
|
||||
launch.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowRight, Check, Coffee, Mail } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Navbar } from '@/components/Navbar';
|
||||
import {
|
||||
DONATE_URL,
|
||||
SPONSOR_CHECKOUT_URL,
|
||||
SPONSOR_CONTACT_EMAIL,
|
||||
} from '@/lib/constants';
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export default function SponsorsPage() {
|
||||
const [downloads, setDownloads] = useState<number | null>(null);
|
||||
const [stars, setStars] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/releases')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.totalDownloads != null) setDownloads(data.totalDownloads);
|
||||
})
|
||||
.catch(() => {});
|
||||
fetch('/api/stars')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (typeof data?.count === 'number') setStars(data.count);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* ── Hero ─────────────────────────────────────────────────── */}
|
||||
<section className="relative pt-32 pb-16">
|
||||
<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-4xl px-6 text-center">
|
||||
<div
|
||||
className="fade-in mb-6 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent"
|
||||
style={{ animationDelay: '50ms' }}
|
||||
>
|
||||
VIP Sponsor
|
||||
</div>
|
||||
|
||||
<h1
|
||||
className="fade-in text-5xl font-bold tracking-tighter leading-[0.95] text-foreground md:text-6xl lg:text-7xl"
|
||||
style={{ animationDelay: '100ms' }}
|
||||
>
|
||||
Get your brand in front of half a million creators.
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"
|
||||
style={{ animationDelay: '200ms' }}
|
||||
>
|
||||
Voicebox is the open-source AI voice studio used by creators, podcasters, voice
|
||||
artists, writers, developers, accessibility users, and curious humans all over the
|
||||
world. Sponsor the project, get your logo in front of all of them.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="fade-in mt-10 flex flex-row items-center justify-center gap-3 sm:gap-4"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
>
|
||||
<a
|
||||
href="#sponsor"
|
||||
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)]"
|
||||
>
|
||||
Become a sponsor
|
||||
</a>
|
||||
<a
|
||||
href={`mailto:${SPONSOR_CONTACT_EMAIL}`}
|
||||
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"
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
Talk to us
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Traction ────────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Reach
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Real distribution, real attention.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Stat
|
||||
value={downloads != null ? formatCount(downloads) : '500k+'}
|
||||
label="Downloads"
|
||||
note="Since launch in February 2026"
|
||||
/>
|
||||
<Stat
|
||||
value={stars != null ? formatCount(stars) : '22k+'}
|
||||
label="GitHub stars"
|
||||
note="Trending #1 maintainer, #4 repo"
|
||||
/>
|
||||
<Stat
|
||||
value="170k+"
|
||||
label="Monthly site visitors"
|
||||
note="voicebox.sh, last 30 days · growing 5×"
|
||||
/>
|
||||
<Stat
|
||||
value="Millions"
|
||||
label="Social reach"
|
||||
note="Tutorials, reels, TikToks"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground mt-10 max-w-2xl mx-auto">
|
||||
Voicebox users are content creators, podcasters, voice artists, writers, developers,
|
||||
accessibility users, hobbyists, and AI enthusiasts. They picked a local-first tool
|
||||
over a cloud subscription — they care about owning their software and the brands
|
||||
behind it.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── What you get ────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Placement
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Where your logo shows up.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border-2 border-accent/40 bg-card/60 backdrop-blur-sm p-8 mb-4 shadow-[0_8px_40px_hsl(43_60%_50%/0.08)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-2">
|
||||
Headline placement
|
||||
</div>
|
||||
<h3 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground mb-2">
|
||||
voicebox.sh — directly below the hero.
|
||||
</h3>
|
||||
<p className="text-sm md:text-base text-muted-foreground leading-relaxed max-w-2xl mb-8">
|
||||
Your logo lives in the prime slot on the homepage — the first thing every visitor
|
||||
sees after the hero. Same row as every other VIP Sponsor, linked to your URL
|
||||
of choice. This is the placement that actually moves the needle.
|
||||
</p>
|
||||
|
||||
{/* Preview of what the placement looks like on the homepage */}
|
||||
<div className="rounded-xl border border-dashed border-border/80 bg-background/50 p-6 md:p-8">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/80">
|
||||
Sponsored by
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-5 md:gap-6">
|
||||
<div className="flex h-32 min-w-[260px] items-center justify-center rounded-2xl border border-border bg-card/40 backdrop-blur-sm px-10">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/sponsors/openai.svg"
|
||||
alt="Example sponsor logo"
|
||||
className="h-14 w-auto max-w-[220px] object-contain brightness-0 invert opacity-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground/70 italic">
|
||||
Example only — actual sponsor logos appear here once placements are live.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<Perk
|
||||
title="GitHub README"
|
||||
body="Logo in the repo's Sponsors section. The README is one of the most-viewed docs on GitHub for any trending project."
|
||||
/>
|
||||
<Perk
|
||||
title="/sponsors page"
|
||||
body="Dedicated logo card on this page with your tagline, what your company does, and a direct link out."
|
||||
/>
|
||||
<Perk
|
||||
title="Release notes"
|
||||
body="One-line acknowledgement in the next major release post — read by the long tail of users who follow Voicebox updates."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Pricing ─────────────────────────────────────────────── */}
|
||||
<section id="sponsor" className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="text-center mb-10">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Pricing
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
One tier. Month-to-month. Cancel anytime.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border-2 border-accent/40 bg-card/60 backdrop-blur-sm p-8 md:p-10 shadow-[0_8px_40px_hsl(43_60%_50%/0.1)]">
|
||||
<div className="flex items-baseline gap-2 mb-2">
|
||||
<span className="text-5xl font-bold tracking-tight text-foreground">$500</span>
|
||||
<span className="text-base text-muted-foreground">/ month</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-8">
|
||||
Billed monthly via Stripe. Logo goes live within 48 hours of payment.
|
||||
</p>
|
||||
|
||||
<ul className="space-y-3 mb-8">
|
||||
<PerkRow text="Logo on voicebox.sh — directly below the hero" />
|
||||
<PerkRow text="Logo in the GitHub README sponsors section" />
|
||||
<PerkRow text="Featured card on /sponsors with your tagline and link" />
|
||||
<PerkRow text="Acknowledgement in the next major release post" />
|
||||
<PerkRow text="Direct line to the team for collaboration" />
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href={SPONSOR_CHECKOUT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full rounded-full bg-accent px-6 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"
|
||||
>
|
||||
Sponsor Voicebox
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground/70 mt-4">
|
||||
Need an annual contract, invoicing, or higher placement?{' '}
|
||||
<a
|
||||
href={`mailto:${SPONSOR_CONTACT_EMAIL}`}
|
||||
className="text-foreground/80 underline-offset-4 hover:underline"
|
||||
>
|
||||
{SPONSOR_CONTACT_EMAIL}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Individual / policy ─────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-4xl px-6 grid md:grid-cols-2 gap-4">
|
||||
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Coffee className="h-5 w-5 text-[#FFDD00]" />
|
||||
<h3 className="text-[15px] font-semibold text-foreground">Not a company?</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground mb-4">
|
||||
Individual supporters keep Voicebox running too. Drop a tip on Buy Me a Coffee and
|
||||
your name shows up in the supporters list.
|
||||
</p>
|
||||
<a
|
||||
href={DONATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80 hover:text-foreground transition-colors"
|
||||
>
|
||||
Support on Buy Me a Coffee
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
</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-3">
|
||||
Who we accept
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Voicebox is local-first and privacy-first. We don't accept sponsorships from
|
||||
companies whose business model conflicts with that — voice-data brokers, ad-tech
|
||||
built on speech, or surveillance vendors. Everyone else is welcome.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ value, label, note }: { value: string; label: string; note: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-5 text-center">
|
||||
<div className="text-3xl md:text-4xl font-bold tracking-tight text-foreground">{value}</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-accent mt-2">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-2">{note}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Perk({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<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">{title}</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PerkRow({ text }: { text: string }) {
|
||||
return (
|
||||
<li className="flex items-start gap-3 text-sm text-foreground/90">
|
||||
<Check className="h-5 w-5 shrink-0 text-accent mt-px" />
|
||||
<span>{text}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
Cloud,
|
||||
Flame,
|
||||
Heart,
|
||||
Lock,
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type {Metadata} from "next";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {TokenSection} from "@/components/TokenSection";
|
||||
import {
|
||||
TOKEN_PROOFS,
|
||||
TOKEN_SOLSCAN_URL,
|
||||
TOKEN_TICKER,
|
||||
} from "@/lib/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `${TOKEN_TICKER} — The official Voicebox token`,
|
||||
description: `${TOKEN_TICKER} is the official community token for Voicebox on Solana. Entirely optional — Voicebox is, and always will be, free and open source.`,
|
||||
openGraph: {
|
||||
title: `${TOKEN_TICKER} on Solana`,
|
||||
description: `The official community token for Voicebox. Optional, just for fun — Voicebox stays free and open source.`,
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/token",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
const USE_OF_FUNDS = [
|
||||
{
|
||||
icon: Rocket,
|
||||
title: "Full-time development",
|
||||
body: "The token is the equivalent of a salary — it lets me work on Voicebox every day instead of squeezing it around other work.",
|
||||
},
|
||||
{
|
||||
icon: Cloud,
|
||||
title: "Mobile + cloud backup & sync",
|
||||
body: "Shipping the mobile app and encrypted cloud backup/sync so your generations and captures are safe and available anywhere.",
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
title: "More engines, more hardware",
|
||||
body: "Adding TTS engines and broadening GPU / OS support so Voicebox runs great on whatever you've got.",
|
||||
},
|
||||
];
|
||||
|
||||
const FAQ = [
|
||||
{
|
||||
q: "Do I need the token to use Voicebox?",
|
||||
a: "No. Voicebox is free and open source, and every feature works without ever touching the token. It exists purely for supporters who want to back the project and have some fun.",
|
||||
},
|
||||
{
|
||||
q: "Is this an investment?",
|
||||
a: "No. $VOICEBOX is a community token, not a security or a promise of returns. There is no roadmap of financial milestones, and nothing here is financial advice. Only spend what you're comfortable with.",
|
||||
},
|
||||
{
|
||||
q: "How do I buy it?",
|
||||
a: "Copy the contract address above, then buy on pump.fun with a Solana wallet. Always verify the address matches the one on this page — impersonators are common.",
|
||||
},
|
||||
{
|
||||
q: "Does buying it fund development?",
|
||||
a: "Yes — going full-time on Voicebox is funded by the token, alongside donations. The surest way to support the project either way is to use it, star the repo, and tell people about it.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function TokenPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* Top padding clears the fixed navbar; TokenSection carries the
|
||||
header, contract address, and buy CTA. */}
|
||||
<main className="pt-16">
|
||||
<TokenSection />
|
||||
|
||||
{/* ── Why a token ──────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Why a token
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
So I can build this full-time.
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto mt-4">
|
||||
Voicebox grew to over a million downloads with zero marketing —
|
||||
but donations alone never made full-time work sustainable.{" "}
|
||||
{TOKEN_TICKER} changed that overnight, and it's already
|
||||
accelerating everything below. The app stays{" "}
|
||||
<b className="text-foreground">free, open source, and local-first</b>{" "}
|
||||
— forever.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{USE_OF_FUNDS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<Icon className="h-5 w-5 text-accent mb-3" />
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{item.body}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Holder utility ───────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="rounded-2xl border-2 border-accent/40 bg-card/60 backdrop-blur-sm p-8 md:p-10 shadow-[0_8px_40px_hsl(43_60%_50%/0.08)]">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Cloud className="h-5 w-5 text-accent" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent">
|
||||
Holder perk · coming soon
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3">
|
||||
Cloud backup & sync — free for holders.
|
||||
</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Encrypted cloud backup and sync (and the mobile cloud) will be a
|
||||
paid service — roughly{" "}
|
||||
<b className="text-foreground">$12/year</b> for everyone else, and{" "}
|
||||
<b className="text-foreground">free for {TOKEN_TICKER} holders</b>.
|
||||
Generate on the go, keep your captures and generations safe, and
|
||||
pick up on any device.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── On-chain transparency ────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
On-chain transparency
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Don't trust — verify.
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto mt-4">
|
||||
Liquidity is locked and supply is reduced through ongoing
|
||||
buyback & burns. Every action is on-chain and linked here, so
|
||||
you never have to take my word for it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{TOKEN_PROOFS.map((proof, i) => {
|
||||
const Icon = proof.kind === "lock" ? Lock : Flame;
|
||||
return (
|
||||
<div
|
||||
key={`${proof.label}-${i}`}
|
||||
className="flex flex-col rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<Icon className="h-5 w-5 text-accent mb-3" />
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{proof.label}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground flex-1">
|
||||
{proof.detail}
|
||||
</p>
|
||||
{proof.txUrl ? (
|
||||
<a
|
||||
href={proof.txUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-foreground/80 hover:text-foreground transition-colors"
|
||||
>
|
||||
View on Solscan
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="mt-4 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground/60">
|
||||
Proof link pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<a
|
||||
href={TOKEN_SOLSCAN_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Inspect supply & holders on Solscan
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Official vs community ────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="rounded-2xl border border-border bg-card/40 backdrop-blur-sm p-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ShieldCheck className="h-5 w-5 text-accent" />
|
||||
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground">
|
||||
One official token. Accept no substitutes.
|
||||
</h2>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
<ProofRow text={`${TOKEN_TICKER} is the only official Voicebox token. The mint address on this page is the single source of truth — always verify it.`} />
|
||||
<ProofRow text="My other projects (including Spacedrive) will never have an official token. This is the only one I'll ever make." />
|
||||
<ProofRow text="I deployed it myself so liquidity can be locked and the trajectory controlled — and I no longer claim fees on any other community tokens." />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Good to know ─────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Good to know
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Optional, just for fun.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{FAQ.map((item) => (
|
||||
<div
|
||||
key={item.q}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{item.q}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{item.a}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground/70 mt-10 max-w-2xl mx-auto">
|
||||
{TOKEN_TICKER} is a community token with no affiliation to any
|
||||
exchange or financial product. Nothing on this page is financial
|
||||
advice. Verify the contract address before buying.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProofRow({text}: {text: string}) {
|
||||
return (
|
||||
<li className="flex items-start gap-3 text-sm text-foreground/90">
|
||||
<Check className="h-5 w-5 shrink-0 text-accent mt-px" />
|
||||
<span>{text}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
/** Compact, copyable contract address — short display, full value to clipboard. */
|
||||
export function CopyAddress({ address }: { address: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard unavailable (e.g. insecure context) — silently no-op.
|
||||
}
|
||||
};
|
||||
|
||||
const short = `${address.slice(0, 4)}…${address.slice(-4)}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title={address}
|
||||
aria-label={copied ? 'Contract address copied' : 'Copy contract address'}
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-2.5 py-1.5 font-mono text-xs text-muted-foreground transition-colors hover:border-border hover:text-foreground"
|
||||
>
|
||||
<span>{short}</span>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-accent" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 opacity-60 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import { Coffee } from 'lucide-react';
|
||||
import { ArrowUpRight, Coffee, Coins } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { DONATE_URL, GITHUB_REPO } from '@/lib/constants';
|
||||
import { CopyAddress } from '@/components/CopyAddress';
|
||||
import {
|
||||
DONATE_URL,
|
||||
GITHUB_REPO,
|
||||
TOKEN_CONTRACT_ADDRESS,
|
||||
TOKEN_TICKER,
|
||||
} from '@/lib/constants';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-border py-12">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 mb-10">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-8 mb-10">
|
||||
{/* Brand */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
@@ -64,6 +70,16 @@ export function Footer() {
|
||||
API
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/cloud" className="hover:text-foreground transition-colors">
|
||||
Cloud
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/pricing" className="hover:text-foreground transition-colors">
|
||||
Pricing
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/download" className="hover:text-foreground transition-colors">
|
||||
Download
|
||||
@@ -76,6 +92,11 @@ export function Footer() {
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">Resources</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<a href="/blog" className="hover:text-foreground transition-colors">
|
||||
Blog
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="https://docs.voicebox.sh"
|
||||
@@ -150,6 +171,26 @@ export function Footer() {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Token */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">Token</h4>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className="h-4 w-4 text-accent" />
|
||||
<span className="font-semibold text-foreground">{TOKEN_TICKER}</span>
|
||||
<span className="text-xs text-muted-foreground/60">Solana</span>
|
||||
</div>
|
||||
<CopyAddress address={TOKEN_CONTRACT_ADDRESS} />
|
||||
<Link
|
||||
href="/token"
|
||||
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||||
>
|
||||
Token details
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-6">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Coffee, Github } from 'lucide-react';
|
||||
import { Coffee, Coins, Github } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DONATE_URL, GITHUB_REPO } from '@/lib/constants';
|
||||
import { DONATE_URL, GITHUB_REPO, TOKEN_TICKER } from '@/lib/constants';
|
||||
|
||||
function formatStarCount(count: number): string {
|
||||
if (count >= 1000) {
|
||||
@@ -32,7 +32,7 @@ export function Navbar() {
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-3 sm:grid sm:grid-cols-3">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-3 sm:grid sm:grid-cols-[1fr_auto_1fr] sm:gap-x-6">
|
||||
{/* Logo + wordmark */}
|
||||
<a href="/" className="flex items-center gap-2.5 justify-self-start">
|
||||
<Image
|
||||
@@ -75,16 +75,16 @@ export function Navbar() {
|
||||
Models
|
||||
</a>
|
||||
<a
|
||||
href="/#api"
|
||||
href="/pricing"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
API
|
||||
Pricing
|
||||
</a>
|
||||
<a
|
||||
href="/download"
|
||||
href="/blog"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Download
|
||||
Blog
|
||||
</a>
|
||||
<a
|
||||
href="https://docs.voicebox.sh"
|
||||
@@ -96,8 +96,18 @@ export function Navbar() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Donate + GitHub star buttons */}
|
||||
{/* Token + Donate + GitHub star buttons */}
|
||||
<div className="flex items-center gap-2 justify-self-end">
|
||||
<a
|
||||
href="/token"
|
||||
className="hidden sm:flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground hover:border-accent/40"
|
||||
aria-label={`${TOKEN_TICKER} token`}
|
||||
>
|
||||
<Coins className="h-4 w-4 text-accent" />
|
||||
<span className="text-[13px] font-semibold tracking-wide text-foreground">
|
||||
{TOKEN_TICKER}
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href={DONATE_URL}
|
||||
target="_blank"
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import {Check} from "lucide-react";
|
||||
import {useState} from "react";
|
||||
import {
|
||||
annualSavingsPercent,
|
||||
type BillingPeriod,
|
||||
PRICING_TIERS,
|
||||
} from "@/lib/pricing";
|
||||
|
||||
const MAX_ANNUAL_SAVINGS = Math.max(
|
||||
...PRICING_TIERS.map((t) => annualSavingsPercent(t)),
|
||||
);
|
||||
|
||||
export function PricingTiers() {
|
||||
const [period, setPeriod] = useState<BillingPeriod>("annual");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
{/* Billing toggle */}
|
||||
<div className="mb-10 flex items-center justify-center">
|
||||
<div className="inline-flex items-center gap-1 rounded-full border border-border bg-card/40 p-1">
|
||||
<ToggleButton
|
||||
active={period === "monthly"}
|
||||
onClick={() => setPeriod("monthly")}
|
||||
>
|
||||
Monthly
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
active={period === "annual"}
|
||||
onClick={() => setPeriod("annual")}
|
||||
>
|
||||
Annual
|
||||
{MAX_ANNUAL_SAVINGS > 0 ? (
|
||||
<span
|
||||
className={`ml-1.5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold transition-colors ${
|
||||
period === "annual"
|
||||
? "bg-white/25 text-white"
|
||||
: "bg-accent/15 text-accent"
|
||||
}`}
|
||||
>
|
||||
Save {MAX_ANNUAL_SAVINGS}%
|
||||
</span>
|
||||
) : null}
|
||||
</ToggleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-4 md:grid-cols-3">
|
||||
{PRICING_TIERS.map((tier) => {
|
||||
const isFree = tier.monthly === 0 && tier.annual === 0;
|
||||
const amount = period === "monthly" ? tier.monthly : tier.annual;
|
||||
const unit = period === "monthly" ? "/month" : "/year";
|
||||
const isExternal = tier.cta.href.startsWith("http");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={`flex flex-col rounded-2xl border bg-card/50 backdrop-blur-sm p-7 ${
|
||||
tier.highlighted
|
||||
? "border-2 border-accent/50 shadow-[0_8px_40px_hsl(43_60%_50%/0.1)] md:-mt-2"
|
||||
: "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{tier.name}
|
||||
</h2>
|
||||
{tier.badge ? (
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
|
||||
tier.highlighted
|
||||
? "bg-accent/15 text-accent"
|
||||
: "border border-border/60 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{tier.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="mb-5 min-h-[2.5rem] text-sm text-muted-foreground">
|
||||
{tier.tagline}
|
||||
</p>
|
||||
|
||||
<div className="mb-1 flex items-baseline gap-1">
|
||||
<span className="text-4xl font-bold tracking-tight text-foreground">
|
||||
${amount}
|
||||
</span>
|
||||
{!isFree ? (
|
||||
<span className="text-sm text-muted-foreground">{unit}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mb-6 min-h-[1rem] text-xs text-muted-foreground/70">
|
||||
{isFree
|
||||
? tier.priceNote
|
||||
: period === "annual"
|
||||
? tier.priceNote
|
||||
: `Billed monthly · ${tier.priceNote ?? ""}`}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={tier.cta.href}
|
||||
{...(isExternal
|
||||
? {target: "_blank", rel: "noopener noreferrer"}
|
||||
: {})}
|
||||
className={`mb-7 inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-semibold transition-all ${
|
||||
tier.highlighted
|
||||
? "bg-accent text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3)] hover:bg-accent-faint"
|
||||
: "border border-border/60 bg-card/40 text-foreground hover:border-accent/40"
|
||||
}`}
|
||||
>
|
||||
{tier.cta.label}
|
||||
</a>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{tier.features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-start gap-3 text-sm text-foreground/90"
|
||||
>
|
||||
<Check className="h-4 w-4 shrink-0 text-accent mt-0.5" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex items-center rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-accent text-white"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { ArrowRight, Heart } from 'lucide-react';
|
||||
import { SPONSORS, type Sponsor } from '@/lib/sponsors';
|
||||
|
||||
export function SponsorPromo() {
|
||||
if (SPONSORS.length === 0) {
|
||||
return <SponsorPromoEmpty />;
|
||||
}
|
||||
return <SponsorStrip sponsors={SPONSORS} />;
|
||||
}
|
||||
|
||||
function SponsorPromoEmpty() {
|
||||
return (
|
||||
<section className="border-t border-border py-16">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="rounded-2xl border-2 border-accent/40 bg-gradient-to-br from-card/80 to-card/40 backdrop-blur-sm p-8 md:p-10 shadow-[0_8px_40px_hsl(43_60%_50%/0.08)]">
|
||||
<div className="grid md:grid-cols-[1fr_auto] items-center gap-8">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 mb-4 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent">
|
||||
<Heart className="h-3.5 w-3.5" />
|
||||
Sponsor Voicebox
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3">
|
||||
Get your logo in front of 170k+ monthly visitors.
|
||||
</h3>
|
||||
<p className="text-sm md:text-base text-muted-foreground leading-relaxed max-w-2xl">
|
||||
Voicebox is open-source and used by creators, voice artists, podcasters,
|
||||
writers, developers, accessibility users, and curious humans all over the world.
|
||||
Sponsor the project and your logo lands on the homepage, in the app, in the
|
||||
README, and on the sponsors page — in front of every one of them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start md:items-end gap-2">
|
||||
<a
|
||||
href="/sponsors"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-3 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 whitespace-nowrap"
|
||||
>
|
||||
Become a sponsor
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
<span className="text-xs text-muted-foreground/70">From $500 / month</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SponsorStrip({ sponsors }: { sponsors: Sponsor[] }) {
|
||||
return (
|
||||
<section className="border-t border-border py-14">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/80">
|
||||
Sponsored by
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-5 md:gap-6">
|
||||
{sponsors.map((sponsor) => (
|
||||
<a
|
||||
key={sponsor.name}
|
||||
href={sponsor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={sponsor.name}
|
||||
className="group flex h-32 min-w-[260px] items-center justify-center rounded-2xl border border-border bg-card/40 backdrop-blur-sm px-10 transition-all hover:border-accent/40 hover:bg-card"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={sponsor.logoSrc}
|
||||
alt={sponsor.logoAlt ?? sponsor.name}
|
||||
className={`h-14 w-auto max-w-[220px] object-contain opacity-80 transition-opacity group-hover:opacity-100 ${
|
||||
sponsor.invert ? 'brightness-0 invert' : ''
|
||||
}`}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<a
|
||||
href="/sponsors"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Become a sponsor
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import {Heart, Quote} from "lucide-react";
|
||||
import {DONATE_URL} from "@/lib/constants";
|
||||
|
||||
type Testimonial = {
|
||||
quote: string;
|
||||
author: string;
|
||||
};
|
||||
|
||||
/** Verbatim supporter messages from Buy Me a Coffee. */
|
||||
const TESTIMONIALS: Testimonial[] = [
|
||||
{
|
||||
quote:
|
||||
"Cloning my own voice was a snap — and now I can hear my reminders and to-dos from my digital doppelgänger. Very cool.",
|
||||
author: "jimzip",
|
||||
},
|
||||
{
|
||||
quote: "It's better than most other paid services.",
|
||||
author: "Peiming Pai",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"I'm using this great tool for my multimedia course project — you made it into the classrooms!",
|
||||
author: "theanoma.ly",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"This app is fantastic! I use it to learn languages and for my learning materials. Congratulations, it's great!",
|
||||
author: "Kevin Serrano",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"Absolutely amazing! The learning curve was very short. Thank you for a great program and for making it free.",
|
||||
author: "DJWhy",
|
||||
},
|
||||
{
|
||||
quote: "First engine I tried, zero config. It worked! Amazing.",
|
||||
author: "Fitz",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"This is great for people who are uncomfortable with advocating for themselves in public. Thanks for making it.",
|
||||
author: "creativeaction.ca",
|
||||
},
|
||||
{
|
||||
quote: "Thanks for this. It's a life-saver!",
|
||||
author: "The Cowboy Movie Channel",
|
||||
},
|
||||
{
|
||||
quote: "Fantastic open-source app!",
|
||||
author: "Mitja",
|
||||
},
|
||||
];
|
||||
|
||||
export function Testimonials() {
|
||||
return (
|
||||
<section id="testimonials" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-14">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-3 py-1 mb-4">
|
||||
<Heart className="h-3 w-3 text-accent" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Loved by users
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
What people are saying
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Voicebox has passed 1M+ downloads. Here's a handful of notes from
|
||||
the people using it every day.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Masonry-style columns so cards flow naturally regardless of length */}
|
||||
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3 [&>*]:mb-4">
|
||||
{TESTIMONIALS.map((t) => (
|
||||
<figure
|
||||
key={t.author}
|
||||
className="break-inside-avoid rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 transition-colors hover:border-accent/30"
|
||||
>
|
||||
<Quote className="h-4 w-4 text-accent/60 mb-3" />
|
||||
<blockquote className="text-sm leading-relaxed text-foreground/90">
|
||||
{t.quote}
|
||||
</blockquote>
|
||||
<figcaption className="mt-4 text-xs font-medium text-muted-foreground">
|
||||
{t.author}
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Attribution + soft CTA */}
|
||||
<div className="mt-10 text-center">
|
||||
<a
|
||||
href={DONATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground/70 hover:text-foreground transition-colors"
|
||||
>
|
||||
From supporters on Buy Me a Coffee →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import {ArrowUpRight, Check, Coins, Copy} from "lucide-react";
|
||||
import {useState} from "react";
|
||||
import {
|
||||
TOKEN_CONTRACT_ADDRESS,
|
||||
TOKEN_PUMP_URL,
|
||||
TOKEN_TICKER,
|
||||
} from "@/lib/constants";
|
||||
|
||||
export function TokenSection() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(TOKEN_CONTRACT_ADDRESS);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard unavailable (e.g. insecure context) — silently no-op.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="token" className="border-t border-border py-24">
|
||||
<div className="relative mx-auto max-w-4xl px-6">
|
||||
{/* Subtle accent glow */}
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 flex justify-center">
|
||||
<div className="h-[260px] w-[520px] rounded-full bg-accent/10 blur-[140px]" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-3 py-1 mb-4">
|
||||
<Coins className="h-3 w-3 text-accent" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Official token
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
{TOKEN_TICKER} on Solana
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
The official {TOKEN_TICKER} token for supporters who want to back
|
||||
the project and have some fun. Voicebox is and always will be{" "}
|
||||
<b className="text-foreground">free and open source</b> — the token
|
||||
is entirely optional and not required to use anything here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contract address + CTA */}
|
||||
<div className="mx-auto max-w-2xl rounded-2xl border border-border bg-card/60 backdrop-blur-sm p-5 sm:p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Contract address
|
||||
</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-background/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
Solana
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
{/* Address bar */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title="Copy contract address"
|
||||
aria-label={
|
||||
copied ? "Contract address copied" : "Copy contract address"
|
||||
}
|
||||
className="group flex min-w-0 flex-1 items-center gap-3 rounded-xl border border-border bg-background/60 px-4 py-3 text-left transition-colors hover:border-accent/40"
|
||||
>
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/90 sm:text-sm">
|
||||
{TOKEN_CONTRACT_ADDRESS}
|
||||
</code>
|
||||
{copied ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent">
|
||||
<Check className="h-4 w-4" />
|
||||
Copied
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors group-hover:text-foreground">
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Buy CTA */}
|
||||
<a
|
||||
href={TOKEN_PUMP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-accent px-5 py-3 text-sm font-semibold 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"
|
||||
>
|
||||
Buy on pump.fun
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {ArrowUpRight, Coins} from "lucide-react";
|
||||
import {TOKEN_TICKER} from "@/lib/constants";
|
||||
|
||||
/**
|
||||
* Compact teaser shown near the bottom of the landing page. The full token
|
||||
* details (contract address, buy CTA, disclaimers) live on the dedicated
|
||||
* /token page — this just points there.
|
||||
*/
|
||||
export function TokenTeaser() {
|
||||
return (
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<div className="relative flex flex-col items-center gap-5 overflow-hidden rounded-2xl border border-border bg-card/40 backdrop-blur-sm px-6 py-10 text-center">
|
||||
{/* Subtle accent glow */}
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 flex justify-center">
|
||||
<div className="h-[200px] w-[420px] rounded-full bg-accent/10 blur-[130px]" />
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 px-3 py-1">
|
||||
<Coins className="h-3 w-3 text-accent" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Official token
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-foreground md:text-3xl">
|
||||
{TOKEN_TICKER} on Solana
|
||||
</h2>
|
||||
|
||||
<p className="max-w-xl text-muted-foreground">
|
||||
An optional way to back the project and have some fun. Voicebox is and
|
||||
always will be{" "}
|
||||
<b className="text-foreground">free and open source</b> — the token is
|
||||
not required to use anything here.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/token"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-3 text-sm font-semibold 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"
|
||||
>
|
||||
Learn about {TOKEN_TICKER}
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {readdirSync, readFileSync} from "node:fs";
|
||||
import {join} from "node:path";
|
||||
import matter from "gray-matter";
|
||||
import {marked} from "marked";
|
||||
|
||||
// Posts are authored as markdown under src/posts. This module uses the Node fs
|
||||
// API and must only be imported from server components / server code — never
|
||||
// from a "use client" file, or the markdown libraries leak into the bundle.
|
||||
|
||||
export type BlogPost = {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
tags: string[];
|
||||
excerpt: string;
|
||||
readingMinutes: number;
|
||||
html: string;
|
||||
};
|
||||
|
||||
export type BlogPostSummary = Omit<BlogPost, "html">;
|
||||
|
||||
const POSTS_DIR = join(process.cwd(), "src/posts");
|
||||
|
||||
function slugFromFile(file: string): string {
|
||||
return file.replace(/\.md$/, "");
|
||||
}
|
||||
|
||||
function parsePost(file: string, raw: string): BlogPost {
|
||||
const {data, content} = matter(raw);
|
||||
const words = content.trim().split(/\s+/).filter(Boolean).length;
|
||||
return {
|
||||
slug: slugFromFile(file),
|
||||
title: String(data.title ?? "Untitled"),
|
||||
author: String(data.author ?? "Jamie Pine"),
|
||||
date:
|
||||
data.date instanceof Date
|
||||
? data.date.toISOString().slice(0, 10)
|
||||
: String(data.date ?? ""),
|
||||
tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
|
||||
excerpt: String(data.excerpt ?? ""),
|
||||
readingMinutes: Math.max(1, Math.ceil(words / 220)),
|
||||
html: marked.parse(content, {async: false}) as string,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadAllPosts(): BlogPost[] {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = readdirSync(POSTS_DIR).filter((f) => f.endsWith(".md"));
|
||||
} catch {
|
||||
return []; // posts dir doesn't exist yet — treat as empty
|
||||
}
|
||||
return files
|
||||
.map((file) => parsePost(file, readFileSync(join(POSTS_DIR, file), "utf8")))
|
||||
.sort((a, b) => (a.date < b.date ? 1 : -1));
|
||||
}
|
||||
|
||||
export function listPosts(): BlogPostSummary[] {
|
||||
return loadAllPosts().map(({html: _html, ...summary}) => summary);
|
||||
}
|
||||
|
||||
export function getPost(slug: string): BlogPost | null {
|
||||
return loadAllPosts().find((post) => post.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
export function formatDate(date: string): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime())) return date;
|
||||
return d.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,51 @@ export const DONATE_URL = 'https://buymeacoffee.com/jamiepine';
|
||||
export const SPONSOR_CHECKOUT_URL = 'https://buy.stripe.com/eVqdRad3n16ubcqf201Jm00';
|
||||
export const SPONSOR_CONTACT_EMAIL = '[email protected]';
|
||||
|
||||
// $VOICEBOX — the official community token on Solana
|
||||
export const TOKEN_TICKER = '$VOICEBOX';
|
||||
export const TOKEN_CONTRACT_ADDRESS = 'FpzZHtp5tbvz6xndEtoJHoGEWcT7cFEuscdCh9RApump';
|
||||
export const TOKEN_PUMP_URL = `https://pump.fun/coin/${TOKEN_CONTRACT_ADDRESS}`;
|
||||
// Solscan token page — lets anyone inspect supply, holders, and history.
|
||||
export const TOKEN_SOLSCAN_URL = `https://solscan.io/token/${TOKEN_CONTRACT_ADDRESS}`;
|
||||
export const TOKEN_TOTAL_SUPPLY = '1B';
|
||||
|
||||
// On-chain transparency log — locks and buyback+burns.
|
||||
// Add a new entry every time a lock or burn happens; set `txUrl` to its Solscan
|
||||
// link to make the card a live, verifiable proof. Entries without a txUrl render
|
||||
// as "proof link pending" — fill them in as soon as the hash is available.
|
||||
export interface TokenProof {
|
||||
kind: 'lock' | 'burn';
|
||||
label: string;
|
||||
detail: string;
|
||||
/** Solscan (or locker) URL proving the action. Empty = pending, shown as such. */
|
||||
txUrl: string;
|
||||
}
|
||||
|
||||
export const TOKEN_PROOFS: TokenProof[] = [
|
||||
{
|
||||
kind: 'lock',
|
||||
label: 'Launch liquidity lock',
|
||||
detail:
|
||||
'Liquidity and a portion of dev holdings were locked at launch (~6.6% top holder), so the supply can be verified on-chain from day one.',
|
||||
txUrl: '', // TODO(jamie): add the Solscan/locker link for the launch lock
|
||||
},
|
||||
{
|
||||
kind: 'burn',
|
||||
label: 'Buyback & burn',
|
||||
detail:
|
||||
'Bought $VOICEBOX back from the open market and burned it to a dead address, permanently removing it from supply.',
|
||||
txUrl:
|
||||
'https://solscan.io/tx/5MjK4CYMBKAewLcjdD6QkM8ctkeG2bjyQhpjNgEumkDbDtoKCVmzKcWwLWsd4QJov8hs5zbGLt3g5vVCp4CBmze5',
|
||||
},
|
||||
{
|
||||
kind: 'burn',
|
||||
label: 'Buyback & burn',
|
||||
detail:
|
||||
'A second buyback and burn — part of an ongoing commitment to keep buying back and reducing supply over time.',
|
||||
txUrl: '', // TODO(jamie): add the Solscan link for the second burn
|
||||
},
|
||||
];
|
||||
|
||||
export const DOWNLOAD_LINKS = {
|
||||
macArm: GITHUB_RELEASES_PAGE,
|
||||
macIntel: GITHUB_RELEASES_PAGE,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Pricing + cloud data — single source of truth for /pricing and /cloud.
|
||||
//
|
||||
// DRAFT: the cloud service is not live yet and the pricing model is not final.
|
||||
// $12/yr is a launch/intro figure; tier limits below are placeholders to shape
|
||||
// the page — tune them once the model is decided. Keeping it all here means the
|
||||
// pages update by editing this file only.
|
||||
|
||||
export const CLOUD_STATUS = "coming-soon" as const; // → flips to "live" at launch
|
||||
export const CLOUD_PRICE_YEARLY = 12; // launch price for the Cloud tier (USD/yr)
|
||||
|
||||
/** Where "get notified" CTAs point until there's a real signup flow. */
|
||||
export const CLOUD_NOTIFY_URL = "https://x.com/VoiceboxAI";
|
||||
|
||||
export type BillingPeriod = "monthly" | "annual";
|
||||
|
||||
export interface PricingTier {
|
||||
id: string;
|
||||
name: string;
|
||||
tagline: string;
|
||||
/** USD per month. 0 = free. */
|
||||
monthly: number;
|
||||
/** USD per year. 0 = free. Set below 12×monthly to reward annual. */
|
||||
annual: number;
|
||||
priceNote?: string;
|
||||
/** Visually emphasize this tier (the headline plan). */
|
||||
highlighted?: boolean;
|
||||
/** Status pill, e.g. "Available now" / "Coming soon". */
|
||||
badge?: string;
|
||||
cta: {label: string; href: string};
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export const PRICING_TIERS: PricingTier[] = [
|
||||
{
|
||||
id: "local",
|
||||
name: "Local",
|
||||
tagline: "The full app, free forever.",
|
||||
monthly: 0,
|
||||
annual: 0,
|
||||
priceNote: "No account required",
|
||||
badge: "Available now",
|
||||
cta: {label: "Download Voicebox", href: "/download"},
|
||||
features: [
|
||||
"Voice cloning across every TTS engine",
|
||||
"Dictation & Capture (audio kept alongside transcript)",
|
||||
"MCP / agent integration & personalities",
|
||||
"Unlimited local generations & captures",
|
||||
"100% open source, runs entirely on your machine",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cloud",
|
||||
name: "Cloud",
|
||||
tagline: "Backup & sync for everything you make.",
|
||||
monthly: 2, // placeholder
|
||||
annual: CLOUD_PRICE_YEARLY, // $12 launch price (≈50% off monthly)
|
||||
priceNote: "Launch price · free for $VOICEBOX holders",
|
||||
highlighted: true,
|
||||
badge: "Coming soon",
|
||||
cta: {label: "Get notified", href: CLOUD_NOTIFY_URL},
|
||||
features: [
|
||||
"Everything in Local",
|
||||
"End-to-end encrypted backup — we can't read it",
|
||||
"Sync across desktop & mobile",
|
||||
"25 GB encrypted storage", // placeholder
|
||||
"Up to 5 devices", // placeholder
|
||||
"30-day version history", // placeholder
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "studio",
|
||||
name: "Studio",
|
||||
tagline: "For power users and professionals.",
|
||||
monthly: 6, // placeholder
|
||||
annual: 48, // placeholder
|
||||
priceNote: "Placeholder — pricing TBD",
|
||||
badge: "Coming soon",
|
||||
cta: {label: "Get notified", href: CLOUD_NOTIFY_URL},
|
||||
features: [
|
||||
"Everything in Cloud",
|
||||
"250 GB encrypted storage", // placeholder
|
||||
"Unlimited devices", // placeholder
|
||||
"1-year version history", // placeholder
|
||||
"Priority support",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Annual savings vs paying 12× the monthly price, as a whole percent (0 if none). */
|
||||
export function annualSavingsPercent(tier: PricingTier): number {
|
||||
if (tier.monthly <= 0 || tier.annual <= 0) return 0;
|
||||
const full = tier.monthly * 12;
|
||||
return Math.max(0, Math.round((1 - tier.annual / full) * 100));
|
||||
}
|
||||
|
||||
export interface CloudFeature {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export const CLOUD_FEATURES: CloudFeature[] = [
|
||||
{
|
||||
title: "End-to-end encrypted",
|
||||
body: "Everything is encrypted on your device before it leaves it. The server only ever stores opaque blobs — it literally cannot read your voices, generations, or captures.",
|
||||
},
|
||||
{
|
||||
title: "Back up everything",
|
||||
body: "Voice profiles, generations, and captures — including the original audio Voicebox keeps alongside each transcript — safe off your machine.",
|
||||
},
|
||||
{
|
||||
title: "Sync across devices",
|
||||
body: "Pick up on desktop or mobile. Your library follows you, encrypted in transit and at rest, with a per-device key.",
|
||||
},
|
||||
{
|
||||
title: "Generate on the go",
|
||||
body: "The mobile app pairs with your library so you can dictate and generate anywhere, then find it all waiting back at your desk.",
|
||||
},
|
||||
{
|
||||
title: "You hold the keys",
|
||||
body: "A recovery phrase you control is the root of your encryption. Lose your devices and you can still restore — but no one else, including us, ever can.",
|
||||
},
|
||||
{
|
||||
title: "Optional & local-first",
|
||||
body: "Voicebox works fully offline without an account. Cloud is an add-on for when you want backup and sync — never a requirement.",
|
||||
},
|
||||
];
|
||||
@@ -1,12 +0,0 @@
|
||||
export type Sponsor = {
|
||||
name: string;
|
||||
url: string;
|
||||
logoSrc: string;
|
||||
logoAlt?: string;
|
||||
tagline?: string;
|
||||
/** Set true for solid-black logos that need to render white on the dark theme. */
|
||||
invert?: boolean;
|
||||
};
|
||||
|
||||
export const SPONSORS: Sponsor[] = [
|
||||
];
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: "Why Voicebox has a token"
|
||||
author: Jamie Pine
|
||||
date: 2026-06-27
|
||||
tags: [Token, Transparency]
|
||||
excerpt: "Voicebox grew to over a million downloads with zero marketing — but donations never made full-time work sustainable. Here's the honest reasoning behind $VOICEBOX, and the commitments that come with it."
|
||||
---
|
||||
|
||||
Voicebox started as a one-day experiment. The Qwen3-TTS model dropped, I wanted to try it, so I built a small CLI to load voice profiles and generate speech. As a designer I already had the interface in my head — profiles as cards, a floating generation box, a player spanning the bottom of the app, a gold accent and a microphone logo to make it feel like a real studio. First working version in a day. Open sourced in three.
|
||||
|
||||
I did no marketing. None, to this day. But Reddit found it, creators started making tutorials, and the "ElevenLabs just lost its moat" posts began. It crossed a million downloads and is closing in on Spacedrive's GitHub star count — entirely organically. Along the way it became two things at once: a free alternative to ElevenLabs for voice cloning, and a free alternative to Wispr Flow for dictation.
|
||||
|
||||
So a fair question keeps coming up: if it's this successful, why a token? Why not just put it behind a subscription, or turn on GitHub Sponsors and call it a day?
|
||||
|
||||
## The honest version
|
||||
|
||||
I could have built this as a cloud app with a monthly subscription and probably done well. I chose open source instead, and I'd make the same call again. I don't think it would have grown like this behind a paywall — people trust it more when they can read the code and run it entirely on their own machine, it earns a ton of organic exposure for free, and I get the community's help making it work across the endless combinations of GPUs and operating systems I could never test alone.
|
||||
|
||||
But open source doesn't pay rent. The donation button on the site brings in roughly $200–300 a month. I didn't expect GitHub Sponsors to move that number much. The old sponsor program made almost nothing. I love this project and I want to work on it full-time — and for a while I couldn't justify it.
|
||||
|
||||
The token changed that overnight. It's the closest thing I've had to a salary in a long time, and it let me go full-time on Voicebox immediately. That's not a hypothetical — it's already happening, and you'll see it in the commits, the releases, and the posts on [@VoiceboxAI](https://x.com/VoiceboxAI).
|
||||
|
||||
## What the token is — and what it isn't
|
||||
|
||||
**$VOICEBOX is entirely optional.** Voicebox is, and always will be, free, open source, and local-first. Every feature works without ever touching the token. It exists for supporters who want to back the project and have some fun — nothing here is gated behind it, and nothing here is financial advice.
|
||||
|
||||
It's also the **only** token I will ever make. My other projects, including Spacedrive, will never have an official token. I thought carefully about this: the community was clearly most interested in Voicebox because of its existing traction, and if I'm going to have a token at all, I'd rather deploy it myself so I can lock liquidity and be accountable for its trajectory — rather than have an anonymous community coin I can't control. As of now I no longer claim fees on any other community tokens, either.
|
||||
|
||||
## Don't trust — verify
|
||||
|
||||
A token only earns trust through actions you can check on-chain. So:
|
||||
|
||||
- Liquidity and a portion of dev holdings were **locked at launch**, visible from day one.
|
||||
- I've done **buyback and burns** — buying $VOICEBOX back from the market and burning it to a dead address, permanently. I've done this more than once, and I'll keep doing it.
|
||||
- The plan is a balanced mix: lock more for trust, burn periodically, but keep enough flexibility to fund real expenses and add liquidity when it helps.
|
||||
|
||||
Every one of these is linked on the [token page](/token), so you never have to take my word for it. Verify the contract address there before you do anything — impersonators are common.
|
||||
|
||||
## What the money actually builds
|
||||
|
||||
Going full-time means the roadmap moves faster. The near-term priorities:
|
||||
|
||||
- **The mobile app.** I use the prototype every day for dictation on the go. It does both cloning and dictation, and it's nearly ready to ship.
|
||||
- **Encrypted cloud backup & sync.** Generate on the go, keep your captures and generations safe, pick up on any device. This will be a paid service — around $12/year — and **free for token holders**.
|
||||
- **More engines and broader hardware support.** More TTS engines, better GPU and OS coverage, so Voicebox runs great on whatever you've got.
|
||||
|
||||
That's the whole pitch. The app stays free forever, the token is an optional way to support it and unlock the cloud service, and the proof of good faith is on-chain and in the changelog. If you want to back the project, the [token page](/token) is here — but starring the repo and telling a friend helps just as much.
|
||||
|
||||
Thanks for being here. Now back to shipping.
|
||||
Reference in New Issue
Block a user