mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-27 06:05:14 -07:00
Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bcb98d1a8 | ||
|
|
95b0c4123c | ||
|
|
2c499fe2a6 | ||
|
|
0dbdec4531 | ||
|
|
a6a5717eb2 | ||
|
|
4427af918e | ||
|
|
2c3df3873d | ||
|
|
3f2c22b793 | ||
|
|
2a1bb6f936 | ||
|
|
29f99a8622 | ||
|
|
166250856f | ||
|
|
2e5b8d2d67 | ||
|
|
c7f50d5668 | ||
|
|
9b3fa177e2 | ||
|
|
e7846eaf77 | ||
|
|
9f183e5832 | ||
|
|
935efedae0 | ||
|
|
d526a9e337 | ||
|
|
4e7f8f9bda | ||
|
|
3d4d0a9335 | ||
|
|
c43f2d45cc | ||
|
|
2a937983c5 | ||
|
|
fe14df5fda | ||
|
|
6cf8da2698 | ||
|
|
70ec8d995b | ||
|
|
c113faf131 | ||
|
|
51c46cd89e | ||
|
|
0aa3a8d6b4 | ||
|
|
c5b7760a8c | ||
|
|
9525bff28a | ||
|
|
800e390108 | ||
|
|
5f62a0ed1b | ||
|
|
7ad91f5767 | ||
|
|
b97c565a45 | ||
|
|
736a661059 | ||
|
|
24833242b5 | ||
|
|
271ecd924b | ||
|
|
c7cd7fd0fd | ||
|
|
c6114b69bc | ||
|
|
1ca7895ffb | ||
|
|
ef63faf33a | ||
|
|
66ca56bb0b | ||
|
|
f21778fcf3 | ||
|
|
687ab2aa16 | ||
|
|
67a9e308a9 | ||
|
|
c9103a24da | ||
|
|
30c2cf1a2c | ||
|
|
c0eba9c628 | ||
|
|
0081e97ad7 | ||
|
|
239523d797 | ||
|
|
27798dd46f | ||
|
|
ea7a4e9f6d | ||
|
|
c65531bed1 | ||
|
|
53a7693101 | ||
|
|
0b2a3cdc78 | ||
|
|
3f1ab75d49 | ||
|
|
abf5dfda8c | ||
|
|
7c50e189cb | ||
|
|
be73a33ee1 | ||
|
|
ef00570145 | ||
|
|
85a3e1363f | ||
|
|
868a40fb7e | ||
|
|
6b75e097e1 | ||
|
|
0cef2c9fe1 | ||
|
|
87c582ad54 |
@@ -28,10 +28,6 @@
|
||||
</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> •
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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,6 +3,7 @@ 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 }) {
|
||||
@@ -116,6 +117,36 @@ 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
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Cloud, Copy, Loader2, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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.
|
||||
//
|
||||
// Once linked, the encrypted-backup rows drive the sync identity flows: this
|
||||
// device registers as an encryption device, and either mints the account's
|
||||
// master key (first device — the recovery phrase is force-displayed exactly
|
||||
// once) or waits to be provisioned by another device / restored from the
|
||||
// phrase. All crypto happens in the local backend; this UI only ever sees the
|
||||
// phrase, and only at mint time.
|
||||
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]);
|
||||
|
||||
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'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-sync-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 animate-spin" />
|
||||
) : (
|
||||
'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 && <BackupRows />}
|
||||
|
||||
{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="https://voicebox.sh/account"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Open account dashboard ↗
|
||||
</a>
|
||||
</SettingRow>
|
||||
)}
|
||||
</SettingSection>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupRows() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [phrase, setPhrase] = useState<string | null>(null);
|
||||
const [phraseCopied, setPhraseCopied] = useState(false);
|
||||
const [restoreInput, setRestoreInput] = useState('');
|
||||
|
||||
const { data: sync } = useQuery({
|
||||
queryKey: ['cloud-sync-status'],
|
||||
queryFn: () => apiClient.getCloudSyncStatus(),
|
||||
});
|
||||
|
||||
const refreshSync = () => queryClient.invalidateQueries({ queryKey: ['cloud-sync-status'] });
|
||||
|
||||
const setup = useMutation({
|
||||
mutationFn: () => apiClient.setupCloudSync(),
|
||||
onSuccess: (result) => {
|
||||
refreshSync();
|
||||
if (result.recovery_phrase) {
|
||||
// First device: the phrase exists only in this response. Force-display
|
||||
// it; the dialog can only be dismissed by confirming.
|
||||
setPhraseCopied(false);
|
||||
setPhrase(result.recovery_phrase);
|
||||
}
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast({ title: 'Could not enable backup', description: error.message, variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const adopt = useMutation({
|
||||
mutationFn: () => apiClient.adoptCloudSync(),
|
||||
onSuccess: (result) => {
|
||||
refreshSync();
|
||||
if (result.status === 'ready') {
|
||||
toast({ title: 'Backup enabled', description: 'This device received its encryption key.' });
|
||||
} else {
|
||||
toast({
|
||||
title: 'Not approved yet',
|
||||
description: 'Approve this device from another synced device, then check again.',
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast({ title: 'Could not check', description: error.message, variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const restore = useMutation({
|
||||
mutationFn: (recoveryPhrase: string) => apiClient.restoreCloudSync(recoveryPhrase),
|
||||
onSuccess: () => {
|
||||
refreshSync();
|
||||
setRestoreInput('');
|
||||
toast({ title: 'Backup restored', description: 'Your encryption key was recovered. Syncing is ready.' });
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast({ title: 'Could not restore', description: error.message, variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: () => apiClient.runCloudSync(),
|
||||
onSuccess: (report) => {
|
||||
refreshSync();
|
||||
const pushed = report.pushed + report.pushed_deletes;
|
||||
const pulled = report.pulled + report.pulled_deletes;
|
||||
toast({
|
||||
title: 'Sync complete',
|
||||
description:
|
||||
pushed === 0 && pulled === 0
|
||||
? 'Everything is already up to date.'
|
||||
: `Backed up ${pushed} ${pushed === 1 ? 'item' : 'items'}, received ${pulled}.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast({ title: 'Sync failed', description: error.message, variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const state = sync?.status ?? 'unregistered';
|
||||
|
||||
return (
|
||||
<>
|
||||
{state === 'unregistered' && (
|
||||
<SettingRow
|
||||
title="Encrypted backup"
|
||||
description="Back up captures, generations, and voice profiles — encrypted on this device before anything is uploaded."
|
||||
action={
|
||||
<Button disabled={setup.isPending} onClick={() => setup.mutate()} size="sm">
|
||||
{setup.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="h-3.5 w-3.5 mr-1.5" />
|
||||
Enable backup
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'awaiting_provision' && (
|
||||
<>
|
||||
<SettingRow
|
||||
title="Waiting for encryption key"
|
||||
description="This account already has a backup. Approve this device from another synced device, or restore with your recovery phrase below."
|
||||
action={
|
||||
<Button disabled={adopt.isPending} onClick={() => adopt.mutate()} size="sm" variant="outline">
|
||||
{adopt.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Check again
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
title="Restore with recovery phrase"
|
||||
description="The 12 words you wrote down when you first enabled backup."
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="w-72"
|
||||
onChange={(e) => setRestoreInput(e.target.value)}
|
||||
placeholder="correct horse battery staple …"
|
||||
value={restoreInput}
|
||||
/>
|
||||
<Button
|
||||
disabled={restore.isPending || restoreInput.trim().split(/\s+/).length < 12}
|
||||
onClick={() => restore.mutate(restoreInput)}
|
||||
size="sm"
|
||||
>
|
||||
{restore.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Restore'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'ready' && (
|
||||
<SettingRow
|
||||
title="Encrypted backup"
|
||||
description="On — content is encrypted on this device before upload. The server can never read it."
|
||||
action={
|
||||
<Button disabled={run.isPending} onClick={() => run.mutate()} size="sm">
|
||||
{run.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
Syncing…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Sync now
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RecoveryPhraseDialog
|
||||
copied={phraseCopied}
|
||||
onConfirm={() => setPhrase(null)}
|
||||
onCopy={() => {
|
||||
if (phrase) {
|
||||
navigator.clipboard?.writeText(phrase);
|
||||
setPhraseCopied(true);
|
||||
}
|
||||
}}
|
||||
phrase={phrase}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The one moment the recovery phrase exists outside the backend. Deliberately
|
||||
// hard to dismiss: no overlay/escape close, only the explicit confirmation.
|
||||
function RecoveryPhraseDialog({
|
||||
phrase,
|
||||
copied,
|
||||
onCopy,
|
||||
onConfirm,
|
||||
}: {
|
||||
phrase: string | null;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog onOpenChange={() => {}} open={phrase !== null}>
|
||||
<DialogContent className="max-w-lg [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Write down your recovery phrase</DialogTitle>
|
||||
<DialogDescription>
|
||||
These 12 words are the only way to restore your backup if you lose all your devices.
|
||||
They are never sent to the server and will not be shown again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-3 gap-2 py-2">
|
||||
{(phrase ?? '').split(' ').map((word, index) => (
|
||||
<div
|
||||
className="rounded-md border border-border bg-muted/40 px-2.5 py-1.5 text-sm"
|
||||
// Position is the identity here — BIP39 phrases can repeat words.
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static list, never reordered
|
||||
key={index}
|
||||
>
|
||||
<span className="mr-1.5 text-muted-foreground tabular-nums">{index + 1}.</span>
|
||||
{word}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<Button onClick={onCopy} size="sm" type="button" variant="outline">
|
||||
<Copy className="h-3.5 w-3.5 mr-1.5" />
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} size="sm" type="button">
|
||||
I've written it down
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,6 @@ 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';
|
||||
@@ -208,8 +207,6 @@ export function GeneralPage() {
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<CloudSection />
|
||||
|
||||
<ApiReferenceCard serverUrl={serverUrl} />
|
||||
|
||||
{platform.metadata.isTauri && <UpdatesSection />}
|
||||
|
||||
@@ -50,11 +50,6 @@ import type {
|
||||
MCPClientBinding,
|
||||
MCPClientBindingListResponse,
|
||||
MCPClientBindingUpsert,
|
||||
CloudLoginStartResponse,
|
||||
CloudStatus,
|
||||
CloudSyncRunResponse,
|
||||
CloudSyncSetupResponse,
|
||||
CloudSyncStatus,
|
||||
} from './types';
|
||||
|
||||
function formatErrorDetail(detail: unknown, fallback: string): string {
|
||||
@@ -925,47 +920,6 @@ 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' });
|
||||
}
|
||||
|
||||
// Encrypted backup & sync. setupCloudSync registers this install as an
|
||||
// encryption device — when it returns a recovery_phrase, this device just
|
||||
// minted the account key and the phrase must be force-displayed once.
|
||||
async getCloudSyncStatus(): Promise<CloudSyncStatus> {
|
||||
return this.request<CloudSyncStatus>('/cloud/sync/status');
|
||||
}
|
||||
|
||||
async setupCloudSync(): Promise<CloudSyncSetupResponse> {
|
||||
return this.request<CloudSyncSetupResponse>('/cloud/sync/setup', { method: 'POST' });
|
||||
}
|
||||
|
||||
async restoreCloudSync(phrase: string): Promise<CloudSyncStatus> {
|
||||
return this.request<CloudSyncStatus>('/cloud/sync/restore', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phrase }),
|
||||
});
|
||||
}
|
||||
|
||||
async adoptCloudSync(): Promise<CloudSyncStatus> {
|
||||
return this.request<CloudSyncStatus>('/cloud/sync/adopt', { method: 'POST' });
|
||||
}
|
||||
|
||||
async runCloudSync(): Promise<CloudSyncRunResponse> {
|
||||
return this.request<CloudSyncRunResponse>('/cloud/sync/run', { method: 'POST' });
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
@@ -521,41 +521,3 @@ 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;
|
||||
}
|
||||
|
||||
export type CloudSyncState = 'unregistered' | 'awaiting_provision' | 'ready';
|
||||
|
||||
export interface CloudSyncStatus {
|
||||
status: CloudSyncState;
|
||||
device_id: string | null;
|
||||
sync_cursor: number;
|
||||
}
|
||||
|
||||
export interface CloudSyncSetupResponse {
|
||||
status: CloudSyncState;
|
||||
device_id: string | null;
|
||||
/** Present exactly once, when this device minted the account's key
|
||||
* material. Must be force-displayed and never persisted. */
|
||||
recovery_phrase: string | null;
|
||||
}
|
||||
|
||||
export interface CloudSyncRunResponse {
|
||||
pushed: number;
|
||||
pushed_deletes: number;
|
||||
pulled: number;
|
||||
pulled_deletes: number;
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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,17 +138,3 @@ 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,8 +11,6 @@ from .models import (
|
||||
Capture,
|
||||
CaptureSettings,
|
||||
ChannelDeviceMapping,
|
||||
CloudSettings,
|
||||
CloudSyncState,
|
||||
EffectPreset,
|
||||
Generation,
|
||||
GenerationSettings,
|
||||
@@ -34,8 +32,6 @@ __all__ = [
|
||||
"Capture",
|
||||
"CaptureSettings",
|
||||
"ChannelDeviceMapping",
|
||||
"CloudSettings",
|
||||
"CloudSyncState",
|
||||
"EffectPreset",
|
||||
"Generation",
|
||||
"GenerationSettings",
|
||||
|
||||
@@ -43,7 +43,6 @@ def run_migrations(engine) -> None:
|
||||
_migrate_generation_versions(engine, inspector, tables)
|
||||
_migrate_capture_settings(engine, inspector, tables)
|
||||
_migrate_mcp_bindings(engine, inspector, tables)
|
||||
_migrate_cloud_settings(engine, inspector, tables)
|
||||
_normalize_storage_paths(engine, tables)
|
||||
|
||||
|
||||
@@ -284,19 +283,6 @@ def _migrate_mcp_bindings(engine, inspector, tables: set[str]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _migrate_cloud_settings(engine, inspector, tables: set[str]) -> None:
|
||||
"""Add the cloud sync columns: ``sync_device_id`` (the server-assigned id
|
||||
from registering this install as an encryption-capable sync device) and
|
||||
``sync_cursor`` (highest applied seq from the sync feed)."""
|
||||
if "cloud_settings" not in tables:
|
||||
return
|
||||
columns = _get_columns(inspector, "cloud_settings")
|
||||
if "sync_device_id" not in columns:
|
||||
_add_column(engine, "cloud_settings", "sync_device_id VARCHAR", "sync_device_id")
|
||||
if "sync_cursor" not in columns:
|
||||
_add_column(engine, "cloud_settings", "sync_cursor INTEGER NOT NULL DEFAULT 0", "sync_cursor")
|
||||
|
||||
|
||||
def _supports_drop_column(engine) -> bool:
|
||||
"""Whether ``ALTER TABLE … DROP COLUMN`` is supported by the dialect +
|
||||
runtime. Non-SQLite dialects (Postgres, MySQL) have supported it for
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON, UniqueConstraint
|
||||
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
from ..utils.capture_chords import (
|
||||
@@ -234,64 +234,6 @@ 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)
|
||||
# Server-assigned sync device id (device table on the cloud side). Set when
|
||||
# this install registers as an encryption-capable device; the matching
|
||||
# X25519 private key lives in the OS keychain (services/cloud_keys.py).
|
||||
sync_device_id = Column(String, nullable=True)
|
||||
# Highest server seq this install has applied from the sync feed.
|
||||
sync_cursor = Column(Integer, nullable=False, default=0)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class CloudSyncState(Base):
|
||||
"""Per-entity sync bookkeeping for cloud backup (one row per synced object).
|
||||
|
||||
Envelopes are randomized (fresh content key + nonce per encryption), so the
|
||||
ciphertext hash changes on every re-encrypt even when the content didn't.
|
||||
To keep the server's changed-hash dedup working, this table remembers what
|
||||
was last uploaded: the plaintext fingerprints (to detect real local edits)
|
||||
and the ciphertext hashes the server currently holds (to re-declare
|
||||
unchanged blobs without re-encrypting or re-uploading them).
|
||||
"""
|
||||
|
||||
__tablename__ = "cloud_sync_state"
|
||||
__table_args__ = (UniqueConstraint("kind", "client_id", name="uq_cloud_sync_state_kind_client"),)
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
kind = Column(String, nullable=False) # capture | generation | profile | settings
|
||||
client_id = Column(String, nullable=False) # the local entity id
|
||||
server_object_id = Column(String, nullable=True)
|
||||
# Client-bumped LWW version (object.version on the server).
|
||||
version = Column(Integer, nullable=False, default=1)
|
||||
# SHA-256 of the canonical plaintext record JSON at last push/pull.
|
||||
record_fingerprint = Column(String, nullable=True)
|
||||
# SHA-256 + size of the record ciphertext the server currently holds.
|
||||
record_hash = Column(String, nullable=True)
|
||||
record_size = Column(Integer, nullable=False, default=0)
|
||||
# Per-asset bookkeeping, JSON: {clientAssetId: {role, fingerprint, hash, size}}
|
||||
assets_json = Column(Text, nullable=False, default="{}")
|
||||
last_synced_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class MCPClientBinding(Base):
|
||||
"""Per-MCP-client settings (voice profile, engine, personality default).
|
||||
|
||||
|
||||
@@ -793,61 +793,3 @@ 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
|
||||
|
||||
|
||||
class CloudSyncSetupResponse(BaseModel):
|
||||
"""Result of registering this install as a sync device.
|
||||
|
||||
``recovery_phrase`` is present exactly once, when this device minted the
|
||||
account's key material (first device). The UI must force-display it and
|
||||
never persist it. When absent, the account already has key material and
|
||||
this device is awaiting provisioning (wrapped key from an existing device,
|
||||
or a recovery-phrase restore)."""
|
||||
|
||||
status: str # unregistered | awaiting_provision | ready
|
||||
device_id: Optional[str] = None
|
||||
recovery_phrase: Optional[str] = None
|
||||
|
||||
|
||||
class CloudSyncStatusResponse(BaseModel):
|
||||
"""Sync identity + progress for the settings UI."""
|
||||
|
||||
status: str # unregistered | awaiting_provision | ready
|
||||
device_id: Optional[str] = None
|
||||
sync_cursor: int = 0
|
||||
|
||||
|
||||
class CloudRestoreRequest(BaseModel):
|
||||
"""Recovery-phrase restore on a fresh device."""
|
||||
|
||||
phrase: str
|
||||
|
||||
|
||||
class CloudSyncRunResponse(BaseModel):
|
||||
"""Outcome of one push+pull sync pass."""
|
||||
|
||||
pushed: int
|
||||
pushed_deletes: int
|
||||
pulled: int
|
||||
pulled_deletes: int
|
||||
cursor: int
|
||||
|
||||
@@ -7,12 +7,6 @@ pydantic>=2.5.0
|
||||
sqlalchemy>=2.0.0
|
||||
alembic>=1.13.0
|
||||
|
||||
# Cloud backup/sync E2E encryption (services/cloud_crypto.py); keyring stores
|
||||
# the device key + master key in the OS keychain (services/cloud_keys.py)
|
||||
pynacl>=1.5.0
|
||||
mnemonic>=0.21
|
||||
keyring>=25
|
||||
|
||||
# ML models
|
||||
torch>=2.2.0
|
||||
transformers>=4.36.0,<=4.57.6
|
||||
|
||||
@@ -23,7 +23,6 @@ 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)
|
||||
@@ -43,4 +42,3 @@ 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)
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
"""Voicebox Cloud routes: device login + encrypted backup/sync.
|
||||
|
||||
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.
|
||||
|
||||
Sync, once logged in:
|
||||
5. POST /cloud/sync/setup — register as an encryption device; on a keyless
|
||||
account this mints the master key and returns
|
||||
the recovery phrase (shown exactly once).
|
||||
6. POST /cloud/sync/restore — recover the master key from the phrase.
|
||||
7. POST /cloud/sync/adopt — pick up a wrapped key another device provisioned.
|
||||
8. GET /cloud/sync/status — identity state + cursor.
|
||||
9. POST /cloud/sync/run — one full push+pull pass.
|
||||
"""
|
||||
|
||||
import socket
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..database import CloudSettings as DBCloudSettings, get_db
|
||||
from ..services import cloud as cloud_service, cloud_account, cloud_sync
|
||||
from ..services.cloud_account import CloudAccountError
|
||||
from ..services.cloud_api import CloudApiError
|
||||
from ..services.cloud_crypto import CloudCryptoError
|
||||
from ..services.cloud_keys import CloudKeyStoreError
|
||||
from ..services.cloud_sync import CloudSyncError
|
||||
|
||||
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))
|
||||
|
||||
|
||||
# ─── Encrypted backup & sync ─────────────────────────────────────────────
|
||||
|
||||
_SYNC_ERRORS = (CloudAccountError, CloudApiError, CloudCryptoError, CloudKeyStoreError, CloudSyncError)
|
||||
|
||||
|
||||
def _sync_status(db: Session) -> models.CloudSyncStatusResponse:
|
||||
identity = cloud_account.identity_status(db)
|
||||
row = db.query(DBCloudSettings).filter(DBCloudSettings.id == 1).first()
|
||||
return models.CloudSyncStatusResponse(
|
||||
status=identity.status,
|
||||
device_id=identity.device_id,
|
||||
sync_cursor=(row.sync_cursor or 0) if row else 0,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync/setup", response_model=models.CloudSyncSetupResponse)
|
||||
async def cloud_sync_setup(db: Session = Depends(get_db)):
|
||||
try:
|
||||
phrase = await cloud_account.setup_device(db)
|
||||
identity = cloud_account.identity_status(db)
|
||||
except _SYNC_ERRORS as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
return models.CloudSyncSetupResponse(
|
||||
status=identity.status,
|
||||
device_id=identity.device_id,
|
||||
recovery_phrase=phrase,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync/restore", response_model=models.CloudSyncStatusResponse)
|
||||
async def cloud_sync_restore(body: models.CloudRestoreRequest, db: Session = Depends(get_db)):
|
||||
try:
|
||||
await cloud_account.restore_with_phrase(db, body.phrase)
|
||||
except _SYNC_ERRORS as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
return _sync_status(db)
|
||||
|
||||
|
||||
@router.post("/sync/adopt", response_model=models.CloudSyncStatusResponse)
|
||||
async def cloud_sync_adopt(db: Session = Depends(get_db)):
|
||||
try:
|
||||
await cloud_account.adopt_wrapped_key(db)
|
||||
except _SYNC_ERRORS as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
return _sync_status(db)
|
||||
|
||||
|
||||
@router.get("/sync/status", response_model=models.CloudSyncStatusResponse)
|
||||
async def cloud_sync_status(db: Session = Depends(get_db)):
|
||||
try:
|
||||
return _sync_status(db)
|
||||
except CloudAccountError:
|
||||
return models.CloudSyncStatusResponse(status="unregistered", device_id=None, sync_cursor=0)
|
||||
|
||||
|
||||
@router.post("/sync/run", response_model=models.CloudSyncRunResponse)
|
||||
async def cloud_sync_run(db: Session = Depends(get_db)):
|
||||
try:
|
||||
report = await cloud_sync.run_sync(db)
|
||||
except _SYNC_ERRORS as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
return models.CloudSyncRunResponse(
|
||||
pushed=report.pushed,
|
||||
pushed_deletes=report.pushed_deletes,
|
||||
pulled=report.pulled,
|
||||
pulled_deletes=report.pulled_deletes,
|
||||
cursor=report.cursor,
|
||||
)
|
||||
@@ -1,160 +0,0 @@
|
||||
"""
|
||||
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.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 _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 = exchanged.json()
|
||||
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."
|
||||
account_user_id = (me.json().get("data") or {}).get("userId")
|
||||
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)
|
||||
db.commit()
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
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.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
|
||||
@@ -1,193 +0,0 @@
|
||||
"""
|
||||
Cloud sync identity: the Master Key lifecycle across devices.
|
||||
|
||||
Ties together the pieces below into the flows from the cloud design doc §9
|
||||
(first device, add-a-device, recovery). The server participates only as a
|
||||
mailbox for ciphertext — every wrap/unwrap here happens locally.
|
||||
|
||||
- ``cloud_crypto`` — the primitives (MK, recovery phrase, sealed boxes)
|
||||
- ``cloud_keys`` — OS-keychain persistence of the private key + MK
|
||||
- ``cloud_api`` — the bearer-key HTTP client
|
||||
- ``CloudSettings`` — the local row holding the API key + sync device id
|
||||
|
||||
Flows:
|
||||
- **First device** (``setup_device`` on a keyless account): generate MK, wrap
|
||||
it under a fresh recovery phrase → escrow to the server, wrap it to our own
|
||||
device key, keep MK in the keychain. Returns the phrase for one-time display.
|
||||
- **New device on an existing account** (``setup_device`` when the account has
|
||||
key material): register and wait — an existing device provisions us
|
||||
(``provision_device`` there, ``adopt_wrapped_key`` here), or the user types
|
||||
the recovery phrase (``restore_with_phrase``).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config
|
||||
from ..database import CloudSettings as DBCloudSettings
|
||||
from . import cloud_crypto, cloud_keys
|
||||
from .cloud_api import CloudApiClient
|
||||
from .cloud_crypto import RecoveryWrap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudAccountError(Exception):
|
||||
"""The identity flow cannot proceed (not connected, no escrow, bad phrase…)."""
|
||||
|
||||
|
||||
def _b64e(raw: bytes) -> str:
|
||||
return base64.b64encode(raw).decode("ascii")
|
||||
|
||||
|
||||
def _b64d(encoded: str) -> bytes:
|
||||
return base64.b64decode(encoded)
|
||||
|
||||
|
||||
def _settings(db: Session) -> DBCloudSettings:
|
||||
row = db.query(DBCloudSettings).filter(DBCloudSettings.id == 1).first()
|
||||
if row is None or not row.api_key or not row.account_user_id:
|
||||
raise CloudAccountError("not connected to Voicebox Cloud — log in first")
|
||||
return row
|
||||
|
||||
|
||||
def _client(row: DBCloudSettings) -> CloudApiClient:
|
||||
return CloudApiClient(config.get_cloud_api_url(), row.api_key)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SyncIdentity:
|
||||
# "unregistered" — logged in, but not yet a sync device
|
||||
# "awaiting_provision" — registered, waiting for a wrapped MK or the phrase
|
||||
# "ready" — MK in the keychain, sync can run
|
||||
status: str
|
||||
device_id: str | None
|
||||
|
||||
|
||||
def identity_status(db: Session) -> SyncIdentity:
|
||||
row = _settings(db)
|
||||
if not row.sync_device_id:
|
||||
return SyncIdentity(status="unregistered", device_id=None)
|
||||
has_mk = cloud_keys.load_secret(row.account_user_id, cloud_keys.MASTER_KEY) is not None
|
||||
return SyncIdentity(status="ready" if has_mk else "awaiting_provision", device_id=row.sync_device_id)
|
||||
|
||||
|
||||
async def setup_device(db: Session) -> str | None:
|
||||
"""Register this install as a sync device.
|
||||
|
||||
On a keyless account this is first-device setup: mints MK + the recovery
|
||||
escrow and returns the phrase — the caller must display it exactly once and
|
||||
never persist it. On an account with existing key material it returns None
|
||||
and the device waits in ``awaiting_provision``.
|
||||
"""
|
||||
row = _settings(db)
|
||||
if row.sync_device_id:
|
||||
raise CloudAccountError("this install is already registered as a sync device")
|
||||
|
||||
private_key, public_key = cloud_crypto.generate_device_keypair()
|
||||
async with _client(row) as client:
|
||||
registered = await client.register_device(row.device_name or "Voicebox Desktop", _b64e(public_key))
|
||||
device_id = registered["deviceId"]
|
||||
|
||||
# Persist the private key before anything can depend on it; a crash
|
||||
# after registration leaves a provisionable device, never a locked one.
|
||||
cloud_keys.store_secret(row.account_user_id, cloud_keys.DEVICE_PRIVATE_KEY, private_key)
|
||||
row.sync_device_id = device_id
|
||||
db.commit()
|
||||
|
||||
if registered["accountHasKey"]:
|
||||
logger.info("registered sync device %s; awaiting MK provisioning", device_id)
|
||||
return None
|
||||
|
||||
master_key = cloud_crypto.generate_master_key()
|
||||
phrase = cloud_crypto.generate_recovery_phrase()
|
||||
escrow = cloud_crypto.wrap_master_key_with_phrase(master_key, phrase)
|
||||
await client.put_account_key(_b64e(escrow.wrapped_key), _b64e(escrow.kdf_salt), escrow.kdf_params)
|
||||
await client.put_wrapped_key(device_id, _b64e(cloud_crypto.wrap_master_key_for_device(master_key, public_key)))
|
||||
cloud_keys.store_secret(row.account_user_id, cloud_keys.MASTER_KEY, master_key)
|
||||
logger.info("initialized account key material as first device %s", device_id)
|
||||
return phrase
|
||||
|
||||
|
||||
async def restore_with_phrase(db: Session, phrase: str) -> None:
|
||||
"""Recover MK from the server-side escrow using the recovery phrase.
|
||||
The device must be registered (``setup_device``) first."""
|
||||
row = _settings(db)
|
||||
if not row.sync_device_id:
|
||||
raise CloudAccountError("register this device before restoring")
|
||||
if not cloud_crypto.validate_recovery_phrase(phrase):
|
||||
raise CloudAccountError("that doesn't look like a valid recovery phrase — check for typos")
|
||||
|
||||
async with _client(row) as client:
|
||||
escrow = await client.get_account_key()
|
||||
if not escrow:
|
||||
raise CloudAccountError("this account has no recovery escrow yet")
|
||||
wrap = RecoveryWrap(
|
||||
wrapped_key=_b64d(escrow["recoveryWrappedKey"]),
|
||||
kdf_salt=_b64d(escrow["kdfSalt"]),
|
||||
kdf_params=escrow["kdfParams"],
|
||||
)
|
||||
master_key = cloud_crypto.unwrap_master_key_with_phrase(wrap, phrase)
|
||||
|
||||
# Also wrap MK to our own device key so future restores on this device
|
||||
# don't need the phrase.
|
||||
private_key = cloud_keys.load_secret(row.account_user_id, cloud_keys.DEVICE_PRIVATE_KEY)
|
||||
if private_key is None:
|
||||
raise CloudAccountError("device key missing from the OS keychain — register this device again")
|
||||
public_key = cloud_crypto.device_public_key(private_key)
|
||||
await client.put_wrapped_key(
|
||||
row.sync_device_id, _b64e(cloud_crypto.wrap_master_key_for_device(master_key, public_key))
|
||||
)
|
||||
cloud_keys.store_secret(row.account_user_id, cloud_keys.MASTER_KEY, master_key)
|
||||
logger.info("restored master key from recovery phrase on device %s", row.sync_device_id)
|
||||
|
||||
|
||||
async def adopt_wrapped_key(db: Session) -> bool:
|
||||
"""For a device in ``awaiting_provision``: fetch our wrapped MK if an
|
||||
existing device has provisioned it. Returns True once MK is in the keychain."""
|
||||
row = _settings(db)
|
||||
if not row.sync_device_id:
|
||||
raise CloudAccountError("register this device before adopting a key")
|
||||
|
||||
private_key = cloud_keys.load_secret(row.account_user_id, cloud_keys.DEVICE_PRIVATE_KEY)
|
||||
if private_key is None:
|
||||
raise CloudAccountError("device key missing from the OS keychain — register this device again")
|
||||
|
||||
async with _client(row) as client:
|
||||
wrapped = await client.get_wrapped_key(row.sync_device_id)
|
||||
if not wrapped:
|
||||
return False
|
||||
master_key = cloud_crypto.unwrap_master_key_for_device(_b64d(wrapped), private_key)
|
||||
cloud_keys.store_secret(row.account_user_id, cloud_keys.MASTER_KEY, master_key)
|
||||
logger.info("adopted provisioned master key on device %s", row.sync_device_id)
|
||||
return True
|
||||
|
||||
|
||||
async def provision_device(db: Session, target_device_id: str) -> None:
|
||||
"""Run on a device that already holds MK: wrap it to another registered
|
||||
device's public key so that device can start syncing."""
|
||||
row = _settings(db)
|
||||
master_key = cloud_keys.load_secret(row.account_user_id, cloud_keys.MASTER_KEY)
|
||||
if master_key is None:
|
||||
raise CloudAccountError("this device holds no master key to provision with")
|
||||
|
||||
async with _client(row) as client:
|
||||
devices = await client.list_devices()
|
||||
target = next((d for d in devices if d["id"] == target_device_id and not d.get("revokedAt")), None)
|
||||
if target is None:
|
||||
raise CloudAccountError("target device not found (or revoked)")
|
||||
wrapped = cloud_crypto.wrap_master_key_for_device(master_key, _b64d(target["publicKey"]))
|
||||
await client.put_wrapped_key(target_device_id, _b64e(wrapped))
|
||||
logger.info("provisioned master key to device %s", target_device_id)
|
||||
|
||||
|
||||
def load_master_key(db: Session) -> bytes:
|
||||
"""The MK for the sync engine. Raises if this device isn't ready."""
|
||||
row = _settings(db)
|
||||
master_key = cloud_keys.load_secret(row.account_user_id, cloud_keys.MASTER_KEY)
|
||||
if master_key is None:
|
||||
raise CloudAccountError("no master key on this device — finish setup or restore first")
|
||||
return master_key
|
||||
@@ -1,174 +0,0 @@
|
||||
"""
|
||||
HTTP client for the Voicebox Cloud API (api.voicebox.sh).
|
||||
|
||||
A thin async wrapper over the bearer-key endpoints the sync client needs:
|
||||
device/key distribution, the encrypted object store, and the sync feed. Every
|
||||
payload sent through here is ciphertext or metadata about ciphertext — the
|
||||
encryption itself happens in ``cloud_crypto`` before bytes reach this module.
|
||||
|
||||
Blob bytes don't flow through the API at all: pushes receive presigned PUT
|
||||
URLs and pulls receive presigned GET URLs, and the client transfers ciphertext
|
||||
directly with the storage host. Those transfers use a separate unauthenticated
|
||||
HTTP client so the bearer key is never sent to the storage host.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TIMEOUT = 30.0
|
||||
_BLOB_TIMEOUT = 120.0 # audio assets can be tens of MB
|
||||
|
||||
|
||||
class CloudApiError(Exception):
|
||||
def __init__(self, message: str, status: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
class CloudApiClient:
|
||||
"""One authenticated session against the cloud API. Use as an async context
|
||||
manager so both underlying connection pools are closed."""
|
||||
|
||||
def __init__(self, api_url: str, api_key: str, *, transport: httpx.AsyncBaseTransport | None = None):
|
||||
self._api = httpx.AsyncClient(
|
||||
base_url=api_url.rstrip("/"),
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=_TIMEOUT,
|
||||
transport=transport,
|
||||
)
|
||||
# Presigned-URL transfers: no Authorization header, longer timeout.
|
||||
self._blobs = httpx.AsyncClient(timeout=_BLOB_TIMEOUT, transport=transport)
|
||||
|
||||
async def __aenter__(self) -> "CloudApiClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._api.aclose()
|
||||
await self._blobs.aclose()
|
||||
|
||||
async def _call(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json: dict | None = None,
|
||||
params: dict | None = None,
|
||||
) -> Any:
|
||||
try:
|
||||
resp = await self._api.request(method, path, json=json, params=params)
|
||||
except httpx.HTTPError as err:
|
||||
raise CloudApiError(f"could not reach Voicebox Cloud: {err}") from err
|
||||
if resp.status_code >= 400:
|
||||
message = f"{method} {path} failed ({resp.status_code})"
|
||||
with contextlib.suppress(ValueError):
|
||||
message = resp.json().get("error", {}).get("message", message)
|
||||
raise CloudApiError(message, status=resp.status_code)
|
||||
payload = resp.json()
|
||||
if not payload.get("ok"):
|
||||
raise CloudApiError(f"{method} {path} returned ok=false", status=resp.status_code)
|
||||
return payload.get("data")
|
||||
|
||||
# -- account ------------------------------------------------------------
|
||||
|
||||
async def me(self) -> dict:
|
||||
return await self._call("GET", "/v1/account/me")
|
||||
|
||||
# -- devices & key distribution ------------------------------------------
|
||||
|
||||
async def register_device(self, name: str, public_key_b64: str) -> dict:
|
||||
"""Returns {deviceId, accountHasKey}."""
|
||||
return await self._call("POST", "/v1/devices", json={"name": name, "publicKey": public_key_b64})
|
||||
|
||||
async def list_devices(self) -> list[dict]:
|
||||
return await self._call("GET", "/v1/devices")
|
||||
|
||||
async def put_wrapped_key(self, device_id: str, wrapped_master_key_b64: str) -> None:
|
||||
await self._call(
|
||||
"POST",
|
||||
f"/v1/devices/{device_id}/wrapped-key",
|
||||
json={"wrappedMasterKey": wrapped_master_key_b64},
|
||||
)
|
||||
|
||||
async def get_wrapped_key(self, device_id: str) -> str | None:
|
||||
data = await self._call("GET", f"/v1/devices/{device_id}/wrapped-key")
|
||||
return data.get("wrappedMasterKey") if data else None
|
||||
|
||||
async def put_account_key(self, recovery_wrapped_key_b64: str, kdf_salt_b64: str, kdf_params: str) -> None:
|
||||
await self._call(
|
||||
"PUT",
|
||||
"/v1/devices/account-key",
|
||||
json={
|
||||
"recoveryWrappedKey": recovery_wrapped_key_b64,
|
||||
"kdfSalt": kdf_salt_b64,
|
||||
"kdfParams": kdf_params,
|
||||
},
|
||||
)
|
||||
|
||||
async def get_account_key(self) -> dict | None:
|
||||
"""Returns {recoveryWrappedKey, kdfSalt, kdfParams} or None if the
|
||||
account has no escrow yet."""
|
||||
return await self._call("GET", "/v1/devices/account-key")
|
||||
|
||||
# -- encrypted object store ----------------------------------------------
|
||||
|
||||
async def push_object(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
client_id: str,
|
||||
version: int,
|
||||
record: dict | None,
|
||||
assets: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Upsert object metadata. ``record`` is {hash, size}; each asset is
|
||||
{role, clientAssetId, hash, size}. Returns {objectId, seq, uploads},
|
||||
where uploads lists presigned PUT URLs for exactly the changed blobs."""
|
||||
return await self._call(
|
||||
"POST",
|
||||
"/v1/objects",
|
||||
json={
|
||||
"kind": kind,
|
||||
"clientId": client_id,
|
||||
"version": version,
|
||||
"record": record,
|
||||
"assets": assets or [],
|
||||
},
|
||||
)
|
||||
|
||||
async def commit_object(self, object_id: str) -> None:
|
||||
"""Ask the server to verify all claimed blobs actually landed in storage."""
|
||||
await self._call("POST", f"/v1/objects/{object_id}/commit")
|
||||
|
||||
async def delete_object(self, object_id: str) -> None:
|
||||
await self._call("DELETE", f"/v1/objects/{object_id}")
|
||||
|
||||
async def get_changes(self, since: int, limit: int = 200) -> dict:
|
||||
"""Sync pull: {changes, cursor, hasMore} for everything newer than ``since``."""
|
||||
return await self._call("GET", "/v1/sync/changes", params={"since": since, "limit": limit})
|
||||
|
||||
# -- blob transfer (presigned URLs, ciphertext only) ----------------------
|
||||
|
||||
async def upload_blob(self, url: str, data: bytes) -> None:
|
||||
try:
|
||||
resp = await self._blobs.put(url, content=data, headers={"Content-Type": "application/octet-stream"})
|
||||
except httpx.HTTPError as err:
|
||||
raise CloudApiError(f"blob upload failed: {err}") from err
|
||||
if resp.status_code >= 400:
|
||||
raise CloudApiError(f"blob upload rejected ({resp.status_code})", status=resp.status_code)
|
||||
|
||||
async def download_blob(self, url: str) -> bytes:
|
||||
try:
|
||||
resp = await self._blobs.get(url)
|
||||
except httpx.HTTPError as err:
|
||||
raise CloudApiError(f"blob download failed: {err}") from err
|
||||
if resp.status_code >= 400:
|
||||
raise CloudApiError(f"blob download rejected ({resp.status_code})", status=resp.status_code)
|
||||
return resp.content
|
||||
@@ -1,233 +0,0 @@
|
||||
"""
|
||||
Client-side cryptography for Voicebox Cloud backup & sync.
|
||||
|
||||
This is the auditable half of the cloud's privacy promise: every byte that
|
||||
leaves this machine is encrypted here first, and the server only ever stores
|
||||
ciphertext plus routing metadata. The key hierarchy (cloud repo,
|
||||
``docs/DESIGN.md``):
|
||||
|
||||
Recovery phrase (BIP39) Device X25519 keypairs
|
||||
| Argon2id(salt, params) | sealed box
|
||||
v v
|
||||
Recovery KEK --wrap--> Master Key (MK) <--wrapped to each device
|
||||
|
|
||||
| per-blob random Content Key (CK),
|
||||
| wrapped by MK inside the envelope
|
||||
v
|
||||
XChaCha20-Poly1305(CK) over each record/asset blob
|
||||
|
||||
Everything in this module is a pure function over bytes — no I/O, no storage,
|
||||
no network. Key persistence (OS keychain) and the sync engine live elsewhere.
|
||||
|
||||
Invariants this module enforces:
|
||||
- The Master Key is random, never derived from anything the server holds
|
||||
(API keys and wallet keys are auth/entitlement, never encryption roots).
|
||||
- Each blob's AAD binds it to its logical slot (object, role, version), so a
|
||||
server cannot substitute one blob for another without decryption failing.
|
||||
- Content Keys travel only inside the envelope, wrapped by MK — the database
|
||||
(local and cloud) stays free of key material.
|
||||
"""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
|
||||
import nacl.bindings
|
||||
import nacl.exceptions
|
||||
import nacl.pwhash
|
||||
import nacl.utils
|
||||
from mnemonic import Mnemonic
|
||||
from nacl.public import PrivateKey, PublicKey, SealedBox
|
||||
from nacl.secret import SecretBox
|
||||
|
||||
KEY_BYTES = 32
|
||||
|
||||
# Envelope framing: magic | alg | len(wrapped_ck) | wrapped_ck | nonce | ciphertext
|
||||
ENVELOPE_MAGIC = b"VBX1"
|
||||
ALG_XCHACHA20_POLY1305 = 1
|
||||
_WRAPPED_CK_LEN_BYTES = 2
|
||||
_NONCE_BYTES = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES # 24
|
||||
|
||||
# Argon2id cost for the recovery-phrase KEK. MODERATE (~256 MiB, interactive
|
||||
# latency) fits a desktop restore flow; the phrase itself already carries
|
||||
# 128 bits of entropy, so the KDF is hardening, not the main defense.
|
||||
_KDF_OPSLIMIT = nacl.pwhash.argon2id.OPSLIMIT_MODERATE
|
||||
_KDF_MEMLIMIT = nacl.pwhash.argon2id.MEMLIMIT_MODERATE
|
||||
|
||||
_mnemonic = Mnemonic("english")
|
||||
|
||||
|
||||
class CloudCryptoError(Exception):
|
||||
"""Envelope malformed, key wrong, or ciphertext tampered with."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Master Key + device keys
|
||||
|
||||
|
||||
def generate_master_key() -> bytes:
|
||||
"""The 32-byte root of content secrecy. Generated once per account, client-side."""
|
||||
return secrets.token_bytes(KEY_BYTES)
|
||||
|
||||
|
||||
def generate_device_keypair() -> tuple[bytes, bytes]:
|
||||
"""X25519 (private, public) for this install. The private key never leaves
|
||||
the device; the public key is registered with the cloud so existing devices
|
||||
can wrap MK to it."""
|
||||
private = PrivateKey.generate()
|
||||
return bytes(private), bytes(private.public_key)
|
||||
|
||||
|
||||
def device_public_key(device_private_key: bytes) -> bytes:
|
||||
"""Re-derive the public half from a stored private key."""
|
||||
return bytes(PrivateKey(device_private_key).public_key)
|
||||
|
||||
|
||||
def wrap_master_key_for_device(master_key: bytes, device_public_key: bytes) -> bytes:
|
||||
"""Seal MK to another device's public key (run on an *existing* device when
|
||||
provisioning a new one). Only the target device's private key can open it."""
|
||||
return SealedBox(PublicKey(device_public_key)).encrypt(master_key)
|
||||
|
||||
|
||||
def unwrap_master_key_for_device(wrapped: bytes, device_private_key: bytes) -> bytes:
|
||||
"""Open a sealed MK with this device's private key."""
|
||||
try:
|
||||
return SealedBox(PrivateKey(device_private_key)).decrypt(wrapped)
|
||||
except nacl.exceptions.CryptoError as err:
|
||||
raise CloudCryptoError("wrapped master key does not match this device key") from err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recovery phrase escrow
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryWrap:
|
||||
"""What the cloud stores in ``account_key``: ciphertext + the KDF context
|
||||
any client needs to re-derive the KEK from the phrase. Holds no secrets."""
|
||||
|
||||
wrapped_key: bytes
|
||||
kdf_salt: bytes
|
||||
kdf_params: str # JSON, e.g. {"m": ..., "t": ..., "p": 1}
|
||||
|
||||
|
||||
def generate_recovery_phrase() -> str:
|
||||
"""12-word BIP39 mnemonic (128-bit entropy) — shown to the user exactly once."""
|
||||
return _mnemonic.generate(strength=128)
|
||||
|
||||
|
||||
def validate_recovery_phrase(phrase: str) -> bool:
|
||||
"""Word-level checksum validation, for catching typos before an unwrap attempt."""
|
||||
return _mnemonic.check(_normalize_phrase(phrase))
|
||||
|
||||
|
||||
def _normalize_phrase(phrase: str) -> str:
|
||||
return " ".join(phrase.lower().split())
|
||||
|
||||
|
||||
def _derive_kek(phrase: str, salt: bytes, opslimit: int, memlimit: int) -> bytes:
|
||||
return nacl.pwhash.argon2id.kdf(
|
||||
KEY_BYTES,
|
||||
_normalize_phrase(phrase).encode("utf-8"),
|
||||
salt,
|
||||
opslimit=opslimit,
|
||||
memlimit=memlimit,
|
||||
)
|
||||
|
||||
|
||||
def wrap_master_key_with_phrase(master_key: bytes, phrase: str) -> RecoveryWrap:
|
||||
"""Argon2id-stretch the phrase into a KEK and wrap MK under it."""
|
||||
salt = nacl.utils.random(nacl.pwhash.argon2id.SALTBYTES)
|
||||
kek = _derive_kek(phrase, salt, _KDF_OPSLIMIT, _KDF_MEMLIMIT)
|
||||
return RecoveryWrap(
|
||||
wrapped_key=SecretBox(kek).encrypt(master_key),
|
||||
kdf_salt=salt,
|
||||
kdf_params=json.dumps({"m": _KDF_MEMLIMIT, "t": _KDF_OPSLIMIT, "p": 1}),
|
||||
)
|
||||
|
||||
|
||||
def unwrap_master_key_with_phrase(wrap: RecoveryWrap, phrase: str) -> bytes:
|
||||
"""Recover MK on a fresh device from the phrase + the stored KDF context."""
|
||||
try:
|
||||
params = json.loads(wrap.kdf_params)
|
||||
opslimit, memlimit = int(params["t"]), int(params["m"])
|
||||
except (ValueError, KeyError, TypeError) as err:
|
||||
raise CloudCryptoError("malformed KDF parameters") from err
|
||||
kek = _derive_kek(phrase, wrap.kdf_salt, opslimit, memlimit)
|
||||
try:
|
||||
return SecretBox(kek).decrypt(wrap.wrapped_key)
|
||||
except nacl.exceptions.CryptoError as err:
|
||||
raise CloudCryptoError("recovery phrase does not unlock this account key") from err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blob envelope
|
||||
|
||||
|
||||
def _build_aad(object_id: str, role: str, version: int) -> bytes:
|
||||
# Binds a blob to its logical slot. Unambiguous because object_id is a UUID
|
||||
# and role is a fixed token — neither contains ":".
|
||||
return f"{object_id}:{role}:{version}".encode()
|
||||
|
||||
|
||||
def encrypt_blob(plaintext: bytes, master_key: bytes, *, object_id: str, role: str, version: int) -> bytes:
|
||||
"""Produce a self-describing VBX1 envelope: a fresh Content Key wrapped by
|
||||
MK rides in the header, and the AAD pins the blob to (object, role, version)."""
|
||||
content_key = secrets.token_bytes(KEY_BYTES)
|
||||
wrapped_ck = SecretBox(master_key).encrypt(content_key)
|
||||
nonce = nacl.utils.random(_NONCE_BYTES)
|
||||
ciphertext = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
plaintext,
|
||||
_build_aad(object_id, role, version),
|
||||
nonce,
|
||||
content_key,
|
||||
)
|
||||
return b"".join(
|
||||
[
|
||||
ENVELOPE_MAGIC,
|
||||
bytes([ALG_XCHACHA20_POLY1305]),
|
||||
len(wrapped_ck).to_bytes(_WRAPPED_CK_LEN_BYTES, "big"),
|
||||
wrapped_ck,
|
||||
nonce,
|
||||
ciphertext,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def decrypt_blob(envelope: bytes, master_key: bytes, *, object_id: str, role: str, version: int) -> bytes:
|
||||
"""Open a VBX1 envelope. Raises CloudCryptoError if the envelope is
|
||||
malformed, the key is wrong, the ciphertext was modified, or the blob was
|
||||
served for a different (object, role, version) slot."""
|
||||
offset = len(ENVELOPE_MAGIC)
|
||||
if envelope[:offset] != ENVELOPE_MAGIC:
|
||||
raise CloudCryptoError("not a VBX1 envelope")
|
||||
if len(envelope) < offset + 1 + _WRAPPED_CK_LEN_BYTES:
|
||||
raise CloudCryptoError("envelope truncated")
|
||||
alg = envelope[offset]
|
||||
if alg != ALG_XCHACHA20_POLY1305:
|
||||
raise CloudCryptoError(f"unsupported envelope algorithm {alg}")
|
||||
offset += 1
|
||||
|
||||
wrapped_len = int.from_bytes(envelope[offset : offset + _WRAPPED_CK_LEN_BYTES], "big")
|
||||
offset += _WRAPPED_CK_LEN_BYTES
|
||||
wrapped_ck = envelope[offset : offset + wrapped_len]
|
||||
offset += wrapped_len
|
||||
nonce = envelope[offset : offset + _NONCE_BYTES]
|
||||
offset += _NONCE_BYTES
|
||||
ciphertext = envelope[offset:]
|
||||
if len(wrapped_ck) != wrapped_len or len(nonce) != _NONCE_BYTES or not ciphertext:
|
||||
raise CloudCryptoError("envelope truncated")
|
||||
|
||||
try:
|
||||
content_key = SecretBox(master_key).decrypt(wrapped_ck)
|
||||
except nacl.exceptions.CryptoError as err:
|
||||
raise CloudCryptoError("master key does not unwrap this blob's content key") from err
|
||||
try:
|
||||
return nacl.bindings.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
ciphertext,
|
||||
_build_aad(object_id, role, version),
|
||||
nonce,
|
||||
content_key,
|
||||
)
|
||||
except nacl.exceptions.CryptoError as err:
|
||||
raise CloudCryptoError("blob failed authentication (tampered, or served for the wrong slot)") from err
|
||||
@@ -1,63 +0,0 @@
|
||||
"""
|
||||
OS-keychain persistence for cloud E2E key material.
|
||||
|
||||
The bearer API key lives in the local database (``CloudSettings``) because it
|
||||
is auth, not secrecy. The *encryption* keys never touch the database: this
|
||||
module stores the device's X25519 private key and the unwrapped Master Key in
|
||||
the OS keychain (macOS Keychain, Windows Credential Locker, Secret Service on
|
||||
Linux) via ``keyring``. Entries are namespaced by cloud account user id so
|
||||
relinking to a different account can never read the previous account's keys.
|
||||
|
||||
Headless installs (Docker/web) may have no keychain backend; operations then
|
||||
raise ``CloudKeyStoreError`` and cloud sync stays unavailable rather than
|
||||
silently degrading to plaintext-on-disk key storage.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
import keyring
|
||||
import keyring.errors
|
||||
|
||||
_SERVICE = "sh.voicebox.cloud"
|
||||
|
||||
DEVICE_PRIVATE_KEY = "device_private_key"
|
||||
MASTER_KEY = "master_key"
|
||||
_ALL_ENTRIES = (DEVICE_PRIVATE_KEY, MASTER_KEY)
|
||||
|
||||
|
||||
class CloudKeyStoreError(Exception):
|
||||
"""The OS keychain is unavailable or rejected the operation."""
|
||||
|
||||
|
||||
def _entry(account_user_id: str, name: str) -> str:
|
||||
return f"{account_user_id}:{name}"
|
||||
|
||||
|
||||
def store_secret(account_user_id: str, name: str, secret: bytes) -> None:
|
||||
try:
|
||||
keyring.set_password(_SERVICE, _entry(account_user_id, name), base64.b64encode(secret).decode("ascii"))
|
||||
except keyring.errors.KeyringError as err:
|
||||
raise CloudKeyStoreError(f"could not store {name} in the OS keychain") from err
|
||||
|
||||
|
||||
def load_secret(account_user_id: str, name: str) -> bytes | None:
|
||||
try:
|
||||
stored = keyring.get_password(_SERVICE, _entry(account_user_id, name))
|
||||
except keyring.errors.KeyringError as err:
|
||||
raise CloudKeyStoreError(f"could not read {name} from the OS keychain") from err
|
||||
return base64.b64decode(stored) if stored else None
|
||||
|
||||
|
||||
def delete_secret(account_user_id: str, name: str) -> None:
|
||||
try:
|
||||
keyring.delete_password(_SERVICE, _entry(account_user_id, name))
|
||||
except keyring.errors.PasswordDeleteError:
|
||||
pass # already absent
|
||||
except keyring.errors.KeyringError as err:
|
||||
raise CloudKeyStoreError(f"could not delete {name} from the OS keychain") from err
|
||||
|
||||
|
||||
def clear(account_user_id: str) -> None:
|
||||
"""Forget all key material for an account (disconnect / account switch)."""
|
||||
for name in _ALL_ENTRIES:
|
||||
delete_secret(account_user_id, name)
|
||||
@@ -1,535 +0,0 @@
|
||||
"""
|
||||
The cloud sync engine: encrypted backup + multi-device restore.
|
||||
|
||||
Walks the local store (SQLite rows + audio files), maps each entity onto the
|
||||
cloud's object model, and drives the push/pull loop against the blind server.
|
||||
Everything crosses the wire as VBX1 ciphertext (``cloud_crypto``); the server
|
||||
only ever learns kinds, ids, sizes, and hashes.
|
||||
|
||||
Mapping (cloud repo ``docs/DESIGN.md`` §5):
|
||||
|
||||
| local entity | kind | record (encrypted JSON) | assets |
|
||||
| ------------------------------------- | ---------- | ------------------------- | ----------------- |
|
||||
| ``captures`` row + wav | capture | the row | the capture audio |
|
||||
| ``generations`` row + version wavs | generation | the row + version rows | each version wav |
|
||||
| ``profiles`` row + samples + avatar | profile | the row + sample rows | sample wavs, avatar |
|
||||
| ``capture_settings`` / ``generation_settings`` | settings | the row | — |
|
||||
|
||||
Path columns are stored storage-relative inside the (encrypted) record, so a
|
||||
restore re-anchors them under the destination machine's data dir.
|
||||
|
||||
Change detection: envelopes are randomized, so ``CloudSyncState`` keeps the
|
||||
plaintext fingerprint (did the content actually change?) alongside the
|
||||
ciphertext hash the server holds (re-declare unchanged blobs without
|
||||
re-encrypting). Conflicts are last-writer-wins per object, matching §6 —
|
||||
push runs before pull, so local edits are declared before remote state lands.
|
||||
|
||||
AAD binding: records are bound to ``(clientId, "record", version)`` and
|
||||
re-encrypted on every version bump. Asset blobs are bound to
|
||||
``(clientId, "asset:<clientAssetId>", 1)`` — assets are content-addressed and
|
||||
practically immutable (audio never changes in place), so their slot binding
|
||||
doesn't chase the object version.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config
|
||||
from ..database import (
|
||||
Capture,
|
||||
CaptureSettings,
|
||||
CloudSettings as DBCloudSettings,
|
||||
CloudSyncState,
|
||||
Generation,
|
||||
GenerationSettings,
|
||||
GenerationVersion,
|
||||
ProfileSample,
|
||||
VoiceProfile,
|
||||
)
|
||||
from . import cloud_account, cloud_crypto
|
||||
from .cloud_api import CloudApiClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ASSET_AAD_VERSION = 1
|
||||
|
||||
|
||||
class CloudSyncError(Exception):
|
||||
"""Sync could not run or an object failed to round-trip."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local object collection (push side)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalAsset:
|
||||
client_asset_id: str
|
||||
role: str # audio | version | sample | avatar
|
||||
path: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalObject:
|
||||
kind: str
|
||||
client_id: str
|
||||
record: dict
|
||||
assets: list[LocalAsset] = field(default_factory=list)
|
||||
|
||||
|
||||
_PATH_COLUMNS = {"audio_path", "avatar_path"}
|
||||
|
||||
|
||||
def _row_to_record(row) -> dict:
|
||||
"""All mapped columns as JSON-safe values; paths storage-relative,
|
||||
datetimes ISO-8601."""
|
||||
record: dict = {}
|
||||
for column in row.__mapper__.columns:
|
||||
value = getattr(row, column.key)
|
||||
if value is None:
|
||||
record[column.key] = None
|
||||
elif column.key in _PATH_COLUMNS:
|
||||
# Rows normally hold data-dir-relative paths already; only rebase
|
||||
# absolute ones. (to_storage_path on a relative value would resolve
|
||||
# it against the CWD and corrupt it.)
|
||||
record[column.key] = config.to_storage_path(value) if Path(value).is_absolute() else value
|
||||
elif isinstance(value, datetime):
|
||||
record[column.key] = value.isoformat()
|
||||
else:
|
||||
record[column.key] = value
|
||||
return record
|
||||
|
||||
|
||||
def _is_datetime_column(column) -> bool:
|
||||
try:
|
||||
return column.type.python_type is datetime
|
||||
except NotImplementedError: # e.g. JSON columns don't declare a python_type
|
||||
return False
|
||||
|
||||
|
||||
def _record_to_row(model, record: dict, existing=None):
|
||||
"""Build or update a model instance from a record dict."""
|
||||
row = existing if existing is not None else model()
|
||||
for column in row.__mapper__.columns:
|
||||
if column.key not in record:
|
||||
continue
|
||||
value = record[column.key]
|
||||
if isinstance(value, str) and _is_datetime_column(column):
|
||||
value = datetime.fromisoformat(value)
|
||||
setattr(row, column.key, value)
|
||||
return row
|
||||
|
||||
|
||||
def _existing_path(value: str | None) -> Path | None:
|
||||
resolved = config.resolve_storage_path(value)
|
||||
return resolved if resolved is not None and resolved.exists() else None
|
||||
|
||||
|
||||
def _collect_captures(db: Session) -> list[LocalObject]:
|
||||
objects = []
|
||||
for row in db.query(Capture).all():
|
||||
assets = []
|
||||
if (path := _existing_path(row.audio_path)) is not None:
|
||||
assets.append(LocalAsset(client_asset_id=row.id, role="audio", path=path))
|
||||
objects.append(LocalObject(kind="capture", client_id=row.id, record=_row_to_record(row), assets=assets))
|
||||
return objects
|
||||
|
||||
|
||||
def _collect_generations(db: Session) -> list[LocalObject]:
|
||||
objects = []
|
||||
for row in db.query(Generation).filter(Generation.status == "completed").all():
|
||||
record = _row_to_record(row)
|
||||
assets = []
|
||||
if (path := _existing_path(row.audio_path)) is not None:
|
||||
assets.append(LocalAsset(client_asset_id=row.id, role="audio", path=path))
|
||||
versions = db.query(GenerationVersion).filter(GenerationVersion.generation_id == row.id).all()
|
||||
record["versions"] = [_row_to_record(v) for v in versions]
|
||||
for version in versions:
|
||||
if (path := _existing_path(version.audio_path)) is not None:
|
||||
assets.append(LocalAsset(client_asset_id=version.id, role="version", path=path))
|
||||
objects.append(LocalObject(kind="generation", client_id=row.id, record=record, assets=assets))
|
||||
return objects
|
||||
|
||||
|
||||
def _collect_profiles(db: Session) -> list[LocalObject]:
|
||||
objects = []
|
||||
for row in db.query(VoiceProfile).all():
|
||||
record = _row_to_record(row)
|
||||
assets = []
|
||||
if (path := _existing_path(row.avatar_path)) is not None:
|
||||
assets.append(LocalAsset(client_asset_id=f"{row.id}-avatar", role="avatar", path=path))
|
||||
samples = db.query(ProfileSample).filter(ProfileSample.profile_id == row.id).all()
|
||||
record["samples"] = [_row_to_record(s) for s in samples]
|
||||
for sample in samples:
|
||||
if (path := _existing_path(sample.audio_path)) is not None:
|
||||
assets.append(LocalAsset(client_asset_id=sample.id, role="sample", path=path))
|
||||
objects.append(LocalObject(kind="profile", client_id=row.id, record=record, assets=assets))
|
||||
return objects
|
||||
|
||||
|
||||
def _collect_settings(db: Session) -> list[LocalObject]:
|
||||
objects = []
|
||||
for client_id, model in (("capture_settings", CaptureSettings), ("generation_settings", GenerationSettings)):
|
||||
row = db.query(model).first()
|
||||
if row is not None:
|
||||
objects.append(LocalObject(kind="settings", client_id=client_id, record=_row_to_record(row)))
|
||||
return objects
|
||||
|
||||
|
||||
def collect_local_objects(db: Session) -> list[LocalObject]:
|
||||
return _collect_captures(db) + _collect_generations(db) + _collect_profiles(db) + _collect_settings(db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Applying pulled records (pull side)
|
||||
|
||||
|
||||
def _write_asset(record_path_value: str | None, data: bytes) -> None:
|
||||
resolved = config.resolve_storage_path(record_path_value)
|
||||
if resolved is None:
|
||||
return
|
||||
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||
resolved.write_bytes(data)
|
||||
|
||||
|
||||
def _apply_children(db: Session, model, parent_filter, child_records: list[dict], blobs: dict[str, bytes]) -> None:
|
||||
"""Upsert child rows (versions/samples) by id; drop local children the
|
||||
record no longer contains; write any pulled audio next to them."""
|
||||
wanted = {child["id"] for child in child_records}
|
||||
for stale in db.query(model).filter(parent_filter).all():
|
||||
if stale.id not in wanted:
|
||||
db.delete(stale)
|
||||
for child in child_records:
|
||||
existing = db.query(model).filter(model.id == child["id"]).first()
|
||||
row = _record_to_row(model, child, existing)
|
||||
if existing is None:
|
||||
db.add(row)
|
||||
if child["id"] in blobs:
|
||||
_write_asset(child.get("audio_path"), blobs[child["id"]])
|
||||
|
||||
|
||||
def _apply_capture(db: Session, client_id: str, record: dict, blobs: dict[str, bytes]) -> None:
|
||||
existing = db.query(Capture).filter(Capture.id == client_id).first()
|
||||
row = _record_to_row(Capture, record, existing)
|
||||
if existing is None:
|
||||
db.add(row)
|
||||
if client_id in blobs:
|
||||
_write_asset(record.get("audio_path"), blobs[client_id])
|
||||
|
||||
|
||||
def _apply_generation(db: Session, client_id: str, record: dict, blobs: dict[str, bytes]) -> None:
|
||||
record = dict(record)
|
||||
versions = record.pop("versions", [])
|
||||
existing = db.query(Generation).filter(Generation.id == client_id).first()
|
||||
row = _record_to_row(Generation, record, existing)
|
||||
if existing is None:
|
||||
db.add(row)
|
||||
if client_id in blobs:
|
||||
_write_asset(record.get("audio_path"), blobs[client_id])
|
||||
_apply_children(db, GenerationVersion, GenerationVersion.generation_id == client_id, versions, blobs)
|
||||
|
||||
|
||||
def _apply_profile(db: Session, client_id: str, record: dict, blobs: dict[str, bytes]) -> None:
|
||||
record = dict(record)
|
||||
samples = record.pop("samples", [])
|
||||
existing = db.query(VoiceProfile).filter(VoiceProfile.id == client_id).first()
|
||||
row = _record_to_row(VoiceProfile, record, existing)
|
||||
if existing is None:
|
||||
db.add(row)
|
||||
if f"{client_id}-avatar" in blobs:
|
||||
_write_asset(record.get("avatar_path"), blobs[f"{client_id}-avatar"])
|
||||
_apply_children(db, ProfileSample, ProfileSample.profile_id == client_id, samples, blobs)
|
||||
|
||||
|
||||
def _apply_settings(db: Session, client_id: str, record: dict) -> None:
|
||||
model = CaptureSettings if client_id == "capture_settings" else GenerationSettings
|
||||
existing = db.query(model).first()
|
||||
row = _record_to_row(model, record, existing)
|
||||
if existing is None:
|
||||
db.add(row)
|
||||
|
||||
|
||||
def _apply_record(db: Session, kind: str, client_id: str, record: dict, blobs: dict[str, bytes]) -> None:
|
||||
if kind == "capture":
|
||||
_apply_capture(db, client_id, record, blobs)
|
||||
elif kind == "generation":
|
||||
_apply_generation(db, client_id, record, blobs)
|
||||
elif kind == "profile":
|
||||
_apply_profile(db, client_id, record, blobs)
|
||||
elif kind == "settings":
|
||||
_apply_settings(db, client_id, record)
|
||||
else:
|
||||
raise CloudSyncError(f"unknown object kind {kind!r}")
|
||||
|
||||
|
||||
def _delete_local(db: Session, kind: str, client_id: str) -> None:
|
||||
if kind == "capture":
|
||||
db.query(Capture).filter(Capture.id == client_id).delete()
|
||||
elif kind == "generation":
|
||||
db.query(GenerationVersion).filter(GenerationVersion.generation_id == client_id).delete()
|
||||
db.query(Generation).filter(Generation.id == client_id).delete()
|
||||
elif kind == "profile":
|
||||
db.query(ProfileSample).filter(ProfileSample.profile_id == client_id).delete()
|
||||
db.query(VoiceProfile).filter(VoiceProfile.id == client_id).delete()
|
||||
# settings singletons are never deleted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The engine
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncReport:
|
||||
pushed: int = 0
|
||||
pushed_deletes: int = 0
|
||||
pulled: int = 0
|
||||
pulled_deletes: int = 0
|
||||
cursor: int = 0
|
||||
|
||||
|
||||
def _canonical(record: dict) -> bytes:
|
||||
"""Canonical bytes for change detection. ``updated_at`` is excluded (at the
|
||||
top level and in embedded child rows): its ``onupdate`` trigger can bump it
|
||||
as a side effect of *applying* a pulled record, and letting that feed back
|
||||
into the fingerprint would bounce an already-synced object back and forth.
|
||||
The field still syncs — it just doesn't count as a change by itself."""
|
||||
stripped = {k: v for k, v in record.items() if k != "updated_at"}
|
||||
for key, value in stripped.items():
|
||||
if isinstance(value, list):
|
||||
stripped[key] = [
|
||||
{k: v for k, v in item.items() if k != "updated_at"} if isinstance(item, dict) else item
|
||||
for item in value
|
||||
]
|
||||
return json.dumps(stripped, sort_keys=True, separators=(",", ":")).encode()
|
||||
|
||||
|
||||
def _fingerprint(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _get_state(db: Session, kind: str, client_id: str) -> CloudSyncState | None:
|
||||
return db.query(CloudSyncState).filter(CloudSyncState.kind == kind, CloudSyncState.client_id == client_id).first()
|
||||
|
||||
|
||||
def _settings_row(db: Session) -> DBCloudSettings:
|
||||
row = db.query(DBCloudSettings).filter(DBCloudSettings.id == 1).first()
|
||||
if row is None:
|
||||
raise CloudSyncError("not connected to Voicebox Cloud")
|
||||
return row
|
||||
|
||||
|
||||
async def _push_object(
|
||||
client: CloudApiClient,
|
||||
db: Session,
|
||||
master_key: bytes,
|
||||
obj: LocalObject,
|
||||
state: CloudSyncState | None,
|
||||
) -> bool:
|
||||
"""Push one object if it changed. Returns True when a push happened."""
|
||||
record_payload = json.dumps(obj.record, sort_keys=True, separators=(",", ":")).encode()
|
||||
record_fp = _fingerprint(_canonical(obj.record))
|
||||
known_assets: dict = json.loads(state.assets_json) if state else {}
|
||||
|
||||
asset_plain: dict[str, bytes] = {}
|
||||
asset_fps: dict[str, str] = {}
|
||||
for asset in obj.assets:
|
||||
data = asset.path.read_bytes()
|
||||
asset_plain[asset.client_asset_id] = data
|
||||
asset_fps[asset.client_asset_id] = _fingerprint(data)
|
||||
|
||||
unchanged = (
|
||||
state is not None
|
||||
and state.server_object_id is not None
|
||||
and state.record_fingerprint == record_fp
|
||||
and {k: v["fingerprint"] for k, v in known_assets.items()} == asset_fps
|
||||
)
|
||||
if unchanged:
|
||||
return False
|
||||
|
||||
version = (state.version + 1) if state is not None else 1
|
||||
record_env = cloud_crypto.encrypt_blob(
|
||||
record_payload, master_key, object_id=obj.client_id, role="record", version=version
|
||||
)
|
||||
|
||||
descriptors = []
|
||||
envelopes: dict[str, bytes] = {}
|
||||
next_assets: dict[str, dict] = {}
|
||||
for asset in obj.assets:
|
||||
caid = asset.client_asset_id
|
||||
known = known_assets.get(caid)
|
||||
if known and known["fingerprint"] == asset_fps[caid]:
|
||||
# Content unchanged: re-declare the ciphertext the server holds.
|
||||
entry = {"role": asset.role, "fingerprint": asset_fps[caid], "hash": known["hash"], "size": known["size"]}
|
||||
else:
|
||||
envelope = cloud_crypto.encrypt_blob(
|
||||
asset_plain[caid],
|
||||
master_key,
|
||||
object_id=obj.client_id,
|
||||
role=f"asset:{caid}",
|
||||
version=_ASSET_AAD_VERSION,
|
||||
)
|
||||
envelopes[caid] = envelope
|
||||
entry = {
|
||||
"role": asset.role,
|
||||
"fingerprint": asset_fps[caid],
|
||||
"hash": _fingerprint(envelope),
|
||||
"size": len(envelope),
|
||||
}
|
||||
next_assets[caid] = entry
|
||||
descriptors.append({"role": asset.role, "clientAssetId": caid, "hash": entry["hash"], "size": entry["size"]})
|
||||
|
||||
pushed = await client.push_object(
|
||||
kind=obj.kind,
|
||||
client_id=obj.client_id,
|
||||
version=version,
|
||||
record={"hash": _fingerprint(record_env), "size": len(record_env)},
|
||||
assets=descriptors,
|
||||
)
|
||||
uploads = {u["for"]: u["url"] for u in pushed["uploads"]}
|
||||
if "record" in uploads:
|
||||
await client.upload_blob(uploads["record"], record_env)
|
||||
for caid, envelope in envelopes.items():
|
||||
url = uploads.get(f"asset:{caid}")
|
||||
if url:
|
||||
await client.upload_blob(url, envelope)
|
||||
await client.commit_object(pushed["objectId"])
|
||||
|
||||
if state is None:
|
||||
state = CloudSyncState(kind=obj.kind, client_id=obj.client_id)
|
||||
db.add(state)
|
||||
state.server_object_id = pushed["objectId"]
|
||||
state.version = version
|
||||
state.record_fingerprint = record_fp
|
||||
state.record_hash = _fingerprint(record_env)
|
||||
state.record_size = len(record_env)
|
||||
state.assets_json = json.dumps(next_assets)
|
||||
state.last_synced_at = datetime.utcnow()
|
||||
return True
|
||||
|
||||
|
||||
async def _push_all(client: CloudApiClient, db: Session, master_key: bytes, report: SyncReport) -> None:
|
||||
local = collect_local_objects(db)
|
||||
local_ids = {(o.kind, o.client_id) for o in local}
|
||||
|
||||
for obj in local:
|
||||
if await _push_object(client, db, master_key, obj, _get_state(db, obj.kind, obj.client_id)):
|
||||
report.pushed += 1
|
||||
db.commit()
|
||||
|
||||
# Local deletions: state rows whose entity no longer exists → tombstone.
|
||||
for state in db.query(CloudSyncState).all():
|
||||
if (state.kind, state.client_id) not in local_ids and state.kind != "settings":
|
||||
if state.server_object_id:
|
||||
await client.delete_object(state.server_object_id)
|
||||
db.delete(state)
|
||||
report.pushed_deletes += 1
|
||||
db.commit()
|
||||
|
||||
|
||||
async def _pull_changes(client: CloudApiClient, db: Session, master_key: bytes, report: SyncReport) -> None:
|
||||
settings = _settings_row(db)
|
||||
cursor = settings.sync_cursor or 0
|
||||
|
||||
while True:
|
||||
page = await client.get_changes(since=cursor)
|
||||
for change in page["changes"]:
|
||||
kind, client_id = change["kind"], change["clientId"]
|
||||
state = _get_state(db, kind, client_id)
|
||||
|
||||
if change["deleted"]:
|
||||
if state is not None:
|
||||
_delete_local(db, kind, client_id)
|
||||
db.delete(state)
|
||||
report.pulled_deletes += 1
|
||||
# Our own pushes echo back through the feed; the stored ciphertext
|
||||
# hash identifies them as already applied.
|
||||
elif change["record"] and (state is None or state.record_hash != change["record"]["hash"]):
|
||||
await _apply_change(client, db, master_key, change, state)
|
||||
report.pulled += 1
|
||||
|
||||
cursor = change["seq"]
|
||||
|
||||
settings.sync_cursor = cursor
|
||||
db.commit()
|
||||
report.cursor = cursor
|
||||
if not page["hasMore"]:
|
||||
break
|
||||
|
||||
|
||||
async def _apply_change(
|
||||
client: CloudApiClient,
|
||||
db: Session,
|
||||
master_key: bytes,
|
||||
change: dict,
|
||||
state: CloudSyncState | None,
|
||||
) -> None:
|
||||
kind, client_id = change["kind"], change["clientId"]
|
||||
record_cipher = await client.download_blob(change["record"]["url"])
|
||||
record = json.loads(
|
||||
cloud_crypto.decrypt_blob(
|
||||
record_cipher, master_key, object_id=client_id, role="record", version=change["version"]
|
||||
)
|
||||
)
|
||||
|
||||
known_assets: dict = json.loads(state.assets_json) if state else {}
|
||||
blobs: dict[str, bytes] = {}
|
||||
next_assets: dict[str, dict] = {}
|
||||
for asset in change["assets"]:
|
||||
caid = asset["clientAssetId"]
|
||||
known = known_assets.get(caid)
|
||||
if known and known["hash"] == asset["hash"]:
|
||||
next_assets[caid] = known
|
||||
continue # ciphertext we already hold locally
|
||||
if not asset["url"]:
|
||||
continue # declared but never uploaded; skip until it lands
|
||||
cipher = await client.download_blob(asset["url"])
|
||||
plain = cloud_crypto.decrypt_blob(
|
||||
cipher, master_key, object_id=client_id, role=f"asset:{caid}", version=_ASSET_AAD_VERSION
|
||||
)
|
||||
blobs[caid] = plain
|
||||
next_assets[caid] = {
|
||||
"role": asset["role"],
|
||||
"fingerprint": _fingerprint(plain),
|
||||
"hash": asset["hash"],
|
||||
"size": asset["size"],
|
||||
}
|
||||
|
||||
_apply_record(db, kind, client_id, record, blobs)
|
||||
|
||||
if state is None:
|
||||
state = CloudSyncState(kind=kind, client_id=client_id)
|
||||
db.add(state)
|
||||
state.server_object_id = change["id"]
|
||||
state.version = change["version"]
|
||||
state.record_fingerprint = _fingerprint(_canonical(record))
|
||||
state.record_hash = change["record"]["hash"]
|
||||
state.record_size = change["record"]["size"]
|
||||
state.assets_json = json.dumps(next_assets)
|
||||
state.last_synced_at = datetime.utcnow()
|
||||
|
||||
|
||||
async def run_sync(db: Session) -> SyncReport:
|
||||
"""One full sync: push local changes, then pull and apply remote ones."""
|
||||
settings = _settings_row(db)
|
||||
master_key = cloud_account.load_master_key(db)
|
||||
report = SyncReport()
|
||||
|
||||
async with CloudApiClient(config.get_cloud_api_url(), settings.api_key) as client:
|
||||
await _push_all(client, db, master_key, report)
|
||||
await _pull_changes(client, db, master_key, report)
|
||||
|
||||
logger.info(
|
||||
"cloud sync: pushed %d (+%d deletes), pulled %d (+%d deletes), cursor %d",
|
||||
report.pushed,
|
||||
report.pushed_deletes,
|
||||
report.pulled,
|
||||
report.pulled_deletes,
|
||||
report.cursor,
|
||||
)
|
||||
return report
|
||||
@@ -1,188 +0,0 @@
|
||||
"""An in-process fake of the voicebox-cloud API for tests.
|
||||
|
||||
Implements the surface the desktop sync client uses — devices, the account-key
|
||||
escrow, the encrypted object store, the sync feed, and blob storage — behind an
|
||||
httpx.MockTransport, mirroring apps/api in the voicebox-cloud repo. Every
|
||||
request body is kept in ``seen_bodies`` so tests can assert what the server was
|
||||
shown (never key material, never plaintext).
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
STORAGE_HOST = "http://cloud.test/__storage/"
|
||||
|
||||
|
||||
class FakeCloud:
|
||||
def __init__(self):
|
||||
self.devices: dict[str, dict] = {}
|
||||
self.account_key: dict | None = None
|
||||
self.objects: dict[str, dict] = {} # objectId -> row (incl. assets dict)
|
||||
self.storage: dict[str, bytes] = {} # key -> ciphertext
|
||||
self.seen_bodies: list[bytes] = []
|
||||
self._next_device = 0
|
||||
self._next_object = 0
|
||||
self._seq = 0
|
||||
|
||||
def transport(self) -> httpx.MockTransport:
|
||||
return httpx.MockTransport(self.handle)
|
||||
|
||||
# -- helpers --------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _ok(data, status=200):
|
||||
return httpx.Response(status, json={"ok": True, "data": data})
|
||||
|
||||
def _seq_next(self) -> int:
|
||||
self._seq += 1
|
||||
return self._seq
|
||||
|
||||
def _find_object(self, kind: str, client_id: str) -> dict | None:
|
||||
return next((o for o in self.objects.values() if o["kind"] == kind and o["clientId"] == client_id), None)
|
||||
|
||||
# -- request routing --------------------------------------------------------
|
||||
|
||||
def handle(self, request: httpx.Request) -> httpx.Response:
|
||||
if request.content:
|
||||
self.seen_bodies.append(request.content)
|
||||
path, method = request.url.path, request.method
|
||||
|
||||
if path.startswith("/__storage/"):
|
||||
key = path[len("/__storage/") :]
|
||||
if method == "PUT":
|
||||
self.storage[key] = request.content
|
||||
return httpx.Response(200)
|
||||
data = self.storage.get(key)
|
||||
return httpx.Response(200, content=data) if data is not None else httpx.Response(404)
|
||||
|
||||
if path == "/v1/devices" and method == "POST":
|
||||
return self._register_device(json.loads(request.content))
|
||||
if path == "/v1/devices" and method == "GET":
|
||||
return self._ok(list(self.devices.values()))
|
||||
if path == "/v1/devices/account-key" and method == "PUT":
|
||||
self.account_key = json.loads(request.content)
|
||||
return self._ok(None)
|
||||
if path == "/v1/devices/account-key" and method == "GET":
|
||||
return self._ok(self.account_key)
|
||||
if path.startswith("/v1/devices/") and path.endswith("/wrapped-key"):
|
||||
device_id = path.split("/")[3]
|
||||
if method == "POST":
|
||||
self.devices[device_id]["wrappedMasterKey"] = json.loads(request.content)["wrappedMasterKey"]
|
||||
return self._ok(None)
|
||||
return self._ok({"wrappedMasterKey": self.devices[device_id]["wrappedMasterKey"]})
|
||||
|
||||
if path == "/v1/objects" and method == "POST":
|
||||
return self._upsert_object(json.loads(request.content))
|
||||
if path.startswith("/v1/objects/") and path.endswith("/commit"):
|
||||
return self._commit(path.split("/")[3])
|
||||
if path.startswith("/v1/objects/") and method == "DELETE":
|
||||
obj = self.objects.get(path.split("/")[3])
|
||||
if obj:
|
||||
obj["deleted"] = True
|
||||
obj["seq"] = self._seq_next()
|
||||
return self._ok(None)
|
||||
|
||||
if path == "/v1/sync/changes" and method == "GET":
|
||||
return self._changes(request.url.params)
|
||||
|
||||
return httpx.Response(404, json={"ok": False, "error": {"message": f"unhandled {method} {path}"}})
|
||||
|
||||
# -- endpoint implementations ----------------------------------------------
|
||||
|
||||
def _register_device(self, body: dict) -> httpx.Response:
|
||||
self._next_device += 1
|
||||
device_id = f"dev-{self._next_device}"
|
||||
self.devices[device_id] = {
|
||||
"id": device_id,
|
||||
"name": body["name"],
|
||||
"publicKey": body["publicKey"],
|
||||
"wrappedMasterKey": None,
|
||||
"revokedAt": None,
|
||||
}
|
||||
return self._ok({"deviceId": device_id, "accountHasKey": self.account_key is not None}, 201)
|
||||
|
||||
def _upsert_object(self, body: dict) -> httpx.Response:
|
||||
obj = self._find_object(body["kind"], body["clientId"])
|
||||
if obj is None:
|
||||
self._next_object += 1
|
||||
obj = {
|
||||
"id": f"obj-{self._next_object}",
|
||||
"kind": body["kind"],
|
||||
"clientId": body["clientId"],
|
||||
"version": 0,
|
||||
"deleted": False,
|
||||
"record": None,
|
||||
"assets": {},
|
||||
}
|
||||
self.objects[obj["id"]] = obj
|
||||
|
||||
obj["version"] = max(obj["version"], body["version"])
|
||||
obj["seq"] = self._seq_next()
|
||||
obj["deleted"] = False
|
||||
|
||||
uploads = []
|
||||
record = body.get("record")
|
||||
if record and (obj["record"] is None or obj["record"]["hash"] != record["hash"]):
|
||||
key = f"o/{obj['id']}/record"
|
||||
obj["record"] = {**record, "key": key}
|
||||
uploads.append({"for": "record", "key": key, "url": STORAGE_HOST + key})
|
||||
for asset in body.get("assets", []):
|
||||
caid = asset["clientAssetId"]
|
||||
existing = obj["assets"].get(caid)
|
||||
if existing is None or existing["hash"] != asset["hash"]:
|
||||
key = f"o/{obj['id']}/a/{caid}"
|
||||
obj["assets"][caid] = {**asset, "key": key}
|
||||
uploads.append({"for": f"asset:{caid}", "key": key, "url": STORAGE_HOST + key})
|
||||
return self._ok({"objectId": obj["id"], "seq": obj["seq"], "uploads": uploads}, 201)
|
||||
|
||||
def _commit(self, object_id: str) -> httpx.Response:
|
||||
obj = self.objects.get(object_id)
|
||||
if obj is None:
|
||||
return httpx.Response(404, json={"ok": False, "error": {"message": "object not found"}})
|
||||
missing = []
|
||||
if obj["record"] and obj["record"]["key"] not in self.storage:
|
||||
missing.append("record")
|
||||
for caid, asset in obj["assets"].items():
|
||||
if asset["key"] not in self.storage:
|
||||
missing.append(f"asset:{caid}")
|
||||
if missing:
|
||||
return httpx.Response(409, json={"ok": False, "error": {"message": f"uploads missing: {missing}"}})
|
||||
return self._ok({"objectId": object_id, "committed": True})
|
||||
|
||||
def _changes(self, params) -> httpx.Response:
|
||||
since = int(params.get("since", 0))
|
||||
limit = int(params.get("limit", 200))
|
||||
rows = sorted((o for o in self.objects.values() if o["seq"] > since), key=lambda o: o["seq"])[:limit]
|
||||
changes = [
|
||||
{
|
||||
"id": o["id"],
|
||||
"kind": o["kind"],
|
||||
"clientId": o["clientId"],
|
||||
"version": o["version"],
|
||||
"seq": o["seq"],
|
||||
"deleted": o["deleted"],
|
||||
"record": (
|
||||
{
|
||||
"hash": o["record"]["hash"],
|
||||
"size": o["record"]["size"],
|
||||
"url": STORAGE_HOST + o["record"]["key"],
|
||||
}
|
||||
if o["record"]
|
||||
else None
|
||||
),
|
||||
"assets": [
|
||||
{
|
||||
"clientAssetId": caid,
|
||||
"role": a["role"],
|
||||
"hash": a["hash"],
|
||||
"size": a["size"],
|
||||
"url": STORAGE_HOST + a["key"] if a["key"] in self.storage else None,
|
||||
}
|
||||
for caid, a in o["assets"].items()
|
||||
],
|
||||
}
|
||||
for o in rows
|
||||
]
|
||||
cursor = rows[-1]["seq"] if rows else since
|
||||
return self._ok({"changes": changes, "cursor": cursor, "hasMore": len(rows) == limit})
|
||||
@@ -1,174 +0,0 @@
|
||||
"""Tests for the cloud sync identity flows (services/cloud_account.py).
|
||||
|
||||
Runs the real flows against a fake in-process cloud (httpx.MockTransport) and
|
||||
a fake in-memory keyring — no network, no OS keychain. The central assertion:
|
||||
the master key and recovery phrase never appear in anything sent to the server.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from datetime import datetime
|
||||
|
||||
import keyring
|
||||
import keyring.backend
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from backend.database.models import Base, CloudSettings
|
||||
from backend.services import cloud_account, cloud_crypto, cloud_keys
|
||||
from backend.services.cloud_account import CloudAccountError
|
||||
from backend.services.cloud_api import CloudApiClient
|
||||
from backend.tests.fake_cloud import FakeCloud
|
||||
|
||||
USER_A = "user-a"
|
||||
|
||||
|
||||
class InMemoryKeyring(keyring.backend.KeyringBackend):
|
||||
priority = 1
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.store: dict[tuple[str, str], str] = {}
|
||||
|
||||
def get_password(self, service, username):
|
||||
return self.store.get((service, username))
|
||||
|
||||
def set_password(self, service, username, password):
|
||||
self.store[(service, username)] = password
|
||||
|
||||
def delete_password(self, service, username):
|
||||
self.store.pop((service, username), None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_keyring(monkeypatch):
|
||||
backend = InMemoryKeyring()
|
||||
monkeypatch.setattr(keyring, "get_password", backend.get_password)
|
||||
monkeypatch.setattr(keyring, "set_password", backend.set_password)
|
||||
monkeypatch.setattr(keyring, "delete_password", backend.delete_password)
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cloud():
|
||||
return FakeCloud()
|
||||
|
||||
|
||||
def make_db(account_user_id=USER_A):
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
db = sessionmaker(bind=engine)()
|
||||
db.add(
|
||||
CloudSettings(
|
||||
id=1,
|
||||
api_key="voicebox_test",
|
||||
device_name="Test Mac",
|
||||
account_user_id=account_user_id,
|
||||
connected_at=datetime(2026, 7, 1),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_client(monkeypatch, cloud):
|
||||
def _client(row):
|
||||
return CloudApiClient("http://cloud.test", row.api_key, transport=cloud.transport())
|
||||
|
||||
monkeypatch.setattr(cloud_account, "_client", _client)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("patched_client", "fake_keyring")
|
||||
class TestIdentityFlows:
|
||||
async def test_first_device_setup(self, cloud):
|
||||
db = make_db()
|
||||
phrase = await cloud_account.setup_device(db)
|
||||
|
||||
assert phrase is not None
|
||||
assert cloud_crypto.validate_recovery_phrase(phrase)
|
||||
assert cloud_account.identity_status(db).status == "ready"
|
||||
assert cloud.account_key is not None
|
||||
# Registered + provisioned to itself.
|
||||
(device,) = cloud.devices.values()
|
||||
assert device["wrappedMasterKey"]
|
||||
|
||||
# The invariant: neither MK nor the phrase ever crossed the wire.
|
||||
mk = cloud_account.load_master_key(db)
|
||||
for body in cloud.seen_bodies:
|
||||
assert mk not in body
|
||||
assert base64.b64encode(mk) not in body
|
||||
assert phrase.encode() not in body
|
||||
|
||||
async def test_second_device_via_provisioning(self, cloud):
|
||||
db_a = make_db()
|
||||
await cloud_account.setup_device(db_a)
|
||||
mk_a = cloud_account.load_master_key(db_a)
|
||||
|
||||
# Second install: same account, its own DB + keychain namespace. Reuse
|
||||
# the same fake keyring but a distinct account row would collide, so
|
||||
# simulate the second device with a separate account_user_id-scoped
|
||||
# keychain by clearing MK after capturing device state.
|
||||
db_b = make_db(account_user_id="user-a-second-install")
|
||||
assert await cloud_account.setup_device(db_b) is None # account already has key material
|
||||
assert cloud_account.identity_status(db_b).status == "awaiting_provision"
|
||||
assert await cloud_account.adopt_wrapped_key(db_b) is False # nothing provisioned yet
|
||||
|
||||
target_id = db_b.query(CloudSettings).one().sync_device_id
|
||||
await cloud_account.provision_device(db_a, target_id)
|
||||
assert await cloud_account.adopt_wrapped_key(db_b) is True
|
||||
assert cloud_account.load_master_key(db_b) == mk_a
|
||||
|
||||
async def test_restore_with_phrase(self, cloud):
|
||||
db_a = make_db()
|
||||
phrase = await cloud_account.setup_device(db_a)
|
||||
mk_a = cloud_account.load_master_key(db_a)
|
||||
|
||||
db_b = make_db(account_user_id="user-a-fresh-machine")
|
||||
assert await cloud_account.setup_device(db_b) is None
|
||||
await cloud_account.restore_with_phrase(db_b, phrase)
|
||||
assert cloud_account.load_master_key(db_b) == mk_a
|
||||
assert cloud_account.identity_status(db_b).status == "ready"
|
||||
|
||||
async def test_restore_rejects_wrong_phrase(self, cloud):
|
||||
db_a = make_db()
|
||||
await cloud_account.setup_device(db_a)
|
||||
|
||||
db_b = make_db(account_user_id="user-a-fresh-machine")
|
||||
await cloud_account.setup_device(db_b)
|
||||
with pytest.raises(cloud_crypto.CloudCryptoError):
|
||||
await cloud_account.restore_with_phrase(db_b, cloud_crypto.generate_recovery_phrase())
|
||||
|
||||
async def test_restore_rejects_invalid_phrase_early(self, cloud):
|
||||
db = make_db()
|
||||
await cloud_account.setup_device(db)
|
||||
with pytest.raises(CloudAccountError, match="valid recovery phrase"):
|
||||
await cloud_account.restore_with_phrase(
|
||||
db, "not a real phrase at all twelve words missing checksum here ok"
|
||||
)
|
||||
|
||||
async def test_double_registration_rejected(self, cloud):
|
||||
db = make_db()
|
||||
await cloud_account.setup_device(db)
|
||||
with pytest.raises(CloudAccountError, match="already registered"):
|
||||
await cloud_account.setup_device(db)
|
||||
|
||||
async def test_requires_login(self, cloud):
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
db = sessionmaker(bind=engine)()
|
||||
with pytest.raises(CloudAccountError, match="log in"):
|
||||
await cloud_account.setup_device(db)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("fake_keyring")
|
||||
class TestKeyStore:
|
||||
def test_round_trip_and_clear(self):
|
||||
cloud_keys.store_secret(USER_A, cloud_keys.MASTER_KEY, b"\x01" * 32)
|
||||
assert cloud_keys.load_secret(USER_A, cloud_keys.MASTER_KEY) == b"\x01" * 32
|
||||
assert cloud_keys.load_secret("other-user", cloud_keys.MASTER_KEY) is None
|
||||
cloud_keys.clear(USER_A)
|
||||
assert cloud_keys.load_secret(USER_A, cloud_keys.MASTER_KEY) is None
|
||||
|
||||
def test_delete_absent_is_noop(self):
|
||||
cloud_keys.delete_secret(USER_A, cloud_keys.DEVICE_PRIVATE_KEY)
|
||||
@@ -1,137 +0,0 @@
|
||||
"""Tests for the cloud E2E crypto primitives (services/cloud_crypto.py)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services.cloud_crypto import (
|
||||
ALG_XCHACHA20_POLY1305,
|
||||
ENVELOPE_MAGIC,
|
||||
CloudCryptoError,
|
||||
RecoveryWrap,
|
||||
decrypt_blob,
|
||||
encrypt_blob,
|
||||
generate_device_keypair,
|
||||
generate_master_key,
|
||||
generate_recovery_phrase,
|
||||
unwrap_master_key_for_device,
|
||||
unwrap_master_key_with_phrase,
|
||||
validate_recovery_phrase,
|
||||
wrap_master_key_for_device,
|
||||
wrap_master_key_with_phrase,
|
||||
)
|
||||
|
||||
SLOT = {"object_id": "0c8f6f4e-9f5a-4a2f-8f6a-1d2e3f4a5b6c", "role": "audio", "version": 3}
|
||||
|
||||
|
||||
class TestMasterKey:
|
||||
def test_master_keys_are_random_32_bytes(self):
|
||||
a, b = generate_master_key(), generate_master_key()
|
||||
assert len(a) == 32
|
||||
assert a != b
|
||||
|
||||
|
||||
class TestDeviceWrap:
|
||||
def test_round_trip(self):
|
||||
mk = generate_master_key()
|
||||
private, public = generate_device_keypair()
|
||||
assert unwrap_master_key_for_device(wrap_master_key_for_device(mk, public), private) == mk
|
||||
|
||||
def test_wrong_device_key_fails(self):
|
||||
mk = generate_master_key()
|
||||
_, public = generate_device_keypair()
|
||||
other_private, _ = generate_device_keypair()
|
||||
with pytest.raises(CloudCryptoError):
|
||||
unwrap_master_key_for_device(wrap_master_key_for_device(mk, public), other_private)
|
||||
|
||||
|
||||
class TestRecoveryPhrase:
|
||||
def test_phrase_is_valid_bip39(self):
|
||||
phrase = generate_recovery_phrase()
|
||||
assert len(phrase.split()) == 12
|
||||
assert validate_recovery_phrase(phrase)
|
||||
|
||||
def test_typo_fails_checksum(self):
|
||||
words = generate_recovery_phrase().split()
|
||||
words[0] = "abandon" if words[0] != "abandon" else "ability"
|
||||
assert not validate_recovery_phrase(" ".join(words))
|
||||
|
||||
def test_round_trip(self):
|
||||
mk = generate_master_key()
|
||||
phrase = generate_recovery_phrase()
|
||||
assert unwrap_master_key_with_phrase(wrap_master_key_with_phrase(mk, phrase), phrase) == mk
|
||||
|
||||
def test_normalization_tolerates_case_and_whitespace(self):
|
||||
mk = generate_master_key()
|
||||
phrase = generate_recovery_phrase()
|
||||
wrap = wrap_master_key_with_phrase(mk, phrase)
|
||||
sloppy = f" {phrase.upper().replace(' ', ' ')} \n"
|
||||
assert unwrap_master_key_with_phrase(wrap, sloppy) == mk
|
||||
|
||||
def test_wrong_phrase_fails(self):
|
||||
wrap = wrap_master_key_with_phrase(generate_master_key(), generate_recovery_phrase())
|
||||
with pytest.raises(CloudCryptoError):
|
||||
unwrap_master_key_with_phrase(wrap, generate_recovery_phrase())
|
||||
|
||||
def test_malformed_kdf_params_fail(self):
|
||||
wrap = wrap_master_key_with_phrase(generate_master_key(), generate_recovery_phrase())
|
||||
broken = RecoveryWrap(wrapped_key=wrap.wrapped_key, kdf_salt=wrap.kdf_salt, kdf_params="not json")
|
||||
with pytest.raises(CloudCryptoError):
|
||||
unwrap_master_key_with_phrase(broken, generate_recovery_phrase())
|
||||
|
||||
|
||||
class TestEnvelope:
|
||||
def test_round_trip(self):
|
||||
mk = generate_master_key()
|
||||
plaintext = b"capture transcript \xf0\x9f\x8e\x99 and some audio bytes" * 100
|
||||
envelope = encrypt_blob(plaintext, mk, **SLOT)
|
||||
assert envelope[:4] == ENVELOPE_MAGIC
|
||||
assert envelope[4] == ALG_XCHACHA20_POLY1305
|
||||
assert decrypt_blob(envelope, mk, **SLOT) == plaintext
|
||||
|
||||
def test_fresh_content_key_per_blob(self):
|
||||
mk = generate_master_key()
|
||||
assert encrypt_blob(b"same", mk, **SLOT) != encrypt_blob(b"same", mk, **SLOT)
|
||||
|
||||
def test_wrong_master_key_fails(self):
|
||||
envelope = encrypt_blob(b"secret", generate_master_key(), **SLOT)
|
||||
with pytest.raises(CloudCryptoError):
|
||||
decrypt_blob(envelope, generate_master_key(), **SLOT)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"slot",
|
||||
[
|
||||
{**SLOT, "object_id": "11111111-2222-3333-4444-555555555555"},
|
||||
{**SLOT, "role": "avatar"},
|
||||
{**SLOT, "version": 4},
|
||||
],
|
||||
)
|
||||
def test_wrong_slot_fails_aad(self, slot):
|
||||
mk = generate_master_key()
|
||||
envelope = encrypt_blob(b"secret", mk, **SLOT)
|
||||
with pytest.raises(CloudCryptoError):
|
||||
decrypt_blob(envelope, mk, **slot)
|
||||
|
||||
def test_tampered_ciphertext_fails(self):
|
||||
mk = generate_master_key()
|
||||
envelope = bytearray(encrypt_blob(b"secret", mk, **SLOT))
|
||||
envelope[-1] ^= 0x01
|
||||
with pytest.raises(CloudCryptoError):
|
||||
decrypt_blob(bytes(envelope), mk, **SLOT)
|
||||
|
||||
def test_bad_magic_and_truncation_fail(self):
|
||||
mk = generate_master_key()
|
||||
envelope = encrypt_blob(b"secret", mk, **SLOT)
|
||||
with pytest.raises(CloudCryptoError):
|
||||
decrypt_blob(b"NOPE" + envelope[4:], mk, **SLOT)
|
||||
with pytest.raises(CloudCryptoError):
|
||||
decrypt_blob(envelope[:20], mk, **SLOT)
|
||||
|
||||
def test_unknown_algorithm_fails(self):
|
||||
mk = generate_master_key()
|
||||
envelope = bytearray(encrypt_blob(b"secret", mk, **SLOT))
|
||||
envelope[4] = 99
|
||||
with pytest.raises(CloudCryptoError):
|
||||
decrypt_blob(bytes(envelope), mk, **SLOT)
|
||||
|
||||
def test_empty_plaintext_round_trips(self):
|
||||
mk = generate_master_key()
|
||||
assert decrypt_blob(encrypt_blob(b"", mk, **SLOT), mk, **SLOT) == b""
|
||||
@@ -1,104 +0,0 @@
|
||||
"""End-to-end round-trip against a real voicebox-cloud dev server.
|
||||
|
||||
Skipped unless VOICEBOX_CLOUD_TEST_API + VOICEBOX_CLOUD_TEST_KEY are set:
|
||||
|
||||
cd voicebox-cloud && pnpm dev:db && pnpm db:migrate && pnpm dev:api
|
||||
# create an account + API key (web app or seed script), then:
|
||||
VOICEBOX_CLOUD_TEST_API=http://localhost:17593 \\
|
||||
VOICEBOX_CLOUD_TEST_KEY=voicebox_… \\
|
||||
pytest backend/tests/test_cloud_roundtrip_integration.py -v
|
||||
|
||||
Exercises the scaffolded server for real: device registration, recovery
|
||||
escrow, encrypted push (presigned PUT + commit), sync pull, decrypt — and
|
||||
verifies the ciphertext at rest is opaque.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services import cloud_crypto
|
||||
from backend.services.cloud_api import CloudApiClient
|
||||
|
||||
API_URL = os.environ.get("VOICEBOX_CLOUD_TEST_API")
|
||||
API_KEY = os.environ.get("VOICEBOX_CLOUD_TEST_KEY")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (API_URL and API_KEY),
|
||||
reason="set VOICEBOX_CLOUD_TEST_API and VOICEBOX_CLOUD_TEST_KEY to run against a dev server",
|
||||
)
|
||||
|
||||
|
||||
async def test_full_roundtrip():
|
||||
master_key = cloud_crypto.generate_master_key()
|
||||
client_id = str(uuid.uuid4())
|
||||
record_plain = json.dumps({"transcript_raw": "hello from the integration test", "language": "en"}).encode()
|
||||
audio_plain = os.urandom(64_000) # stands in for capture audio
|
||||
|
||||
async with CloudApiClient(API_URL, API_KEY) as client:
|
||||
# Device + escrow round-trip.
|
||||
private_key, public_key = cloud_crypto.generate_device_keypair()
|
||||
registered = await client.register_device("integration-test", base64.b64encode(public_key).decode())
|
||||
device_id = registered["deviceId"]
|
||||
wrapped = cloud_crypto.wrap_master_key_for_device(master_key, public_key)
|
||||
await client.put_wrapped_key(device_id, base64.b64encode(wrapped).decode())
|
||||
fetched = await client.get_wrapped_key(device_id)
|
||||
assert cloud_crypto.unwrap_master_key_for_device(base64.b64decode(fetched), private_key) == master_key
|
||||
|
||||
# Push: encrypt locally, upsert metadata, PUT ciphertext, commit.
|
||||
object_id_placeholder = client_id # AAD object binding uses the client id pre-push
|
||||
record_env = cloud_crypto.encrypt_blob(
|
||||
record_plain, master_key, object_id=object_id_placeholder, role="record", version=1
|
||||
)
|
||||
audio_env = cloud_crypto.encrypt_blob(
|
||||
audio_plain, master_key, object_id=object_id_placeholder, role="audio", version=1
|
||||
)
|
||||
pushed = await client.push_object(
|
||||
kind="capture",
|
||||
client_id=client_id,
|
||||
version=1,
|
||||
record={"hash": hashlib.sha256(record_env).hexdigest(), "size": len(record_env)},
|
||||
assets=[
|
||||
{
|
||||
"role": "audio",
|
||||
"clientAssetId": f"{client_id}-audio",
|
||||
"hash": hashlib.sha256(audio_env).hexdigest(),
|
||||
"size": len(audio_env),
|
||||
}
|
||||
],
|
||||
)
|
||||
uploads = {u["for"]: u["url"] for u in pushed["uploads"]}
|
||||
await client.upload_blob(uploads["record"], record_env)
|
||||
await client.upload_blob(uploads[f"asset:{client_id}-audio"], audio_env)
|
||||
await client.commit_object(pushed["objectId"])
|
||||
|
||||
# Pull: cursor 0 must include our object; ciphertext decrypts to the original.
|
||||
changes = await client.get_changes(since=pushed["seq"] - 1, limit=10)
|
||||
change = next(c for c in changes["changes"] if c["clientId"] == client_id)
|
||||
assert change["kind"] == "capture"
|
||||
assert not change["deleted"]
|
||||
|
||||
record_cipher = await client.download_blob(change["record"]["url"])
|
||||
assert record_cipher == record_env # opaque, byte-identical ciphertext at rest
|
||||
assert record_plain not in record_cipher
|
||||
assert (
|
||||
cloud_crypto.decrypt_blob(record_cipher, master_key, object_id=client_id, role="record", version=1)
|
||||
== record_plain
|
||||
)
|
||||
|
||||
(asset,) = change["assets"]
|
||||
audio_cipher = await client.download_blob(asset["url"])
|
||||
assert (
|
||||
cloud_crypto.decrypt_blob(audio_cipher, master_key, object_id=client_id, role="audio", version=1)
|
||||
== audio_plain
|
||||
)
|
||||
|
||||
# Tombstone propagates.
|
||||
await client.delete_object(pushed["objectId"])
|
||||
changes = await client.get_changes(since=changes["cursor"], limit=10)
|
||||
tombstone = next(c for c in changes["changes"] if c["clientId"] == client_id)
|
||||
assert tombstone["deleted"] is True
|
||||
@@ -1,278 +0,0 @@
|
||||
"""Tests for the cloud sync engine (services/cloud_sync.py).
|
||||
|
||||
Simulates two installs ("machines") of the desktop app — each with its own
|
||||
SQLite database, data directory, and keychain — syncing through the in-process
|
||||
FakeCloud. Covers the full backup → restore path, incremental pushes, deletes,
|
||||
and the blindness invariant (nothing plaintext ever lands in server storage).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import keyring
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from backend import config
|
||||
from backend.database.models import (
|
||||
Base,
|
||||
Capture,
|
||||
CaptureSettings,
|
||||
CloudSettings,
|
||||
Generation,
|
||||
GenerationVersion,
|
||||
ProfileSample,
|
||||
VoiceProfile,
|
||||
)
|
||||
from backend.services import cloud_account, cloud_sync
|
||||
from backend.services.cloud_api import CloudApiClient
|
||||
from backend.tests.fake_cloud import FakeCloud
|
||||
|
||||
AUDIO_A = b"RIFFfake-capture-audio" + b"\x11" * 4000
|
||||
AUDIO_B = b"RIFFfake-generation-audio" + b"\x22" * 4000
|
||||
AUDIO_C = b"RIFFfake-version-audio" + b"\x33" * 4000
|
||||
AUDIO_D = b"RIFFfake-sample-audio" + b"\x44" * 4000
|
||||
AVATAR = b"\x89PNGfake-avatar" + b"\x55" * 500
|
||||
|
||||
|
||||
class Install:
|
||||
"""One simulated machine: its own DB, data dir, and keychain store."""
|
||||
|
||||
def __init__(self, root, name: str):
|
||||
self.data_dir = root / name
|
||||
self.data_dir.mkdir()
|
||||
self.keychain: dict[tuple[str, str], str] = {}
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
self.db = sessionmaker(bind=engine)()
|
||||
self.db.add(
|
||||
CloudSettings(
|
||||
id=1,
|
||||
api_key=f"voicebox_{name}",
|
||||
device_name=name,
|
||||
account_user_id="user-1",
|
||||
connected_at=datetime(2026, 7, 1),
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def activate(self, monkeypatch):
|
||||
"""Point global config + keychain at this machine."""
|
||||
config.set_data_dir(self.data_dir)
|
||||
store = self.keychain
|
||||
monkeypatch.setattr(keyring, "get_password", lambda s, u: store.get((s, u)))
|
||||
monkeypatch.setattr(keyring, "set_password", lambda s, u, p: store.__setitem__((s, u), p))
|
||||
monkeypatch.setattr(keyring, "delete_password", lambda s, u: store.pop((s, u), None))
|
||||
|
||||
def write_file(self, relative: str, data: bytes) -> str:
|
||||
path = self.data_dir / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cloud(monkeypatch):
|
||||
fake = FakeCloud()
|
||||
monkeypatch.setattr(
|
||||
cloud_sync,
|
||||
"CloudApiClient",
|
||||
lambda url, key: CloudApiClient(url, key, transport=fake.transport()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cloud_account,
|
||||
"_client",
|
||||
lambda row: CloudApiClient("http://cloud.test", row.api_key, transport=fake.transport()),
|
||||
)
|
||||
return fake
|
||||
|
||||
|
||||
def seed_content(install: Install) -> None:
|
||||
db = install.db
|
||||
profile = VoiceProfile(
|
||||
id="prof-1",
|
||||
name="Morgan",
|
||||
description="test voice",
|
||||
language="en",
|
||||
avatar_path=install.write_file("profiles/prof-1/avatar.png", AVATAR),
|
||||
personality="dry wit",
|
||||
)
|
||||
db.add(profile)
|
||||
db.add(
|
||||
ProfileSample(
|
||||
id="samp-1",
|
||||
profile_id="prof-1",
|
||||
audio_path=install.write_file("profiles/prof-1/samples/samp-1.wav", AUDIO_D),
|
||||
reference_text="hello there",
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
Capture(
|
||||
id="cap-1",
|
||||
audio_path=install.write_file("captures/cap-1.wav", AUDIO_A),
|
||||
source="dictation",
|
||||
language="en",
|
||||
transcript_raw="the raw transcript",
|
||||
transcript_refined="the refined transcript",
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
Generation(
|
||||
id="gen-1",
|
||||
profile_id="prof-1",
|
||||
text="hello world",
|
||||
audio_path=install.write_file("generations/gen-1.wav", AUDIO_B),
|
||||
status="completed",
|
||||
source="manual",
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
GenerationVersion(
|
||||
id="ver-1",
|
||||
generation_id="gen-1",
|
||||
label="Take 2",
|
||||
audio_path=install.write_file("generations/ver-1.wav", AUDIO_C),
|
||||
)
|
||||
)
|
||||
db.add(CaptureSettings(id=1, stt_model="turbo", language="auto"))
|
||||
db.commit()
|
||||
|
||||
|
||||
async def connect(install: Install, monkeypatch, phrase: str | None = None) -> str | None:
|
||||
install.activate(monkeypatch)
|
||||
result = await cloud_account.setup_device(install.db)
|
||||
if phrase is not None:
|
||||
await cloud_account.restore_with_phrase(install.db, phrase)
|
||||
return result
|
||||
|
||||
|
||||
class TestSyncEngine:
|
||||
async def test_backup_then_restore_on_second_machine(self, cloud, tmp_path, monkeypatch):
|
||||
a = Install(tmp_path, "machine-a")
|
||||
phrase = await connect(a, monkeypatch)
|
||||
seed_content(a)
|
||||
|
||||
report = await cloud_sync.run_sync(a.db)
|
||||
assert report.pushed == 4 # capture, generation, profile, capture_settings
|
||||
assert report.pulled == 0 # own echoes are recognized by ciphertext hash
|
||||
|
||||
# Server blindness: every stored blob is a VBX1 envelope, no plaintext.
|
||||
assert cloud.storage
|
||||
for blob in cloud.storage.values():
|
||||
assert blob[:4] == b"VBX1"
|
||||
assert b"transcript" not in blob
|
||||
assert AUDIO_A not in blob
|
||||
for body in cloud.seen_bodies:
|
||||
assert b"the raw transcript" not in body
|
||||
assert b"Morgan" not in body
|
||||
|
||||
# Fresh machine: restore identity from phrase, then pull everything.
|
||||
b = Install(tmp_path, "machine-b")
|
||||
await connect(b, monkeypatch, phrase=phrase)
|
||||
report_b = await cloud_sync.run_sync(b.db)
|
||||
assert report_b.pulled == report.pushed
|
||||
|
||||
cap = b.db.query(Capture).one()
|
||||
assert cap.id == "cap-1"
|
||||
assert cap.transcript_raw == "the raw transcript"
|
||||
assert (b.data_dir / "captures/cap-1.wav").read_bytes() == AUDIO_A
|
||||
|
||||
prof = b.db.query(VoiceProfile).one()
|
||||
assert prof.name == "Morgan"
|
||||
assert prof.personality == "dry wit"
|
||||
assert (b.data_dir / "profiles/prof-1/avatar.png").read_bytes() == AVATAR
|
||||
samp = b.db.query(ProfileSample).one()
|
||||
assert samp.reference_text == "hello there"
|
||||
assert (b.data_dir / "profiles/prof-1/samples/samp-1.wav").read_bytes() == AUDIO_D
|
||||
|
||||
gen = b.db.query(Generation).one()
|
||||
assert gen.text == "hello world"
|
||||
assert (b.data_dir / "generations/gen-1.wav").read_bytes() == AUDIO_B
|
||||
ver = b.db.query(GenerationVersion).one()
|
||||
assert ver.label == "Take 2"
|
||||
assert (b.data_dir / "generations/ver-1.wav").read_bytes() == AUDIO_C
|
||||
|
||||
settings = b.db.query(CaptureSettings).one()
|
||||
assert settings.stt_model == "turbo"
|
||||
|
||||
# Second sync on B is a no-op in both directions.
|
||||
report_b2 = await cloud_sync.run_sync(b.db)
|
||||
assert (report_b2.pushed, report_b2.pulled) == (0, 0)
|
||||
|
||||
async def test_incremental_edit_propagates(self, cloud, tmp_path, monkeypatch):
|
||||
a = Install(tmp_path, "machine-a")
|
||||
phrase = await connect(a, monkeypatch)
|
||||
seed_content(a)
|
||||
await cloud_sync.run_sync(a.db)
|
||||
|
||||
b = Install(tmp_path, "machine-b")
|
||||
await connect(b, monkeypatch, phrase=phrase)
|
||||
await cloud_sync.run_sync(b.db)
|
||||
|
||||
# Edit on A: only the capture should push, and only its record blob
|
||||
# should re-upload (the audio is unchanged).
|
||||
a.activate(monkeypatch)
|
||||
blobs_before = dict(cloud.storage)
|
||||
cap = a.db.query(Capture).one()
|
||||
cap.transcript_refined = "edited on machine A"
|
||||
a.db.commit()
|
||||
report_a = await cloud_sync.run_sync(a.db)
|
||||
assert report_a.pushed == 1
|
||||
changed_keys = [k for k, v in cloud.storage.items() if blobs_before.get(k) != v]
|
||||
assert changed_keys == [k for k in changed_keys if k.endswith("/record")]
|
||||
|
||||
b.activate(monkeypatch)
|
||||
report_b = await cloud_sync.run_sync(b.db)
|
||||
assert report_b.pulled == 1
|
||||
assert b.db.query(Capture).one().transcript_refined == "edited on machine A"
|
||||
|
||||
async def test_delete_propagates(self, cloud, tmp_path, monkeypatch):
|
||||
a = Install(tmp_path, "machine-a")
|
||||
phrase = await connect(a, monkeypatch)
|
||||
seed_content(a)
|
||||
await cloud_sync.run_sync(a.db)
|
||||
|
||||
b = Install(tmp_path, "machine-b")
|
||||
await connect(b, monkeypatch, phrase=phrase)
|
||||
await cloud_sync.run_sync(b.db)
|
||||
|
||||
a.activate(monkeypatch)
|
||||
cap = a.db.query(Capture).one()
|
||||
a.db.delete(cap)
|
||||
a.db.commit()
|
||||
report_a = await cloud_sync.run_sync(a.db)
|
||||
assert report_a.pushed_deletes == 1
|
||||
|
||||
b.activate(monkeypatch)
|
||||
report_b = await cloud_sync.run_sync(b.db)
|
||||
assert report_b.pulled_deletes == 1
|
||||
assert b.db.query(Capture).count() == 0
|
||||
|
||||
async def test_last_writer_wins_on_conflict(self, cloud, tmp_path, monkeypatch):
|
||||
a = Install(tmp_path, "machine-a")
|
||||
phrase = await connect(a, monkeypatch)
|
||||
seed_content(a)
|
||||
await cloud_sync.run_sync(a.db)
|
||||
|
||||
b = Install(tmp_path, "machine-b")
|
||||
await connect(b, monkeypatch, phrase=phrase)
|
||||
await cloud_sync.run_sync(b.db)
|
||||
|
||||
# Concurrent edits to the same capture on both machines.
|
||||
a.activate(monkeypatch)
|
||||
a.db.query(Capture).one().transcript_refined = "A's edit"
|
||||
a.db.commit()
|
||||
await cloud_sync.run_sync(a.db)
|
||||
|
||||
b.activate(monkeypatch)
|
||||
b.db.query(Capture).one().transcript_refined = "B's edit"
|
||||
b.db.commit()
|
||||
await cloud_sync.run_sync(b.db) # B pushes after A: B is the last writer
|
||||
|
||||
a.activate(monkeypatch)
|
||||
await cloud_sync.run_sync(a.db)
|
||||
assert a.db.query(Capture).one().transcript_refined == "B's edit"
|
||||
|
||||
b.activate(monkeypatch)
|
||||
await cloud_sync.run_sync(b.db)
|
||||
assert b.db.query(Capture).one().transcript_refined == "B's edit"
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"app": {
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.5.0",
|
||||
"version": "0.4.2",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -57,7 +57,6 @@
|
||||
"react-dom": "^18.3.0",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-sound-visualizer": "^1.4.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"wavesurfer.js": "^7.0.0",
|
||||
@@ -76,7 +75,7 @@
|
||||
},
|
||||
"landing": {
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.5.0",
|
||||
"version": "0.4.2",
|
||||
"dependencies": {
|
||||
"@fontsource/space-grotesk": "^5.2.10",
|
||||
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||
@@ -86,9 +85,7 @@
|
||||
"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",
|
||||
@@ -107,7 +104,7 @@
|
||||
},
|
||||
"tauri": {
|
||||
"name": "@voicebox/tauri",
|
||||
"version": "0.5.0",
|
||||
"version": "0.4.2",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
@@ -130,7 +127,7 @@
|
||||
},
|
||||
"web": {
|
||||
"name": "@voicebox/web",
|
||||
"version": "0.5.0",
|
||||
"version": "0.4.2",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"react": "^18.3.0",
|
||||
@@ -666,7 +663,7 @@
|
||||
|
||||
"arg": ["[email protected]", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-hidden": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
@@ -762,8 +759,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -772,8 +767,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -822,8 +815,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -848,8 +839,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -866,7 +855,7 @@
|
||||
|
||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"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=="],
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
@@ -880,8 +869,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -926,8 +913,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1006,8 +991,6 @@
|
||||
|
||||
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"qr.js": ["[email protected]", "", {}, "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="],
|
||||
|
||||
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"react": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
@@ -1022,8 +1005,6 @@
|
||||
|
||||
"react-loaders": ["[email protected]", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
|
||||
|
||||
"react-qr-code": ["[email protected]", "", { "dependencies": { "prop-types": "^15.8.1", "qr.js": "0.0.0" }, "peerDependencies": { "react": "*" } }, "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg=="],
|
||||
|
||||
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"react-remove-scroll": ["[email protected]", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
@@ -1052,8 +1033,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1072,12 +1051,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1156,8 +1131,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1212,8 +1185,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1224,12 +1195,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
+2
-3
@@ -5,9 +5,8 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
# 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"
|
||||
# Bind to localhost only for security
|
||||
- "127.0.0.1:17493:17493"
|
||||
|
||||
volumes:
|
||||
# Bind-mount for generated audio (customize the host path as needed)
|
||||
|
||||
+59
-181
@@ -1,6 +1,6 @@
|
||||
# Voicebox Project Status & Roadmap
|
||||
|
||||
> Last updated: 2026-06-27 | Current version: **v0.5.0** | 402 open issues | 88 open PRs | 1.3M downloads · 34.8k stars
|
||||
> Last updated: 2026-04-18 | Current version: **v0.4.1** | 232 open issues | 12 open PRs
|
||||
|
||||
---
|
||||
|
||||
@@ -86,46 +86,6 @@ 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:**
|
||||
@@ -271,144 +231,64 @@ Shipped 2026-04-25 (PR #544). Voicebox went from a voice-cloning studio to a ful
|
||||
|
||||
## Open PRs — Triage & Analysis
|
||||
|
||||
**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
|
||||
### Recently Merged (Since Last Update — 2026-03-18 → 2026-04-18)
|
||||
|
||||
| PR | Title | Merged |
|
||||
|----|-------|--------|
|
||||
| **#550** | Fix web API URL for remote access | 2026-04-25 |
|
||||
| **#544** | feat: 0.5.0 Capture release — dictation, MCP, personalities | 2026-04-25 |
|
||||
| **#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 |
|
||||
|
||||
### i18n / translations (~12) — easy wins, unblock a large user segment
|
||||
### Currently Open (12 PRs)
|
||||
|
||||
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.
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -643,21 +523,19 @@ Seven TTS engines shipped, more candidates queued. Issue #419 asks for a first-c
|
||||
|
||||
## Recommended Priorities
|
||||
|
||||
### 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.
|
||||
### Tier 1 — Ship Now
|
||||
|
||||
| Priority | PR/Item | Impact | Effort |
|
||||
|----------|---------|--------|--------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
### Tier 2 — Feature Work
|
||||
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
# 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,9 +17,7 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -1,104 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
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,99 +142,6 @@
|
||||
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,9 +11,8 @@ 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";
|
||||
@@ -132,6 +131,9 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Sponsor promo ────────────────────────────────────────── */}
|
||||
<SponsorPromo />
|
||||
|
||||
{/* ── Features ─────────────────────────────────────────────── */}
|
||||
<Features />
|
||||
|
||||
@@ -156,9 +158,6 @@ 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">
|
||||
@@ -242,9 +241,6 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── $VOICEBOX token (teaser → /token) ─────────────────────── */}
|
||||
<TokenTeaser />
|
||||
|
||||
{/* ── Footer ───────────────────────────────────────────────── */}
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
'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,19 +1,13 @@
|
||||
import { ArrowUpRight, Coffee, Coins } from 'lucide-react';
|
||||
import { Coffee } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { CopyAddress } from '@/components/CopyAddress';
|
||||
import {
|
||||
DONATE_URL,
|
||||
GITHUB_REPO,
|
||||
TOKEN_CONTRACT_ADDRESS,
|
||||
TOKEN_TICKER,
|
||||
} from '@/lib/constants';
|
||||
import { DONATE_URL, GITHUB_REPO } 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-5 gap-8 mb-10">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 mb-10">
|
||||
{/* Brand */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
@@ -70,16 +64,6 @@ 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
|
||||
@@ -92,11 +76,6 @@ 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"
|
||||
@@ -171,26 +150,6 @@ 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, Coins, Github } from 'lucide-react';
|
||||
import { Coffee, Github } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DONATE_URL, GITHUB_REPO, TOKEN_TICKER } from '@/lib/constants';
|
||||
import { DONATE_URL, GITHUB_REPO } 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-[1fr_auto_1fr] sm:gap-x-6">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-3 sm:grid sm:grid-cols-3">
|
||||
{/* 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="/pricing"
|
||||
href="/#api"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Pricing
|
||||
API
|
||||
</a>
|
||||
<a
|
||||
href="/blog"
|
||||
href="/download"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Blog
|
||||
Download
|
||||
</a>
|
||||
<a
|
||||
href="https://docs.voicebox.sh"
|
||||
@@ -96,18 +96,8 @@ export function Navbar() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Token + Donate + GitHub star buttons */}
|
||||
{/* 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"
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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,51 +8,6 @@ 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,
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
// 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.",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
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[] = [
|
||||
];
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
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