mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -07:00
Mobile companion app + paired-device backend
New iOS-first companion (Expo SDK 54 + NativeWind v4) with three tabs: Captures (the hero — floating gold mic, live mic-meter waveform, expand-row playback), Generate (profile picker + speak + autoplay + recent), and Voices (searchable profile list). Pairing (V0): backend mints a one-time token, mobile scans/pastes the voicebox:// URL, server returns a long-lived bearer it stores only as a SHA-256 hash. Bearer-or-loopback auth applied to every user-data router so binding 0.0.0.0 doesn't leak existing endpoints. Loopback callers (the desktop app) keep their friction-free access. Desktop Settings → Mobile pane: live host picker (LAN / Tailscale auto- detected via the App-bundle binary path on macOS), QR rendering, 5-minute expiry countdown, copyable URL fallback, paired-device list with revoke. Auto-closes when a new device pairs. just dev now binds the backend to 0.0.0.0 so paired phones can reach it — and just setup-python pins mlx-audio==0.4.1 + mlx-lm so fresh Apple Silicon worktrees get a working STT path on first install.
This commit is contained in:
@@ -52,6 +52,7 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Lock, MoreHorizontal, Plus, Smartphone, WifiOff } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import {
|
||||
usePairedDevices,
|
||||
useRevokePairedDevice,
|
||||
} from '@/lib/hooks/usePairedDevices';
|
||||
import type { PairedDeviceResponse } from '@/lib/api/types';
|
||||
import { PairDeviceDialog } from './PairDeviceDialog';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
function formatRelative(iso: string | null): string {
|
||||
if (!iso) return 'never';
|
||||
const then = new Date(iso).getTime();
|
||||
const diffSec = Math.floor((Date.now() - then) / 1000);
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
|
||||
if (diffSec < 86400 * 30) return `${Math.floor(diffSec / 86400)}d ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
export function MobilePage() {
|
||||
const [pairOpen, setPairOpen] = useState(false);
|
||||
const devices = usePairedDevices();
|
||||
const revoke = useRevokePairedDevice();
|
||||
const { toast } = useToast();
|
||||
|
||||
const active = (devices.data ?? []).filter((d) => !d.revoked);
|
||||
const revoked = (devices.data ?? []).filter((d) => d.revoked);
|
||||
|
||||
function handleRevoke(d: PairedDeviceResponse) {
|
||||
revoke.mutate(d.id, {
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Device revoked',
|
||||
description: `${d.name} can no longer reach this Voicebox.`,
|
||||
});
|
||||
},
|
||||
onError: (e) => {
|
||||
toast({
|
||||
title: 'Revoke failed',
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-8 items-start max-w-5xl">
|
||||
<div className="flex-1 min-w-0 max-w-2xl space-y-10">
|
||||
<SettingSection
|
||||
title="Mobile"
|
||||
description="Pair your phone to dictate, browse captures, and generate from anywhere on your network."
|
||||
>
|
||||
<SettingRow
|
||||
title="Paired devices"
|
||||
description={
|
||||
active.length === 0
|
||||
? 'No devices yet — pair your phone to get started.'
|
||||
: `${active.length} active${revoked.length > 0 ? `, ${revoked.length} revoked` : ''}`
|
||||
}
|
||||
action={
|
||||
<Button onClick={() => setPairOpen(true)} size="sm" className="gap-1.5">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Pair device
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{devices.data && devices.data.length > 0 ? (
|
||||
<div className="pt-3 space-y-2">
|
||||
{[...active, ...revoked].map((d) => (
|
||||
<DeviceRow
|
||||
key={d.id}
|
||||
device={d}
|
||||
onRevoke={() => handleRevoke(d)}
|
||||
revoking={revoke.isPending && revoke.variables === d.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : devices.isLoading ? (
|
||||
<div className="pt-3 text-sm text-muted-foreground">Loading devices…</div>
|
||||
) : (
|
||||
<EmptyState onPair={() => setPairOpen(true)} />
|
||||
)}
|
||||
</SettingSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">About pairing</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Pairing creates a long-lived bearer that only your phone holds.
|
||||
Voicebox stores just a hash — there's no path to recover the
|
||||
bearer if the device loses it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">How it works</h3>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li className="flex gap-2.5">
|
||||
<Smartphone className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">Local-first.</span>{' '}
|
||||
Your phone talks to this Voicebox over LAN or Tailscale — no cloud,
|
||||
no relay.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-2.5">
|
||||
<Lock className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">Bearer-only.</span>{' '}
|
||||
The bearer is shown to the device once at pairing time and
|
||||
never persisted server-side in plaintext.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-2.5">
|
||||
<WifiOff className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">Revocable.</span>{' '}
|
||||
Revoke any device here — its bearer stops working immediately.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<PairDeviceDialog open={pairOpen} onOpenChange={setPairOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceRow({
|
||||
device,
|
||||
onRevoke,
|
||||
revoking,
|
||||
}: {
|
||||
device: PairedDeviceResponse;
|
||||
onRevoke: () => void;
|
||||
revoking: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between gap-3 rounded-lg border px-4 py-3 ${
|
||||
device.revoked ? 'border-border/50 bg-muted/20 opacity-60' : 'border-border bg-card'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${
|
||||
device.revoked ? 'bg-muted' : 'bg-accent/15'
|
||||
}`}
|
||||
>
|
||||
<Smartphone
|
||||
className={`h-4 w-4 ${device.revoked ? 'text-muted-foreground' : 'text-accent'}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{device.name}
|
||||
{device.revoked ? (
|
||||
<span className="ml-2 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
revoked
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Last seen {formatRelative(device.last_seen_at)} · paired{' '}
|
||||
{formatRelative(device.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!device.revoked ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={revoking}>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onRevoke} className="text-destructive">
|
||||
Revoke
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ onPair }: { onPair: () => void }) {
|
||||
return (
|
||||
<div className="pt-6 flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border/60 px-6 py-10 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-accent/10">
|
||||
<Smartphone className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">No paired devices</p>
|
||||
<p className="text-xs text-muted-foreground max-w-[280px]">
|
||||
Pair your phone to dictate captures, queue generations, and play back voices on the go.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onPair} size="sm" className="mt-2 gap-1.5">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Pair device
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { Check, Copy, Loader2, RefreshCw, Smartphone } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import {
|
||||
PAIRED_DEVICES_KEY,
|
||||
useInitPairing,
|
||||
usePairedDevices,
|
||||
usePairHostCandidates,
|
||||
} from '@/lib/hooks/usePairedDevices';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function formatRemaining(ms: number): string {
|
||||
if (ms <= 0) return 'expired';
|
||||
const total = Math.floor(ms / 1000);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function PairDeviceDialog({ open, onOpenChange }: Props) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const candidates = usePairHostCandidates(open);
|
||||
const initPairing = useInitPairing();
|
||||
const devices = usePairedDevices({ polling: open });
|
||||
|
||||
const [selectedHost, setSelectedHost] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
// The IDs that existed when the dialog opened — anything new is a fresh
|
||||
// pairing we should celebrate.
|
||||
const [baselineDeviceIds, setBaselineDeviceIds] = useState<Set<string> | null>(null);
|
||||
|
||||
// On open: snapshot baseline devices, default-select the first non-loopback
|
||||
// candidate, and mint the first token.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setBaselineDeviceIds(null);
|
||||
setSelectedHost(null);
|
||||
initPairing.reset();
|
||||
return;
|
||||
}
|
||||
if (devices.data && baselineDeviceIds === null) {
|
||||
setBaselineDeviceIds(new Set(devices.data.map((d) => d.id)));
|
||||
}
|
||||
if (candidates.data && candidates.data.length > 0 && selectedHost === null) {
|
||||
const preferred =
|
||||
candidates.data.find((c) => c.kind !== 'loopback') ?? candidates.data[0];
|
||||
setSelectedHost(preferred.address);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, candidates.data, devices.data]);
|
||||
|
||||
// Re-mint the token whenever the host selection changes (the URL embeds
|
||||
// the host, so a new selection means a new QR).
|
||||
useEffect(() => {
|
||||
if (!open || !selectedHost) return;
|
||||
initPairing.mutate(selectedHost);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, selectedHost]);
|
||||
|
||||
// Wall-clock tick for the countdown.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [open]);
|
||||
|
||||
// Detect a freshly paired device and close + toast.
|
||||
useEffect(() => {
|
||||
if (!open || !devices.data || baselineDeviceIds === null) return;
|
||||
const newDevice = devices.data.find(
|
||||
(d) => !baselineDeviceIds.has(d.id) && !d.revoked,
|
||||
);
|
||||
if (newDevice) {
|
||||
toast({
|
||||
title: 'Device paired',
|
||||
description: `${newDevice.name} is now connected.`,
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: PAIRED_DEVICES_KEY });
|
||||
onOpenChange(false);
|
||||
}
|
||||
}, [open, devices.data, baselineDeviceIds, toast, qc, onOpenChange]);
|
||||
|
||||
const pairing = initPairing.data;
|
||||
const expiresAtMs = pairing ? new Date(pairing.expires_at).getTime() : 0;
|
||||
const remainingMs = expiresAtMs - now;
|
||||
const expired = pairing != null && remainingMs <= 0;
|
||||
|
||||
const qrValue = useMemo(() => pairing?.pairing_url ?? '', [pairing]);
|
||||
|
||||
async function handleCopy() {
|
||||
if (!pairing) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(pairing.pairing_url);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Copy failed',
|
||||
description: e instanceof Error ? e.message : 'Could not access clipboard',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleRegenerate() {
|
||||
if (!selectedHost) return;
|
||||
initPairing.mutate(selectedHost);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Smartphone className="h-4 w-4 text-accent" />
|
||||
Pair a new device
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Open Voicebox on your phone, tap <span className="text-foreground">Get started → Pair</span>,
|
||||
then point its camera at this QR.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Host picker */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Reachable at
|
||||
</label>
|
||||
<Select
|
||||
value={selectedHost ?? ''}
|
||||
onValueChange={(v) => setSelectedHost(v)}
|
||||
disabled={!candidates.data || candidates.data.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={candidates.isError ? 'Could not load' : 'Detecting addresses…'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{candidates.data?.map((c) => (
|
||||
<SelectItem key={c.address} value={c.address}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs">{c.address}</span>
|
||||
<span className="text-xs text-muted-foreground">— {c.label}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{candidates.isError ? (
|
||||
<p className="text-xs text-destructive leading-snug">
|
||||
{(candidates.error as Error)?.message ??
|
||||
'Failed to fetch /pair/host-candidates — is the backend up to date?'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* QR */}
|
||||
<div className="flex items-center justify-center rounded-xl border border-border bg-white p-6 min-h-[260px]">
|
||||
{initPairing.isPending && !pairing ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : initPairing.isError ? (
|
||||
<p className="text-sm text-destructive text-center">
|
||||
{(initPairing.error as Error)?.message ?? 'Failed to mint token'}
|
||||
</p>
|
||||
) : qrValue ? (
|
||||
<QRCode value={qrValue} size={220} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No host selected</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Countdown + regenerate */}
|
||||
{pairing ? (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={expired ? 'text-destructive' : 'text-muted-foreground'}>
|
||||
{expired
|
||||
? 'QR expired — regenerate to continue'
|
||||
: `Expires in ${formatRemaining(remainingMs)}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegenerate}
|
||||
className="inline-flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{expired ? 'Regenerate' : 'New QR'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Copyable URL */}
|
||||
{pairing ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Or paste this URL on the phone
|
||||
</label>
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<code className="flex-1 truncate text-xs font-mono">{pairing.pairing_url}</code>
|
||||
<Button size="sm" variant="ghost" onClick={handleCopy} className="h-7 px-2">
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-accent" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
The token is single-use and expires in 5 minutes. Once paired, your phone holds a
|
||||
long-lived bearer that only it knows — Voicebox stores just a hash. Revoke any time.
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface SettingsTab {
|
||||
| '/settings/generation'
|
||||
| '/settings/captures'
|
||||
| '/settings/mcp'
|
||||
| '/settings/mobile'
|
||||
| '/settings/gpu'
|
||||
| '/settings/logs'
|
||||
| '/settings/changelog'
|
||||
@@ -25,6 +26,9 @@ const tabs: SettingsTab[] = [
|
||||
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' },
|
||||
{ labelKey: 'settings.tabs.captures', path: '/settings/captures' },
|
||||
{ labelKey: 'settings.tabs.mcp', path: '/settings/mcp' },
|
||||
// Plain-string label for V0 — translation keys come when mobile graduates
|
||||
// out of "experimental" status.
|
||||
{ label: 'Mobile', path: '/settings/mobile' },
|
||||
{ labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true },
|
||||
{ labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true },
|
||||
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
|
||||
|
||||
@@ -50,6 +50,9 @@ import type {
|
||||
MCPClientBinding,
|
||||
MCPClientBindingListResponse,
|
||||
MCPClientBindingUpsert,
|
||||
HostCandidate,
|
||||
PairInitResponse,
|
||||
PairedDeviceResponse,
|
||||
} from './types';
|
||||
|
||||
function formatErrorDetail(detail: unknown, fallback: string): string {
|
||||
@@ -920,6 +923,26 @@ class ApiClient {
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// Mobile pairing
|
||||
async getPairHostCandidates(): Promise<HostCandidate[]> {
|
||||
return this.request<HostCandidate[]>('/pair/host-candidates');
|
||||
}
|
||||
|
||||
async initPairing(host: string): Promise<PairInitResponse> {
|
||||
return this.request<PairInitResponse>(
|
||||
`/pair/init?host=${encodeURIComponent(host)}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
|
||||
async listPairedDevices(): Promise<PairedDeviceResponse[]> {
|
||||
return this.request<PairedDeviceResponse[]>('/devices');
|
||||
}
|
||||
|
||||
async revokePairedDevice(deviceId: string): Promise<void> {
|
||||
await this.request<void>(`/devices/${deviceId}`, { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
@@ -521,3 +521,27 @@ export interface MCPClientBindingUpsert {
|
||||
export interface MCPClientBindingListResponse {
|
||||
items: MCPClientBinding[];
|
||||
}
|
||||
|
||||
/* ─── Mobile pairing (V0) ────────────────────────────────────────────── */
|
||||
|
||||
export type HostCandidateKind = 'lan' | 'tailscale' | 'loopback';
|
||||
|
||||
export interface HostCandidate {
|
||||
address: string; // host:port
|
||||
label: string; // human-friendly name
|
||||
kind: HostCandidateKind;
|
||||
}
|
||||
|
||||
export interface PairInitResponse {
|
||||
token: string;
|
||||
expires_at: string;
|
||||
pairing_url: string; // voicebox://pair?host=…&token=…
|
||||
}
|
||||
|
||||
export interface PairedDeviceResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
revoked: boolean;
|
||||
created_at: string;
|
||||
last_seen_at: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
|
||||
export const PAIRED_DEVICES_KEY = ['paired-devices'] as const;
|
||||
export const PAIR_HOST_CANDIDATES_KEY = ['pair-host-candidates'] as const;
|
||||
|
||||
/**
|
||||
* List all paired (and revoked) devices. Pass ``polling: true`` while the
|
||||
* pair dialog is open so the device list refreshes when the user finishes
|
||||
* scanning on their phone — this is how the desktop UI detects success
|
||||
* without needing an SSE stream.
|
||||
*/
|
||||
export function usePairedDevices({ polling = false }: { polling?: boolean } = {}) {
|
||||
return useQuery({
|
||||
queryKey: PAIRED_DEVICES_KEY,
|
||||
queryFn: () => apiClient.listPairedDevices(),
|
||||
refetchInterval: polling ? 2000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidate addresses the desktop can embed in the QR (LAN, Tailscale,
|
||||
* loopback). Cached for the lifetime of the dialog — interfaces don't
|
||||
* change often enough to be worth re-polling.
|
||||
*/
|
||||
export function usePairHostCandidates(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: PAIR_HOST_CANDIDATES_KEY,
|
||||
queryFn: () => apiClient.getPairHostCandidates(),
|
||||
enabled,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a fresh pairing token for a chosen host. The result is short-lived
|
||||
* (5 min); the dialog should re-mint when expiry is hit.
|
||||
*/
|
||||
export function useInitPairing() {
|
||||
return useMutation({
|
||||
mutationFn: (host: string) => apiClient.initPairing(host),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a paired device. Invalidates the device list so the row disappears
|
||||
* on success.
|
||||
*/
|
||||
export function useRevokePairedDevice() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (deviceId: string) => apiClient.revokePairedDevice(deviceId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: PAIRED_DEVICES_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { GenerationPage } from '@/components/ServerTab/GenerationPage';
|
||||
import { GpuPage } from '@/components/ServerTab/GpuPage';
|
||||
import { LogsPage } from '@/components/ServerTab/LogsPage';
|
||||
import { MCPPage } from '@/components/ServerTab/MCPPage';
|
||||
import { MobilePage } from '@/components/ServerTab/MobilePage';
|
||||
import { SettingsLayout } from '@/components/ServerTab/ServerTab';
|
||||
import { Sidebar } from '@/components/Sidebar';
|
||||
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
||||
@@ -166,6 +167,12 @@ const settingsMCPRoute = createRoute({
|
||||
component: MCPPage,
|
||||
});
|
||||
|
||||
const settingsMobileRoute = createRoute({
|
||||
getParentRoute: () => settingsRoute,
|
||||
path: '/mobile',
|
||||
component: MobilePage,
|
||||
});
|
||||
|
||||
const settingsGpuRoute = createRoute({
|
||||
getParentRoute: () => settingsRoute,
|
||||
path: '/gpu',
|
||||
@@ -212,6 +219,7 @@ const routeTree = rootRoute.addChildren([
|
||||
settingsGenerationRoute,
|
||||
settingsCapturesRoute,
|
||||
settingsMCPRoute,
|
||||
settingsMobileRoute,
|
||||
settingsGpuRoute,
|
||||
settingsLogsRoute,
|
||||
settingsChangelogRoute,
|
||||
|
||||
@@ -16,6 +16,8 @@ from .models import (
|
||||
GenerationSettings,
|
||||
GenerationVersion,
|
||||
MCPClientBinding,
|
||||
PairedDevice,
|
||||
PairingToken,
|
||||
ProfileChannelMapping,
|
||||
ProfileSample,
|
||||
Project,
|
||||
@@ -37,6 +39,8 @@ __all__ = [
|
||||
"GenerationSettings",
|
||||
"GenerationVersion",
|
||||
"MCPClientBinding",
|
||||
"PairedDevice",
|
||||
"PairingToken",
|
||||
"ProfileChannelMapping",
|
||||
"ProfileSample",
|
||||
"Project",
|
||||
|
||||
@@ -279,3 +279,37 @@ class Capture(Base):
|
||||
llm_model = Column(String, nullable=True)
|
||||
refinement_flags = Column(Text, nullable=True) # JSON blob
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class PairedDevice(Base):
|
||||
"""A mobile device paired with this Voicebox install (V0 pair flow).
|
||||
|
||||
Stores only the SHA-256 of the bearer token; the bearer plaintext is
|
||||
returned to the device once at pairing time and never persisted
|
||||
server-side. If the device loses its bearer the user must re-pair.
|
||||
"""
|
||||
|
||||
__tablename__ = "paired_devices"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False)
|
||||
bearer_hash = Column(String, nullable=False, unique=True, index=True)
|
||||
revoked = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
last_seen_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class PairingToken(Base):
|
||||
"""One-time token used to complete a device pairing.
|
||||
|
||||
Minted by the desktop UI via /pair/init, redeemed by the mobile
|
||||
device via /pair/complete in exchange for a long-lived bearer.
|
||||
Single-use; expires after ~5 minutes.
|
||||
"""
|
||||
|
||||
__tablename__ = "pairing_tokens"
|
||||
|
||||
token = Column(String, primary_key=True)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
used_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
@@ -793,3 +793,61 @@ class AvailableEffectsResponse(BaseModel):
|
||||
"""Response listing all available effect types."""
|
||||
|
||||
effects: List[AvailableEffect]
|
||||
|
||||
|
||||
# --- Mobile pairing (V0) -----------------------------------------------------
|
||||
|
||||
class HostCandidate(BaseModel):
|
||||
"""A reachable address the desktop can embed in the pair QR."""
|
||||
|
||||
address: str # ``host:port``, e.g. "192.168.1.5:17493"
|
||||
label: str # human-friendly name shown in the desktop host picker
|
||||
kind: str # "lan" | "tailscale" | "loopback"
|
||||
|
||||
|
||||
class PairInitResponse(BaseModel):
|
||||
"""Response for POST /pair/init — desktop renders ``pairing_url`` as a QR."""
|
||||
|
||||
token: str
|
||||
expires_at: datetime
|
||||
pairing_url: str # full voicebox://pair?host=…&token=… URL
|
||||
|
||||
|
||||
class PairCompleteRequest(BaseModel):
|
||||
"""Mobile-side request body for POST /pair/complete."""
|
||||
|
||||
token: str = Field(..., min_length=1, max_length=128)
|
||||
device_name: str = Field(..., min_length=1, max_length=80)
|
||||
|
||||
|
||||
class PairCompleteResponse(BaseModel):
|
||||
"""One-time response after successful pairing.
|
||||
|
||||
``bearer`` is returned in plaintext exactly once and is never persisted
|
||||
server-side; the device must save it (e.g. iOS SecureStore) immediately.
|
||||
"""
|
||||
|
||||
device_id: str
|
||||
bearer: str
|
||||
device_name: str
|
||||
|
||||
|
||||
class PairedDeviceResponse(BaseModel):
|
||||
"""A row in the desktop's Settings → Mobile device list."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
revoked: bool
|
||||
created_at: datetime
|
||||
last_seen_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
"""Identity of the bearer-authenticated caller."""
|
||||
|
||||
device_id: str
|
||||
device_name: str
|
||||
last_seen_at: Optional[datetime] = None
|
||||
|
||||
+42
-19
@@ -1,6 +1,23 @@
|
||||
"""Route registration for the voicebox API."""
|
||||
"""Route registration for the voicebox API.
|
||||
|
||||
from fastapi import FastAPI
|
||||
Authentication model
|
||||
--------------------
|
||||
Two router groups:
|
||||
|
||||
* **Open** — ``health`` (status checks anyone on the LAN may probe) and
|
||||
``pairing`` (pre-pair endpoints + admin endpoints with their own
|
||||
loopback-only or token-only gates).
|
||||
|
||||
* **Protected** — everything else, gated by ``require_bearer_or_loopback``:
|
||||
loopback callers (the desktop app over 127.0.0.1) pass without auth as
|
||||
before; LAN/Tailscale callers must present a valid paired-device bearer.
|
||||
This is what lets ``just dev`` bind to 0.0.0.0 without exposing user
|
||||
data to anyone on the same network.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
from ..utils.auth import require_bearer_or_loopback
|
||||
|
||||
|
||||
def register_routers(app: FastAPI) -> None:
|
||||
@@ -23,22 +40,28 @@ 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 .pairing import router as pairing_router
|
||||
|
||||
# Open — health probes and the pre-pair / admin pairing endpoints.
|
||||
app.include_router(health_router)
|
||||
app.include_router(profiles_router)
|
||||
app.include_router(channels_router)
|
||||
app.include_router(generations_router)
|
||||
app.include_router(history_router)
|
||||
app.include_router(transcription_router)
|
||||
app.include_router(llm_router)
|
||||
app.include_router(captures_router)
|
||||
app.include_router(stories_router)
|
||||
app.include_router(effects_router)
|
||||
app.include_router(audio_router)
|
||||
app.include_router(models_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(cuda_router)
|
||||
app.include_router(speak_router)
|
||||
app.include_router(mcp_bindings_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(pairing_router)
|
||||
|
||||
# Protected — loopback callers pass through; LAN callers need a paired bearer.
|
||||
protected = [Depends(require_bearer_or_loopback)]
|
||||
app.include_router(profiles_router, dependencies=protected)
|
||||
app.include_router(channels_router, dependencies=protected)
|
||||
app.include_router(generations_router, dependencies=protected)
|
||||
app.include_router(history_router, dependencies=protected)
|
||||
app.include_router(transcription_router, dependencies=protected)
|
||||
app.include_router(llm_router, dependencies=protected)
|
||||
app.include_router(captures_router, dependencies=protected)
|
||||
app.include_router(stories_router, dependencies=protected)
|
||||
app.include_router(effects_router, dependencies=protected)
|
||||
app.include_router(audio_router, dependencies=protected)
|
||||
app.include_router(models_router, dependencies=protected)
|
||||
app.include_router(settings_router, dependencies=protected)
|
||||
app.include_router(tasks_router, dependencies=protected)
|
||||
app.include_router(cuda_router, dependencies=protected)
|
||||
app.include_router(speak_router, dependencies=protected)
|
||||
app.include_router(mcp_bindings_router, dependencies=protected)
|
||||
app.include_router(events_router, dependencies=protected)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Mobile device pairing endpoints (V0 — bearer auth)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..database import PairedDevice, get_db
|
||||
from ..services import pairing
|
||||
from ..utils.auth import require_loopback, require_paired_device
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/pair/init",
|
||||
response_model=models.PairInitResponse,
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
async def pair_init(request: Request, db: Session = Depends(get_db)):
|
||||
"""Mint a one-time pairing token (loopback callers only).
|
||||
|
||||
Optional ``?host=`` query param overrides what's embedded in the QR's
|
||||
pairing URL. The desktop UI should pass whatever address is reachable
|
||||
from the mobile device — LAN IP, Tailscale 100.x address, or MagicDNS
|
||||
name. Defaults to the request Host header for curl-driven local testing.
|
||||
"""
|
||||
host = request.query_params.get("host") or (
|
||||
request.headers.get("host") or "127.0.0.1:17493"
|
||||
)
|
||||
return pairing.init_pairing_token(db, host=host)
|
||||
|
||||
|
||||
@router.post("/pair/complete", response_model=models.PairCompleteResponse)
|
||||
async def pair_complete(
|
||||
body: models.PairCompleteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Exchange a pairing token for a long-lived bearer.
|
||||
|
||||
Open endpoint — possession of the (one-time, short-TTL) token is
|
||||
itself the proof of authorization.
|
||||
"""
|
||||
try:
|
||||
return pairing.complete_pairing(db, body.token, body.device_name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/devices",
|
||||
response_model=list[models.PairedDeviceResponse],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
async def list_paired_devices(db: Session = Depends(get_db)):
|
||||
"""List paired devices for the desktop Settings → Mobile pane."""
|
||||
return pairing.list_devices(db)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/devices/{device_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
async def revoke_paired_device(device_id: str, db: Session = Depends(get_db)):
|
||||
"""Revoke a paired device's bearer."""
|
||||
try:
|
||||
pairing.revoke_device(db, device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/me", response_model=models.MeResponse)
|
||||
async def me(device: PairedDevice = Depends(require_paired_device)):
|
||||
"""Identity of the bearer-authenticated caller — used by mobile to
|
||||
confirm pairing succeeded and the bearer round-trips.
|
||||
"""
|
||||
return models.MeResponse(
|
||||
device_id=device.id,
|
||||
device_name=device.name,
|
||||
last_seen_at=device.last_seen_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pair/host-candidates",
|
||||
response_model=list[models.HostCandidate],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
async def host_candidates(request: Request):
|
||||
"""Suggested host strings (LAN IP, Tailscale, loopback) for the QR."""
|
||||
port = request.url.port or 17493
|
||||
return pairing.list_host_candidates(port=port)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Mobile device pairing service (V0 — bearer auth, no E2E payload encryption yet).
|
||||
|
||||
Flow:
|
||||
1. Desktop UI (loopback) calls POST /pair/init → mints a single-use token
|
||||
with a 5-minute TTL and returns a ``voicebox://pair?...`` URL.
|
||||
2. Mobile scans the QR (or pastes the URL) and POSTs /pair/complete with
|
||||
the token + a human-readable device name.
|
||||
3. Server validates the token, mints a long-lived bearer, and stores
|
||||
only SHA-256(bearer). The bearer plaintext is returned exactly once.
|
||||
4. Mobile saves the bearer in SecureStore. Subsequent calls carry
|
||||
``Authorization: Bearer <bearer>``.
|
||||
|
||||
The bearer is returned exactly once. Server has no path to recover it; if
|
||||
the device loses its key the user must re-pair (and revoke the old device
|
||||
from Settings → Mobile if they want to be tidy).
|
||||
|
||||
Phase 2 will layer XChaCha20-Poly1305 payload encryption + HKDF session
|
||||
keys on top — see ``mobile/PLAN.md`` § Pairing & transport. The bearer
|
||||
established here is the foundation either way.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import socket
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import PairedDevice, PairingToken
|
||||
from ..models import (
|
||||
HostCandidate,
|
||||
PairCompleteResponse,
|
||||
PairInitResponse,
|
||||
PairedDeviceResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PAIRING_TOKEN_TTL = timedelta(minutes=5)
|
||||
TOKEN_BYTES = 32 # urlsafe-b64 encoded → ~44 chars
|
||||
|
||||
|
||||
def _hash_bearer(bearer: str) -> str:
|
||||
return hashlib.sha256(bearer.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _build_pairing_url(token: str, host: str) -> str:
|
||||
# ``host`` should be reachable from the mobile device — LAN IP, Tailscale
|
||||
# 100.x address, MagicDNS hostname (e.g. ``mac.tail-xxxx.ts.net:17493``).
|
||||
# The desktop UI is responsible for picking the right host; loopback is
|
||||
# only useful for curl-driven local testing.
|
||||
return f"voicebox://pair?host={host}&token={token}"
|
||||
|
||||
|
||||
def init_pairing_token(db: Session, host: str) -> PairInitResponse:
|
||||
"""Mint a one-time pairing token. Caller must already be authorized as loopback."""
|
||||
token = secrets.token_urlsafe(TOKEN_BYTES)
|
||||
expires_at = datetime.utcnow() + PAIRING_TOKEN_TTL
|
||||
db.add(PairingToken(token=token, expires_at=expires_at))
|
||||
db.commit()
|
||||
return PairInitResponse(
|
||||
token=token,
|
||||
expires_at=expires_at,
|
||||
pairing_url=_build_pairing_url(token, host),
|
||||
)
|
||||
|
||||
|
||||
def complete_pairing(db: Session, token: str, device_name: str) -> PairCompleteResponse:
|
||||
"""Exchange a pairing token for a long-lived device bearer.
|
||||
|
||||
Raises ValueError on invalid / expired / already-used token.
|
||||
"""
|
||||
row = db.query(PairingToken).filter(PairingToken.token == token).first()
|
||||
if row is None:
|
||||
raise ValueError("Invalid pairing token")
|
||||
if row.used_at is not None:
|
||||
raise ValueError("Pairing token already used")
|
||||
if row.expires_at < datetime.utcnow():
|
||||
raise ValueError("Pairing token expired")
|
||||
|
||||
row.used_at = datetime.utcnow()
|
||||
|
||||
bearer = secrets.token_urlsafe(TOKEN_BYTES)
|
||||
device = PairedDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
name=device_name.strip(),
|
||||
bearer_hash=_hash_bearer(bearer),
|
||||
)
|
||||
db.add(device)
|
||||
db.commit()
|
||||
|
||||
logger.info("Paired new device id=%s name=%s", device.id, device.name)
|
||||
|
||||
return PairCompleteResponse(
|
||||
device_id=device.id,
|
||||
bearer=bearer,
|
||||
device_name=device.name,
|
||||
)
|
||||
|
||||
|
||||
def authenticate_bearer(db: Session, bearer: str) -> Optional[PairedDevice]:
|
||||
"""Look up a paired device by bearer. Bumps last_seen_at on success."""
|
||||
if not bearer:
|
||||
return None
|
||||
bearer_hash = _hash_bearer(bearer)
|
||||
device = (
|
||||
db.query(PairedDevice)
|
||||
.filter(PairedDevice.bearer_hash == bearer_hash, PairedDevice.revoked.is_(False))
|
||||
.first()
|
||||
)
|
||||
if device is not None:
|
||||
device.last_seen_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return device
|
||||
|
||||
|
||||
def list_devices(db: Session) -> list[PairedDeviceResponse]:
|
||||
"""Return all paired devices (revoked included) for the desktop UI."""
|
||||
rows = db.query(PairedDevice).order_by(PairedDevice.created_at.desc()).all()
|
||||
return [PairedDeviceResponse.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
def revoke_device(db: Session, device_id: str) -> None:
|
||||
"""Mark a device as revoked. Idempotent on repeat calls."""
|
||||
device = db.query(PairedDevice).filter(PairedDevice.id == device_id).first()
|
||||
if device is None:
|
||||
raise ValueError("Device not found")
|
||||
device.revoked = True
|
||||
db.commit()
|
||||
logger.info("Revoked device id=%s name=%s", device.id, device.name)
|
||||
|
||||
|
||||
# --- Host discovery ---------------------------------------------------------
|
||||
|
||||
def _get_lan_ip() -> Optional[str]:
|
||||
"""Best-effort outbound IPv4 of the host. Uses the UDP-connect trick —
|
||||
no packets are actually sent; the kernel just picks the source IP it
|
||||
would use to reach 8.8.8.8.
|
||||
"""
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
return s.getsockname()[0]
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _get_tailscale_ip() -> Optional[str]:
|
||||
"""Return the host's Tailscale 100.x address if Tailscale is installed
|
||||
and reports one. Returns ``None`` on any failure — Tailscale is optional.
|
||||
|
||||
Tries ``tailscale`` on PATH first, then falls back to the Mac App Store
|
||||
install location (the macOS App bundle doesn't symlink onto PATH by
|
||||
default, only into the user's shell init via an alias subprocess can't see).
|
||||
"""
|
||||
candidate_binaries = [
|
||||
"tailscale",
|
||||
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
||||
]
|
||||
for binary in candidate_binaries:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[binary, "ip", "--4"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
continue
|
||||
if result.returncode != 0:
|
||||
continue
|
||||
ip = result.stdout.strip().splitlines()[0].strip() if result.stdout else ""
|
||||
if ip:
|
||||
return ip
|
||||
return None
|
||||
|
||||
|
||||
def list_host_candidates(port: int) -> list[HostCandidate]:
|
||||
"""Return suggested host strings the desktop can embed in the QR.
|
||||
|
||||
Order matters — the UI defaults to the first non-loopback entry.
|
||||
"""
|
||||
candidates: list[HostCandidate] = []
|
||||
lan_ip = _get_lan_ip()
|
||||
if lan_ip and not lan_ip.startswith("127."):
|
||||
candidates.append(
|
||||
HostCandidate(address=f"{lan_ip}:{port}", label="Local network", kind="lan")
|
||||
)
|
||||
tailscale_ip = _get_tailscale_ip()
|
||||
if tailscale_ip and tailscale_ip != lan_ip:
|
||||
candidates.append(
|
||||
HostCandidate(address=f"{tailscale_ip}:{port}", label="Tailscale", kind="tailscale")
|
||||
)
|
||||
candidates.append(
|
||||
HostCandidate(address=f"127.0.0.1:{port}", label="Loopback (testing only)", kind="loopback")
|
||||
)
|
||||
return candidates
|
||||
@@ -0,0 +1,103 @@
|
||||
"""FastAPI dependencies for the V0 mobile-pair auth model.
|
||||
|
||||
Two dependencies are exposed:
|
||||
|
||||
* ``require_loopback`` — reject calls that don't originate from a loopback
|
||||
address. Used to gate desktop-only admin endpoints (pair init, devices
|
||||
list, revoke). Loopback callers stay unauthenticated everywhere else
|
||||
too — the desktop app talks to its own backend over 127.0.0.1.
|
||||
|
||||
* ``require_paired_device`` — validate ``Authorization: Bearer <token>``
|
||||
against the ``paired_devices`` table. Used to identify paired mobile
|
||||
callers and bumps ``last_seen_at`` on success.
|
||||
|
||||
Phase 2 will layer XChaCha20-Poly1305 payload encryption on top of the
|
||||
bearer (see ``mobile/PLAN.md``); the bearer stays the identity primitive.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import PairedDevice, get_db
|
||||
from ..services import pairing as pairing_service
|
||||
|
||||
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
|
||||
def require_loopback(request: Request) -> None:
|
||||
"""Reject calls from non-loopback addresses."""
|
||||
client = request.client
|
||||
host = client.host if client else None
|
||||
if host not in LOOPBACK_HOSTS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Loopback only",
|
||||
)
|
||||
|
||||
|
||||
def _extract_bearer(request: Request) -> Optional[str]:
|
||||
auth = request.headers.get("Authorization") or request.headers.get("authorization")
|
||||
if not auth:
|
||||
return None
|
||||
parts = auth.split(None, 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return None
|
||||
return parts[1].strip()
|
||||
|
||||
|
||||
def require_paired_device(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> PairedDevice:
|
||||
"""Resolve the PairedDevice authenticated by the request bearer."""
|
||||
bearer = _extract_bearer(request)
|
||||
if not bearer:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing bearer token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
device = pairing_service.authenticate_bearer(db, bearer)
|
||||
if device is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or revoked bearer token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return device
|
||||
|
||||
|
||||
def require_bearer_or_loopback(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
"""Loopback callers pass without auth; everyone else needs a paired bearer.
|
||||
|
||||
Applied as a router-level dependency on user-data endpoints so the
|
||||
desktop UI (which talks over 127.0.0.1) keeps its current friction-free
|
||||
access while LAN-reachable callers must be a paired mobile device.
|
||||
|
||||
The pre-pair endpoints (``POST /pair/complete``) and the desktop-only
|
||||
admin endpoints (``POST /pair/init``, ``GET /devices``) intentionally
|
||||
stay outside this gate — they have their own dependencies.
|
||||
"""
|
||||
client = request.client
|
||||
host = client.host if client else None
|
||||
if host in LOOPBACK_HOSTS:
|
||||
return
|
||||
bearer = _extract_bearer(request)
|
||||
if not bearer:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing bearer token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
device = pairing_service.authenticate_bearer(db, bearer)
|
||||
if device is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or revoked bearer token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"app": {
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -57,6 +57,7 @@
|
||||
"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",
|
||||
@@ -75,7 +76,7 @@
|
||||
},
|
||||
"landing": {
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@fontsource/space-grotesk": "^5.2.10",
|
||||
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||
@@ -104,7 +105,7 @@
|
||||
},
|
||||
"tauri": {
|
||||
"name": "@voicebox/tauri",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
@@ -127,7 +128,7 @@
|
||||
},
|
||||
"web": {
|
||||
"name": "@voicebox/web",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"react": "^18.3.0",
|
||||
@@ -991,6 +992,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1005,6 +1008,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -52,6 +52,10 @@ setup-python:
|
||||
if [ "$(uname -m)" = "arm64" ] && [ "$(uname)" = "Darwin" ]; then
|
||||
echo "Detected Apple Silicon — installing MLX dependencies..."
|
||||
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
|
||||
# mlx-audio + mlx-lm are intentionally --no-deps (their transformers>=5
|
||||
# pin conflicts with our pinned 4.x). The runtime API surface we use
|
||||
# works fine on transformers 4.57.x. See requirements-mlx.txt notes.
|
||||
{{ pip }} install --no-deps mlx-audio==0.4.1 mlx-lm
|
||||
fi
|
||||
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
{{ pip }} install pyinstaller ruff pytest pytest-asyncio -q
|
||||
@@ -100,6 +104,11 @@ setup-js:
|
||||
# ─── Development ──────────────────────────────────────────────────────
|
||||
|
||||
# Start backend (if not already running) + frontend for development
|
||||
# Binds the backend to 0.0.0.0 so paired mobile devices can reach it over the
|
||||
# LAN/Tailscale. This exposes existing unauth routes (/generate, /transcribe,
|
||||
# /captures, /profiles) to anyone on the same network — fine for a trusted
|
||||
# home network, NOT fine on public Wi-Fi. Bearer-auth middleware on those
|
||||
# routes is on the roadmap; until then, treat dev as LAN-trusted.
|
||||
[unix]
|
||||
dev: _ensure-venv _ensure-sidecar
|
||||
#!/usr/bin/env bash
|
||||
@@ -109,8 +118,8 @@ dev: _ensure-venv _ensure-sidecar
|
||||
if curl -sf http://127.0.0.1:17493/health > /dev/null 2>&1; then
|
||||
echo "Backend already running on http://localhost:17493"
|
||||
else
|
||||
echo "Starting backend on http://localhost:17493 ..."
|
||||
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
|
||||
echo "Starting backend on http://0.0.0.0:17493 (LAN-reachable) ..."
|
||||
{{ venv_bin }}/uvicorn backend.main:app --reload --host 0.0.0.0 --port 17493 &
|
||||
backend_pid=$!
|
||||
sleep 2
|
||||
fi
|
||||
@@ -124,21 +133,21 @@ dev: _ensure-venv _ensure-sidecar
|
||||
dev: _ensure-venv _ensure-sidecar
|
||||
$backendJob = $null; \
|
||||
try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:17493/health" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop; Write-Host "Backend already running on http://localhost:17493" } catch { \
|
||||
Write-Host "Starting backend on http://localhost:17493 ..."; \
|
||||
$backendJob = Start-Process -PassThru -NoNewWindow -FilePath "{{ python }}" -ArgumentList "-m","uvicorn","backend.main:app","--reload","--port","17493"; \
|
||||
Write-Host "Starting backend on http://0.0.0.0:17493 (LAN-reachable) ..."; \
|
||||
$backendJob = Start-Process -PassThru -NoNewWindow -FilePath "{{ python }}" -ArgumentList "-m","uvicorn","backend.main:app","--reload","--host","0.0.0.0","--port","17493"; \
|
||||
Start-Sleep -Seconds 2; \
|
||||
}; \
|
||||
Write-Host "Starting Tauri desktop app..."; \
|
||||
try { Set-Location "{{ tauri_dir }}"; bun run tauri dev } finally { if ($backendJob) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
|
||||
|
||||
# Start backend only
|
||||
# Start backend only — bound to 0.0.0.0 for mobile pairing (see `dev` above)
|
||||
[unix]
|
||||
dev-backend: _ensure-venv
|
||||
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493
|
||||
{{ venv_bin }}/uvicorn backend.main:app --reload --host 0.0.0.0 --port 17493
|
||||
|
||||
[windows]
|
||||
dev-backend: _ensure-venv
|
||||
& "{{ python }}" -m uvicorn backend.main:app --reload --port 17493
|
||||
& "{{ python }}" -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 17493
|
||||
|
||||
# Start Tauri desktop app only (backend must be running separately)
|
||||
[unix]
|
||||
@@ -180,6 +189,15 @@ dev-web: _ensure-venv
|
||||
Write-Host "Starting web app..."; \
|
||||
try { Set-Location "{{ web_dir }}"; bun run dev } finally { if ($backendJob) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
|
||||
|
||||
# Start Expo dev server for the mobile companion app (run `cd mobile && bun install` once first)
|
||||
[unix]
|
||||
dev-mobile:
|
||||
cd mobile && bunx expo start
|
||||
|
||||
[windows]
|
||||
dev-mobile:
|
||||
Set-Location "mobile"; bunx expo start
|
||||
|
||||
# Kill all dev processes
|
||||
[unix]
|
||||
kill:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
# Voicebox Mobile — V1 plan
|
||||
|
||||
A companion app for the Voicebox desktop. iPhone-first, dictate-anywhere, Captures as the hero. Talks to a paired desktop over Tailscale or LAN with end-to-end encryption.
|
||||
|
||||
V1 is entirely local — no cloud, no account. The device key minted during pairing is the root of the user's lifetime encryption identity and gets reused by the cloud phases that follow; see [`docs/plans/CLOUD_ROADMAP.md`](../docs/plans/CLOUD_ROADMAP.md) for the post-mobile arc (backup & sync → private inference → marketplace).
|
||||
|
||||
---
|
||||
|
||||
## Repo layout
|
||||
|
||||
- New `mobile/` at repo root, sibling to `app/`, `backend/`, `tauri/`, `landing/`, `web/`
|
||||
- Standalone Expo project — not a Bun workspace member (avoids React/Tauri/Bun version drag)
|
||||
- Type-sharing via OpenAPI: generate `mobile/src/api/types.ts` from backend's `/openapi.json`, commit it, regenerate on demand
|
||||
- Branch off `main` once 0.5.0 ships → `feat/mobile-app`
|
||||
|
||||
## Stack (verified 2026-04-25)
|
||||
|
||||
### The SDK 54 vs SDK 55 fork
|
||||
|
||||
Expo SDK 55 is the current `latest` (`[email protected]`, React Native 0.83, React 19.2, New Architecture only — Legacy Architecture was dropped in 55). It also ships Expo Router v7, Hermes v1 with bytecode diffing, and `expo-brownfield`. But: **NativeWind v5 is the only NativeWind line that targets SDK 55, and v5 is still pre-release** (`5.0.0-preview.3`, with explicit "not intended for production use" warning in the v5 docs). NativeWind v4 stable (`4.2.3`) is paired with SDK 54.
|
||||
|
||||
Two real options:
|
||||
|
||||
- **Option A — Ship-fast (recommended for V1):** Expo SDK 54 + NativeWind v4.2.3. Both stable, official pairing, well-documented. Loses SDK 55's bytecode-diff updates and Expo Router v7 sugar but everything works.
|
||||
- **Option B — Cutting-edge:** Expo SDK 55.0.17 + NativeWind 5.0.0-preview.3. Latest everything, but the NativeWind v5 maintainer explicitly says it's for experimentation. More breakage during dev, brittle CI.
|
||||
|
||||
Recommendation: **Option A**. We're trying to ship a companion app, not stress-test pre-release styling layers. We can bump to SDK 55 + NativeWind v5 once both stabilize (Expo targets stable SDK 55 mid-2026).
|
||||
|
||||
### Pinned versions (assuming Option A)
|
||||
|
||||
| Package | Version | Notes |
|
||||
| --- | --- | --- |
|
||||
| `expo` | `~54` (latest 54.x) | New Architecture default since SDK 51 |
|
||||
| `expo-router` | bundled with SDK | file-based, typed routes |
|
||||
| `nativewind` | `4.2.3` | Tailwind class parity with `app/` |
|
||||
| `expo-audio` | `~54` (bundled with SDK) | stable in SDK 54+, replaces `expo-av` recording |
|
||||
| `@shopify/react-native-skia` | `2.6.2` | live waveform; WaveSurfer is DOM-only |
|
||||
| `expo-camera` | `~54` (bundled) | QR scan (barcode scanning built in) |
|
||||
| `expo-secure-store` | `~54` (bundled) | paired device key |
|
||||
| `react-native-reanimated` | `4.3.0` | rewritten for new arch |
|
||||
| `react-native-gesture-handler` | `2.31.1` | |
|
||||
| `zustand` | `5.0.12` | mirrors desktop |
|
||||
| `@tanstack/react-query` | `5.100.5` | mirrors desktop |
|
||||
|
||||
(Versions for `expo-*` packages are managed by `npx expo install`, which picks the patch that matches the SDK — don't pin them by hand.)
|
||||
|
||||
### Other stack decisions
|
||||
|
||||
- **TypeScript strict**
|
||||
- Theme tokens copied straight from `app/`'s shadcn theme (`hsl(43 60% 50%)` for the gold accent, dark surfaces match)
|
||||
- **EAS Build** + **Dev Client** from day one — Skia and SecureStore push us off Expo Go
|
||||
|
||||
## Pairing & transport — Tailscale-friendly
|
||||
|
||||
1. Desktop: new **Settings → Mobile** with "Pair device" → renders QR + 6-digit fallback
|
||||
2. QR payload: `voicebox://pair?host=<url>&secret=<b64>&fp=<sha256>`
|
||||
- `host` = whatever address the user can reach: LAN IP (`192.168.x.x:17493`), Tailscale 100.x address, or MagicDNS name (`mac.tail-xxxx.ts.net:17493`) — Tailscale Just Works with zero extra code
|
||||
- `secret` = one-time pairing token; mobile exchanges it for a long-lived device key on first request
|
||||
- `fp` = self-signed cert fingerprint we mint at pair time, pinned on mobile
|
||||
3. Auth: bearer token + XChaCha20-Poly1305 payload encryption with HKDF per-session keys — E2E layer above HTTP, survives any future cloud relay swap
|
||||
|
||||
## Backend additions (desktop, separate PR before mobile work)
|
||||
|
||||
- `POST /pair/init` — mint pairing token, return QR payload
|
||||
- `POST /pair/complete` — exchange token for device-bound long-lived key, persist `paired_devices` row
|
||||
- `GET/DELETE /devices` — Settings → Mobile lists & revokes paired devices, with `last_seen_at`
|
||||
- Bearer middleware on `/generate`, `/transcribe`, `/profiles`, `/captures`, `/speak` (loopback callers stay unauthenticated as today; paired-device callers use the bearer)
|
||||
- `/captures` upload accepts `m4a` (expo-audio's iOS default) in addition to existing formats
|
||||
|
||||
## Screens
|
||||
|
||||
### First-run
|
||||
|
||||
Pair flow: scan QR → confirm desktop name + fingerprint → store creds in SecureStore.
|
||||
|
||||
### Tab 1 — Generate
|
||||
|
||||
- Profile cards horizontal scroll (top)
|
||||
- Recent generations list (middle) — tap to play, long-press for version picker / regenerate / share
|
||||
- Floating generate box (bottom): text input + engine indicator + speak button
|
||||
|
||||
### Tab 2 — Voices
|
||||
|
||||
- List of profiles (cloned + presets), grouped by engine compatibility
|
||||
- Tap to inspect: samples (Skia waveform thumbs), language, last used
|
||||
- **No** profile creation in V1
|
||||
|
||||
### Tab 3 — Captures (the hero)
|
||||
|
||||
- **Big gold mic button** bottom-front-and-center — same accent as the sponsor CTA
|
||||
- Tap-to-toggle in V1 (push-to-talk fights iOS gestures, defer)
|
||||
- Live mic waveform via Skia, amplitude polled at 30 Hz, scrolling buffer
|
||||
- Transcript text area above the waveform — empty during recording, populated on stop
|
||||
- State pill at top: `recording → uploading → transcribing → refining → done` (mirrors desktop pill semantics)
|
||||
- Captures list below: each row has a Skia mini-waveform, tap to expand, scrub, edit transcript inline, "Play as voice profile"
|
||||
- **Audio preserved + downloadable** — visually obvious in the UI; this is the USP
|
||||
- Schema/UI built so resumable capture can land later without re-architecting (no "one capture = one continuous take" assumptions baked in)
|
||||
|
||||
## Out of scope for V1 (deliberately)
|
||||
|
||||
Stories editor · Voice profile creation / sample recording · Effects editor · Personality LLM controls · Streaming transcription (lands with resumable capture in V2) · Settings beyond pairing · Android (pipeline supports it; QA focus iOS first)
|
||||
|
||||
## V2+ candidates
|
||||
|
||||
- **Resumable capture** — pause/resume that actually appends audio AND transcript (Apple Voice Notes drops the second-half transcript on resume; Voicebox shouldn't)
|
||||
- **Document mode** — refinement LLM writes/edits a markdown document live as you speak, including dictated edits ("change the second bullet to…")
|
||||
- **Streaming transcription** — partial transcripts during recording
|
||||
- **Voice profile creation on mobile** — record samples directly
|
||||
- **Android**
|
||||
- **Cloud relay** — once the Voicebox platform exists, the same E2E layer rides over a relay so pairing isn't tied to Tailscale/LAN
|
||||
|
||||
## Build/dev workflow
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
bun install
|
||||
bunx expo prebuild # generate native projects (Skia + SecureStore need it)
|
||||
bunx expo run:ios # device on the same Tailnet
|
||||
eas build --profile development # distributable Dev Client for TestFlight
|
||||
```
|
||||
|
||||
Bundle ID: `sh.voicebox.mobile` (need to register in App Store Connect).
|
||||
|
||||
## Order of attack
|
||||
|
||||
1. Backend: pairing endpoints + bearer middleware (desktop PR, can land before mobile)
|
||||
2. Mobile: Expo scaffold + NativeWind + theme tokens + Pair screen
|
||||
3. Mobile: Captures tab end-to-end — validates transport, E2E layer, audio upload, waveform, pill states all in one flow
|
||||
4. Mobile: Generate tab
|
||||
5. Mobile: Voices tab
|
||||
6. EAS dev build + TestFlight internal track
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **iOS only V1?** Default: yes.
|
||||
2. **NativeWind or hand-rolled styles?** Default: NativeWind — class parity with `app/` is worth the small bundler tax.
|
||||
3. **Crib patterns from the Spacedrive mobile app first**, or start clean from current Expo docs?
|
||||
4. **Bundle ID + display name** — confirm `sh.voicebox.mobile` / "Voicebox" before EAS setup.
|
||||
5. **Pair screen UX** — QR-only, or always offer the 6-digit code as an a11y/fallback?
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Voicebox",
|
||||
"slug": "voicebox",
|
||||
"scheme": "voicebox",
|
||||
"version": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0F0F0F"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "sh.voicebox.mobile"
|
||||
},
|
||||
"android": {
|
||||
"package": "sh.voicebox.mobile",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0F0F0F"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Voicebox uses the camera to scan the pairing QR shown by your desktop."
|
||||
}
|
||||
],
|
||||
"expo-audio"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Redirect, Tabs } from 'expo-router';
|
||||
import { Mic, PenTool, Users } from 'lucide-react-native';
|
||||
import { View } from 'react-native';
|
||||
import { colors } from '@/lib/colors';
|
||||
import { useSession } from '@/lib/session';
|
||||
|
||||
export default function TabsLayout() {
|
||||
const { session, hydrated } = useSession();
|
||||
|
||||
// Wait for SecureStore hydration before deciding where to send the user.
|
||||
// Without this we'd briefly render the tabs against a null session and
|
||||
// every authenticated query would fire with no bearer.
|
||||
if (!hydrated) {
|
||||
return <View style={{ flex: 1, backgroundColor: colors.background }} />;
|
||||
}
|
||||
if (!session) return <Redirect href="/welcome" />;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.card,
|
||||
borderTopColor: colors.border,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
tabBarActiveTintColor: colors.accent,
|
||||
tabBarInactiveTintColor: colors.mutedForeground,
|
||||
tabBarLabelStyle: { fontSize: 11, fontWeight: '600' },
|
||||
sceneStyle: { backgroundColor: colors.background },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Captures',
|
||||
tabBarIcon: ({ color, size }) => <Mic size={size - 2} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="generate"
|
||||
options={{
|
||||
title: 'Generate',
|
||||
tabBarIcon: ({ color, size }) => <PenTool size={size - 2} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="voices"
|
||||
options={{
|
||||
title: 'Voices',
|
||||
tabBarIcon: ({ color, size }) => <Users size={size - 2} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
|
||||
import { Loader2, Pause, Play, Sparkles, Volume2 } from 'lucide-react-native';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
authHeaders,
|
||||
buildGenerationAudioUrl,
|
||||
type HistoryResponse,
|
||||
type VoiceProfileResponse,
|
||||
} from '@/lib/api';
|
||||
import { colors } from '@/lib/colors';
|
||||
import { useGenerate, useGenerationPolling, useHistory, useProfiles } from '@/lib/hooks';
|
||||
import { useSession } from '@/lib/session';
|
||||
|
||||
export default function GenerateTab() {
|
||||
const session = useSession((s) => s.session);
|
||||
const profiles = useProfiles();
|
||||
const history = useHistory(20);
|
||||
const generateMutation = useGenerate();
|
||||
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||
const [text, setText] = useState('');
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const [nowPlayingId, setNowPlayingId] = useState<string | null>(null);
|
||||
|
||||
const polling = useGenerationPolling(pendingId);
|
||||
const playingSource = useMemo(() => {
|
||||
if (!session || !nowPlayingId) return null;
|
||||
return {
|
||||
uri: buildGenerationAudioUrl(session, nowPlayingId),
|
||||
headers: authHeaders(session),
|
||||
};
|
||||
}, [session, nowPlayingId]);
|
||||
const player = useAudioPlayer(playingSource ?? null);
|
||||
const playerStatus = useAudioPlayerStatus(player);
|
||||
|
||||
// Default-select first profile when the list arrives.
|
||||
useEffect(() => {
|
||||
if (selectedProfileId === null && profiles.data && profiles.data.length > 0) {
|
||||
setSelectedProfileId(profiles.data[0].id);
|
||||
}
|
||||
}, [profiles.data, selectedProfileId]);
|
||||
|
||||
// When the polled generation completes, auto-play it and clear pending.
|
||||
useEffect(() => {
|
||||
const data = polling.data;
|
||||
if (!data) return;
|
||||
if (data.status === 'completed') {
|
||||
setNowPlayingId(data.id);
|
||||
setPendingId(null);
|
||||
} else if (data.status === 'failed') {
|
||||
setPendingId(null);
|
||||
}
|
||||
}, [polling.data]);
|
||||
|
||||
// Auto-play when a new source comes in.
|
||||
useEffect(() => {
|
||||
if (!playingSource) return;
|
||||
const t = window.setTimeout(() => {
|
||||
try {
|
||||
player.play();
|
||||
} catch {
|
||||
// ignore — player may not be ready yet
|
||||
}
|
||||
}, 100);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [playingSource, player]);
|
||||
|
||||
const selectedProfile = profiles.data?.find((p) => p.id === selectedProfileId) ?? null;
|
||||
const isGenerating =
|
||||
generateMutation.isPending ||
|
||||
(pendingId !== null && polling.data?.status !== 'completed');
|
||||
|
||||
function handleGenerate() {
|
||||
if (!selectedProfile || !text.trim() || isGenerating) return;
|
||||
generateMutation.mutate(
|
||||
{
|
||||
profile_id: selectedProfile.id,
|
||||
text: text.trim(),
|
||||
language: selectedProfile.language,
|
||||
engine: selectedProfile.default_engine ?? undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (gen) => {
|
||||
setPendingId(gen.id);
|
||||
setText('');
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handlePlayHistory(item: HistoryResponse) {
|
||||
if (item.status !== 'completed') return;
|
||||
if (nowPlayingId === item.id) {
|
||||
// Toggle play/pause
|
||||
if (playerStatus.playing) player.pause();
|
||||
else player.play();
|
||||
return;
|
||||
}
|
||||
setNowPlayingId(item.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||
{/* Header */}
|
||||
<View className="px-6 py-3">
|
||||
<Text className="text-foreground text-2xl font-bold tracking-tight">Generate</Text>
|
||||
</View>
|
||||
|
||||
{/* Profile picker */}
|
||||
<View className="pb-3">
|
||||
{profiles.isLoading ? (
|
||||
<View className="px-6 h-24 justify-center">
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
) : profiles.data && profiles.data.length > 0 ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingHorizontal: 24, gap: 10 }}
|
||||
>
|
||||
{profiles.data.map((p) => (
|
||||
<ProfileCard
|
||||
key={p.id}
|
||||
profile={p}
|
||||
selected={p.id === selectedProfileId}
|
||||
onPress={() => setSelectedProfileId(p.id)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View className="px-6 py-4">
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
No profiles found on this desktop. Create one in Voicebox first.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Text input */}
|
||||
<View className="px-6 pb-3">
|
||||
<View className="rounded-2xl border border-border bg-card p-4 gap-3">
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder={
|
||||
selectedProfile
|
||||
? `Speak as ${selectedProfile.name}…`
|
||||
: 'Pick a voice above'
|
||||
}
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
multiline
|
||||
editable={!!selectedProfile && !isGenerating}
|
||||
className="text-foreground text-base min-h-[80px]"
|
||||
style={{ textAlignVertical: 'top' }}
|
||||
/>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
{text.length > 0 ? `${text.length} chars` : ' '}
|
||||
</Text>
|
||||
<Button
|
||||
label={isGenerating ? 'Generating' : 'Speak'}
|
||||
size="sm"
|
||||
loading={isGenerating}
|
||||
disabled={!selectedProfile || !text.trim() || isGenerating}
|
||||
onPress={handleGenerate}
|
||||
leftSlot={
|
||||
isGenerating ? null : <Sparkles size={14} color="#F2F2F2" />
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
{generateMutation.isError ? (
|
||||
<Text className="text-destructive text-xs">
|
||||
{(generateMutation.error as Error)?.message ?? 'Generate failed'}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Recent generations */}
|
||||
<View className="px-6 pt-2 pb-1">
|
||||
<Text className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Recent
|
||||
</Text>
|
||||
</View>
|
||||
<FlatList
|
||||
data={history.data?.items ?? []}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ paddingHorizontal: 24, paddingBottom: 80 }}
|
||||
ItemSeparatorComponent={() => <View className="h-2" />}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={history.isRefetching && !history.isLoading}
|
||||
onRefresh={() => history.refetch()}
|
||||
tintColor={colors.accent}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
history.isLoading ? (
|
||||
<View className="pt-6 items-center">
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-muted-foreground text-sm pt-4">
|
||||
Nothing yet — your generations will appear here.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<HistoryRow
|
||||
item={item}
|
||||
isActive={nowPlayingId === item.id}
|
||||
isPlaying={nowPlayingId === item.id && playerStatus.playing}
|
||||
onPress={() => handlePlayHistory(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileCard({
|
||||
profile,
|
||||
selected,
|
||||
onPress,
|
||||
}: {
|
||||
profile: VoiceProfileResponse;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className={`w-[140px] rounded-2xl px-4 py-3 border ${
|
||||
selected ? 'border-accent bg-accent/10' : 'border-border bg-card'
|
||||
}`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Voice ${profile.name}`}
|
||||
accessibilityState={{ selected }}
|
||||
>
|
||||
<View
|
||||
className={`h-9 w-9 rounded-full mb-2 items-center justify-center ${
|
||||
selected ? 'bg-accent' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<Volume2 size={16} color={selected ? '#F2F2F2' : colors.mutedForeground} />
|
||||
</View>
|
||||
<Text
|
||||
className="text-foreground text-sm font-semibold"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{profile.name}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-[11px]" numberOfLines={1}>
|
||||
{profile.language.toUpperCase()}
|
||||
{profile.voice_type === 'preset' ? ' · preset' : ''}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryRow({
|
||||
item,
|
||||
isActive,
|
||||
isPlaying,
|
||||
onPress,
|
||||
}: {
|
||||
item: HistoryResponse;
|
||||
isActive: boolean;
|
||||
isPlaying: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const isCompleted = item.status === 'completed';
|
||||
const isFailed = item.status === 'failed';
|
||||
const isPending = !isCompleted && !isFailed;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={!isCompleted}
|
||||
className={`flex-row items-center gap-3 rounded-xl px-3 py-3 border ${
|
||||
isActive
|
||||
? 'border-accent/50 bg-accent/10'
|
||||
: 'border-border bg-card'
|
||||
} ${!isCompleted ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<View
|
||||
className={`h-10 w-10 rounded-full items-center justify-center ${
|
||||
isActive ? 'bg-accent' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 size={16} color={colors.mutedForeground} />
|
||||
) : isPlaying ? (
|
||||
<Pause size={16} color="#F2F2F2" fill="#F2F2F2" />
|
||||
) : (
|
||||
<Play size={16} color={isActive ? '#F2F2F2' : colors.mutedForeground} fill={isActive ? '#F2F2F2' : 'transparent'} />
|
||||
)}
|
||||
</View>
|
||||
<View className="flex-1 min-w-0">
|
||||
<Text className="text-foreground text-sm" numberOfLines={2}>
|
||||
{item.text}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-[11px] mt-0.5">
|
||||
{item.profile_name}
|
||||
{item.duration ? ` · ${item.duration.toFixed(1)}s` : ''}
|
||||
{isFailed ? ' · failed' : ''}
|
||||
{isPending ? ' · generating…' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { LogOut, Mic, Pause, Play, Square, Trash2 } from 'lucide-react-native';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { LiveWaveform } from '@/components/ui/LiveWaveform';
|
||||
import {
|
||||
authHeaders,
|
||||
buildCaptureAudioUrl,
|
||||
deleteCapture,
|
||||
type CaptureResponse,
|
||||
} from '@/lib/api';
|
||||
import { colors } from '@/lib/colors';
|
||||
import { useDictation, type DictationPhase } from '@/lib/dictation';
|
||||
import { useCaptures, useInvalidateCaptures } from '@/lib/hooks';
|
||||
import { useSession } from '@/lib/session';
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (sec < 60) return 'just now';
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
|
||||
if (sec < 86400 * 7) return `${Math.floor(sec / 86400)}d ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (!ms) return '';
|
||||
const total = Math.round(ms / 1000);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const total = Math.max(0, Math.floor(seconds));
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function pillCopyFor(phase: DictationPhase, durationSec: number): string {
|
||||
if (phase === 'recording') {
|
||||
const m = Math.floor(durationSec / 60);
|
||||
const s = durationSec % 60;
|
||||
return `Recording ${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
if (phase === 'processing') return 'Transcribing…';
|
||||
if (phase === 'error') return 'Error';
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function CapturesTab() {
|
||||
const router = useRouter();
|
||||
const session = useSession((s) => s.session);
|
||||
const clearSession = useSession((s) => s.clear);
|
||||
const captures = useCaptures();
|
||||
const invalidateCaptures = useInvalidateCaptures();
|
||||
const dictation = useDictation();
|
||||
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// Single shared player instance for whichever capture is currently expanded.
|
||||
const playerSource = useMemo(() => {
|
||||
if (!session || !expandedId) return null;
|
||||
return {
|
||||
uri: buildCaptureAudioUrl(session, expandedId),
|
||||
headers: authHeaders(session),
|
||||
};
|
||||
}, [session, expandedId]);
|
||||
const player = useAudioPlayer(playerSource ?? null);
|
||||
const status = useAudioPlayerStatus(player);
|
||||
|
||||
// Pulse the mic button while recording.
|
||||
const pulse = useRef(new Animated.Value(1)).current;
|
||||
useEffect(() => {
|
||||
if (dictation.phase !== 'recording') {
|
||||
pulse.setValue(1);
|
||||
return;
|
||||
}
|
||||
const loop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 1.12, duration: 700, useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [dictation.phase, pulse]);
|
||||
|
||||
async function handleMicPress() {
|
||||
if (dictation.phase === 'recording') {
|
||||
const result = await dictation.stop();
|
||||
if (result) invalidateCaptures();
|
||||
return;
|
||||
}
|
||||
if (dictation.phase === 'processing') return;
|
||||
void dictation.start();
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await clearSession();
|
||||
router.replace('/welcome');
|
||||
}
|
||||
|
||||
function toggleExpanded(captureId: string) {
|
||||
if (expandedId === captureId) {
|
||||
setExpandedId(null);
|
||||
} else {
|
||||
setExpandedId(captureId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(capture: CaptureResponse) {
|
||||
if (!session) return;
|
||||
if (expandedId === capture.id) setExpandedId(null);
|
||||
try {
|
||||
await deleteCapture(session, capture.id);
|
||||
invalidateCaptures();
|
||||
} catch (e) {
|
||||
console.warn('Delete failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
const items = captures.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center justify-between px-6 py-3">
|
||||
<Text className="text-foreground text-2xl font-bold tracking-tight">Captures</Text>
|
||||
<Pressable
|
||||
onPress={handleSignOut}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Sign out"
|
||||
accessibilityRole="button"
|
||||
className="h-9 w-9 rounded-full items-center justify-center"
|
||||
>
|
||||
<LogOut size={18} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Recording HUD: live waveform + timer while recording. Falls back to a
|
||||
compact pill for processing/error so we don't reserve the screen
|
||||
space while transcription runs. */}
|
||||
{dictation.phase === 'recording' ? (
|
||||
<View className="px-6 pb-2 gap-2">
|
||||
<View className="rounded-2xl bg-destructive/10 border border-destructive/30 px-4 py-3 gap-2">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<View className="h-2 w-2 rounded-full bg-destructive" />
|
||||
<Text className="text-foreground text-xs font-semibold uppercase tracking-widest">
|
||||
Recording
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="text-foreground text-xs font-semibold"
|
||||
style={{ fontFamily: 'Menlo' }}
|
||||
>
|
||||
{`${Math.floor(dictation.durationSec / 60)}:${(dictation.durationSec % 60).toString().padStart(2, '0')}`}
|
||||
</Text>
|
||||
</View>
|
||||
<LiveWaveform active={true} levelDb={dictation.meteringDb} />
|
||||
</View>
|
||||
</View>
|
||||
) : dictation.phase !== 'idle' ? (
|
||||
<View className="px-6 pb-2">
|
||||
<View
|
||||
className={`self-center flex-row items-center gap-2 px-4 py-1.5 rounded-full ${
|
||||
dictation.phase === 'error' ? 'bg-destructive/15' : 'bg-accent/15'
|
||||
}`}
|
||||
>
|
||||
{dictation.phase === 'processing' ? (
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
) : null}
|
||||
<Text
|
||||
className={`text-xs font-semibold ${
|
||||
dictation.phase === 'error' ? 'text-destructive' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{pillCopyFor(dictation.phase, dictation.durationSec)}
|
||||
</Text>
|
||||
</View>
|
||||
{dictation.error ? (
|
||||
<Text className="text-destructive text-xs text-center mt-2">{dictation.error}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* List */}
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(c) => c.id}
|
||||
contentContainerStyle={{ paddingHorizontal: 24, paddingTop: 8, paddingBottom: 180 }}
|
||||
ItemSeparatorComponent={() => <View className="h-3" />}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={captures.isRefetching && !captures.isLoading}
|
||||
onRefresh={() => captures.refetch()}
|
||||
tintColor={colors.accent}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
captures.isLoading ? (
|
||||
<View className="items-center pt-16">
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
) : (
|
||||
<EmptyState />
|
||||
)
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<CaptureRow
|
||||
capture={item}
|
||||
expanded={expandedId === item.id}
|
||||
isPlaying={expandedId === item.id && status.playing}
|
||||
currentTime={expandedId === item.id ? status.currentTime ?? 0 : 0}
|
||||
duration={
|
||||
expandedId === item.id
|
||||
? status.duration ?? (item.duration_ms ? item.duration_ms / 1000 : 0)
|
||||
: item.duration_ms
|
||||
? item.duration_ms / 1000
|
||||
: 0
|
||||
}
|
||||
onPress={() => toggleExpanded(item.id)}
|
||||
onPlayPause={() => {
|
||||
if (status.playing) player.pause();
|
||||
else player.play();
|
||||
}}
|
||||
onDelete={() => handleDelete(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Floating mic */}
|
||||
<View pointerEvents="box-none" className="absolute left-0 right-0" style={{ bottom: 24 }}>
|
||||
<View className="items-center">
|
||||
<Animated.View style={{ transform: [{ scale: pulse }] }}>
|
||||
<Pressable
|
||||
onPress={handleMicPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
dictation.phase === 'recording' ? 'Stop recording' : 'Start recording'
|
||||
}
|
||||
disabled={dictation.phase === 'processing'}
|
||||
style={({ pressed }) => ({
|
||||
width: 78,
|
||||
height: 78,
|
||||
borderRadius: 39,
|
||||
backgroundColor:
|
||||
dictation.phase === 'recording' ? colors.destructive : colors.accent,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
shadowColor:
|
||||
dictation.phase === 'recording' ? colors.destructive : colors.accent,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 18,
|
||||
elevation: 10,
|
||||
})}
|
||||
>
|
||||
{dictation.phase === 'processing' ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : dictation.phase === 'recording' ? (
|
||||
<Square size={28} color="#fff" fill="#fff" />
|
||||
) : (
|
||||
<Mic size={32} color="#fff" />
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function CaptureRow({
|
||||
capture,
|
||||
expanded,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
onPress,
|
||||
onPlayPause,
|
||||
onDelete,
|
||||
}: {
|
||||
capture: CaptureResponse;
|
||||
expanded: boolean;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onPress: () => void;
|
||||
onPlayPause: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const transcript = capture.transcript_refined?.trim() || capture.transcript_raw.trim();
|
||||
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className={`rounded-2xl border px-4 py-3 gap-2 ${
|
||||
expanded ? 'border-accent/50 bg-accent/5' : 'border-border bg-card'
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-muted-foreground text-[11px] font-semibold uppercase tracking-widest">
|
||||
{formatRelative(capture.created_at)}
|
||||
{capture.duration_ms ? ` · ${formatDuration(capture.duration_ms)}` : ''}
|
||||
</Text>
|
||||
{capture.transcript_refined ? (
|
||||
<View className="px-1.5 py-0.5 rounded-sm bg-accent/15">
|
||||
<Text className="text-accent text-[10px] font-semibold">REFINED</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text
|
||||
className="text-foreground text-sm leading-snug"
|
||||
numberOfLines={expanded ? undefined : 6}
|
||||
>
|
||||
{transcript || '(empty transcript)'}
|
||||
</Text>
|
||||
|
||||
{expanded ? (
|
||||
<View className="pt-2 gap-2 border-t border-border/60">
|
||||
{/* Progress bar */}
|
||||
<View className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<View
|
||||
className="h-full bg-accent"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-muted-foreground text-[11px]" style={{ fontFamily: 'Menlo' }}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
hitSlop={8}
|
||||
className="h-8 w-8 rounded-full items-center justify-center"
|
||||
accessibilityLabel="Delete capture"
|
||||
>
|
||||
<Trash2 size={14} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlayPause();
|
||||
}}
|
||||
hitSlop={8}
|
||||
className="h-9 w-9 rounded-full items-center justify-center bg-accent"
|
||||
accessibilityLabel={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} color="#F2F2F2" fill="#F2F2F2" />
|
||||
) : (
|
||||
<Play size={16} color="#F2F2F2" fill="#F2F2F2" />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<View className="items-center pt-20 px-6 gap-3">
|
||||
<View className="h-14 w-14 rounded-full bg-accent/10 items-center justify-center">
|
||||
<Mic size={22} color={colors.accent} />
|
||||
</View>
|
||||
<Text className="text-foreground text-base font-semibold">No captures yet</Text>
|
||||
<Text className="text-muted-foreground text-sm text-center max-w-[260px]">
|
||||
Tap the mic to dictate. Audio and transcript both stay on your desktop — Voicebox keeps every recording.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Mic, Sparkles, User2, Users, Volume2 } from 'lucide-react-native';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { type VoiceProfileResponse } from '@/lib/api';
|
||||
import { colors } from '@/lib/colors';
|
||||
import { useProfiles } from '@/lib/hooks';
|
||||
|
||||
export default function VoicesTab() {
|
||||
const profiles = useProfiles();
|
||||
const [query, setQuery] = useState('');
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const items = profiles.data ?? [];
|
||||
if (!query.trim()) return items;
|
||||
const q = query.trim().toLowerCase();
|
||||
return items.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.description?.toLowerCase().includes(q) ||
|
||||
p.language.toLowerCase().includes(q),
|
||||
);
|
||||
}, [profiles.data, query]);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||
<View className="px-6 py-3 gap-3">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-foreground text-2xl font-bold tracking-tight">Voices</Text>
|
||||
{profiles.data ? (
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
{profiles.data.length} {profiles.data.length === 1 ? 'voice' : 'voices'}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search voices…"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
className="rounded-xl border border-border bg-card px-4 py-2.5 text-foreground"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={filtered}
|
||||
keyExtractor={(p) => p.id}
|
||||
contentContainerStyle={{ paddingHorizontal: 24, paddingBottom: 80 }}
|
||||
ItemSeparatorComponent={() => <View className="h-2" />}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={profiles.isRefetching && !profiles.isLoading}
|
||||
onRefresh={() => profiles.refetch()}
|
||||
tintColor={colors.accent}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
profiles.isLoading ? (
|
||||
<View className="pt-12 items-center">
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
) : query ? (
|
||||
<Text className="text-muted-foreground text-sm pt-6 text-center">
|
||||
No voices match "{query}".
|
||||
</Text>
|
||||
) : (
|
||||
<EmptyState />
|
||||
)
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<ProfileRow
|
||||
profile={item}
|
||||
expanded={expandedId === item.id}
|
||||
onPress={() => setExpandedId(expandedId === item.id ? null : item.id)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileRow({
|
||||
profile,
|
||||
expanded,
|
||||
onPress,
|
||||
}: {
|
||||
profile: VoiceProfileResponse;
|
||||
expanded: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const kindIcon =
|
||||
profile.voice_type === 'preset' ? Sparkles : profile.voice_type === 'designed' ? User2 : Mic;
|
||||
const KindIcon = kindIcon;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className={`rounded-2xl border px-4 py-3 ${
|
||||
expanded ? 'border-accent/40 bg-accent/5' : 'border-border bg-card'
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View
|
||||
className={`h-11 w-11 rounded-full items-center justify-center ${
|
||||
profile.voice_type === 'preset' ? 'bg-accent/15' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<Volume2 size={18} color={profile.voice_type === 'preset' ? colors.accent : colors.mutedForeground} />
|
||||
</View>
|
||||
<View className="flex-1 min-w-0">
|
||||
<Text className="text-foreground text-sm font-semibold" numberOfLines={1}>
|
||||
{profile.name}
|
||||
</Text>
|
||||
<View className="flex-row items-center gap-2 mt-0.5">
|
||||
<Text className="text-muted-foreground text-[11px] uppercase tracking-wider">
|
||||
{profile.language}
|
||||
</Text>
|
||||
<KindBadge kind={profile.voice_type} />
|
||||
<Text className="text-muted-foreground text-[11px]">
|
||||
{profile.generation_count} gens
|
||||
{profile.voice_type === 'cloned' ? ` · ${profile.sample_count} samples` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<KindIcon size={16} color={colors.mutedForeground} />
|
||||
</View>
|
||||
{expanded && profile.description ? (
|
||||
<View className="mt-3 pt-3 border-t border-border/60">
|
||||
<Text className="text-muted-foreground text-sm leading-snug">
|
||||
{profile.description}
|
||||
</Text>
|
||||
{profile.default_engine ? (
|
||||
<Text className="text-muted-foreground text-[11px] uppercase tracking-wider mt-2">
|
||||
engine · {profile.default_engine}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function KindBadge({ kind }: { kind: VoiceProfileResponse['voice_type'] }) {
|
||||
const label =
|
||||
kind === 'preset' ? 'PRESET' : kind === 'designed' ? 'DESIGNED' : 'CLONED';
|
||||
return (
|
||||
<View className="px-1.5 py-0.5 rounded-sm bg-muted">
|
||||
<Text className="text-muted-foreground text-[9px] font-semibold tracking-widest">
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<View className="items-center pt-16 px-6 gap-3">
|
||||
<View className="h-14 w-14 rounded-full bg-accent/10 items-center justify-center">
|
||||
<Users size={22} color={colors.accent} />
|
||||
</View>
|
||||
<Text className="text-foreground text-base font-semibold">No voices yet</Text>
|
||||
<Text className="text-muted-foreground text-sm text-center max-w-[260px]">
|
||||
Create voice profiles in your Voicebox desktop — they'll appear here automatically.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import '../global.css';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { colors } from '@/lib/colors';
|
||||
import { useSession } from '@/lib/session';
|
||||
|
||||
export default function RootLayout() {
|
||||
const hydrate = useSession((s) => s.hydrate);
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<SafeAreaProvider>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
animation: 'fade',
|
||||
}}
|
||||
/>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ApiError, completePair } from '@/lib/api';
|
||||
import { colors } from '@/lib/colors';
|
||||
import { parsePairUrl } from '@/lib/pairing';
|
||||
import { useSession } from '@/lib/session';
|
||||
|
||||
export default function PairScreen() {
|
||||
const router = useRouter();
|
||||
const setSession = useSession((s) => s.setSession);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [deviceName, setDeviceName] = useState('iPhone');
|
||||
const [manualUrl, setManualUrl] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Guard against the camera firing onBarcodeScanned multiple times for the
|
||||
// same code while the request is in flight.
|
||||
const scannedRef = useRef(false);
|
||||
|
||||
async function handlePair(host: string, token: string) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await completePair(host, token, deviceName.trim() || 'Mobile');
|
||||
await setSession({
|
||||
host,
|
||||
bearer: res.bearer,
|
||||
deviceId: res.device_id,
|
||||
deviceName: res.device_name,
|
||||
});
|
||||
router.replace('/');
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof ApiError
|
||||
? e.message
|
||||
: e instanceof Error
|
||||
? `${e.message} — is the desktop reachable at this address?`
|
||||
: 'Pair failed';
|
||||
setError(msg);
|
||||
scannedRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBarcode({ data }: { data: string }) {
|
||||
if (scannedRef.current) return;
|
||||
const payload = parsePairUrl(data);
|
||||
if (!payload) return;
|
||||
scannedRef.current = true;
|
||||
void handlePair(payload.host, payload.token);
|
||||
}
|
||||
|
||||
function handleManualPair() {
|
||||
const payload = parsePairUrl(manualUrl);
|
||||
if (!payload) {
|
||||
setError("That doesn't look like a Voicebox pairing URL.");
|
||||
return;
|
||||
}
|
||||
void handlePair(payload.host, payload.token);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View className="flex-row items-center justify-between px-6 pt-2 pb-4">
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
hitSlop={12}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel pairing"
|
||||
>
|
||||
<Text className="text-muted-foreground text-sm">Cancel</Text>
|
||||
</Pressable>
|
||||
<Text className="text-foreground text-base font-semibold">Pair device</Text>
|
||||
<View style={{ width: 60 }} />
|
||||
</View>
|
||||
|
||||
<View className="px-6 gap-4">
|
||||
<Text className="text-muted-foreground text-sm leading-snug">
|
||||
On your desktop, open Voicebox → Settings → Mobile → Pair device, then point your camera at the QR code.
|
||||
</Text>
|
||||
|
||||
<View className="gap-2">
|
||||
<Text className="text-muted-foreground text-[11px] font-semibold uppercase tracking-widest">
|
||||
Device name
|
||||
</Text>
|
||||
<TextInput
|
||||
value={deviceName}
|
||||
onChangeText={setDeviceName}
|
||||
placeholder="iPhone"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
className="bg-card border border-border rounded-lg px-4 py-3 text-foreground"
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-1 mx-6 mt-4 rounded-2xl overflow-hidden bg-card border border-border items-center justify-center">
|
||||
{permission?.granted ? (
|
||||
<CameraView
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={busy ? undefined : handleBarcode}
|
||||
/>
|
||||
) : (
|
||||
<View className="items-center gap-4 px-8">
|
||||
<Text className="text-foreground text-base text-center">
|
||||
Camera access lets you scan the pairing QR.
|
||||
</Text>
|
||||
<Button
|
||||
label={permission ? 'Allow camera' : 'Allow camera'}
|
||||
onPress={() => {
|
||||
void requestPermission();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="px-6 pt-4 pb-2 gap-3 border-t border-border mt-4">
|
||||
<Text className="text-muted-foreground text-[11px] font-semibold uppercase tracking-widest">
|
||||
Or paste pairing URL
|
||||
</Text>
|
||||
<TextInput
|
||||
value={manualUrl}
|
||||
onChangeText={setManualUrl}
|
||||
placeholder="voicebox://pair?host=…&token=…"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
multiline
|
||||
className="bg-card border border-border rounded-lg px-4 py-3 text-foreground"
|
||||
style={{ minHeight: 60, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace' }}
|
||||
/>
|
||||
{error ? (
|
||||
<Text className="text-destructive text-sm">{error}</Text>
|
||||
) : null}
|
||||
<Button
|
||||
label={busy ? 'Pairing…' : 'Pair'}
|
||||
onPress={handleManualPair}
|
||||
loading={busy}
|
||||
disabled={!manualUrl.trim() || busy}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { WelcomeScreen } from '@/screens/WelcomeScreen';
|
||||
|
||||
export default function WelcomeRoute() {
|
||||
const router = useRouter();
|
||||
return <WelcomeScreen onGetStarted={() => router.push('/pair')} />;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 311 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 311 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 311 KiB |
@@ -0,0 +1,9 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
||||
'nativewind/babel',
|
||||
],
|
||||
};
|
||||
};
|
||||
+1763
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,6 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const { withNativeWind } = require('nativewind/metro');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = withNativeWind(config, { input: './global.css' });
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"expo": "~54.0.33",
|
||||
"expo-audio": "~1.1.1",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"lucide-react-native": "^1.11.0",
|
||||
"nativewind": "4.2.3",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { forwardRef } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
type PressableProps,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { colors } from '@/lib/colors';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost';
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
type Props = Omit<PressableProps, 'children'> & {
|
||||
label: string;
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
loading?: boolean;
|
||||
leftSlot?: React.ReactNode;
|
||||
rightSlot?: React.ReactNode;
|
||||
};
|
||||
|
||||
const containerBase =
|
||||
'flex-row items-center justify-center rounded-full active:opacity-90';
|
||||
|
||||
const variantContainer: Record<Variant, string> = {
|
||||
primary: 'bg-accent',
|
||||
secondary: 'bg-card border border-border',
|
||||
ghost: 'bg-transparent',
|
||||
};
|
||||
|
||||
const variantLabel: Record<Variant, string> = {
|
||||
primary: 'text-accent-foreground',
|
||||
secondary: 'text-foreground',
|
||||
ghost: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
const sizeContainer: Record<Size, string> = {
|
||||
sm: 'px-4 py-2 gap-1.5',
|
||||
md: 'px-6 py-3 gap-2',
|
||||
lg: 'px-8 py-4 gap-2.5',
|
||||
};
|
||||
|
||||
const sizeLabel: Record<Size, string> = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
};
|
||||
|
||||
// Mirrors the desktop CTA in landing/src/components/SponsorPromo.tsx —
|
||||
// rounded-full, gold accent, uppercase semibold label with wide tracking.
|
||||
export const Button = forwardRef<View, Props>(function Button(
|
||||
{
|
||||
label,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
disabled,
|
||||
style,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const isPrimary = variant === 'primary';
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled: isDisabled, busy: loading }}
|
||||
disabled={isDisabled}
|
||||
style={[
|
||||
isPrimary && {
|
||||
shadowColor: colors.accent,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 16,
|
||||
elevation: 6,
|
||||
},
|
||||
isDisabled && { opacity: 0.5 },
|
||||
style as object,
|
||||
]}
|
||||
className={`${containerBase} ${variantContainer[variant]} ${sizeContainer[size]}`}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={isPrimary ? colors.accentForeground : colors.foreground}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{leftSlot}
|
||||
<Text
|
||||
className={`font-semibold uppercase ${variantLabel[variant]} ${sizeLabel[size]}`}
|
||||
style={{ letterSpacing: 1.2 }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{rightSlot}
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { colors } from '@/lib/colors';
|
||||
|
||||
const BAR_COUNT = 36;
|
||||
const MIN_BAR = 0.1;
|
||||
|
||||
/**
|
||||
* Scrolling-buffer waveform driven by mic metering. Each metering tick
|
||||
* pushes a new bar onto the right and drops the oldest from the left, so
|
||||
* the waveform reads as a 36-bar sliding window of the last few seconds
|
||||
* of audio. Pure React Native — no Skia, no native modules.
|
||||
*
|
||||
* ``levelDb`` should be the value out of ``RecorderState.metering`` (a
|
||||
* dB-scale instantaneous level, typically -160 to 0).
|
||||
*/
|
||||
export function LiveWaveform({
|
||||
active,
|
||||
levelDb,
|
||||
}: {
|
||||
active: boolean;
|
||||
levelDb: number | undefined;
|
||||
}) {
|
||||
const [buffer, setBuffer] = useState<number[]>(() => Array(BAR_COUNT).fill(MIN_BAR));
|
||||
|
||||
// Push a new bar each time the metering value changes (which happens
|
||||
// every poll interval — set to ~80ms in dictation.ts).
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const norm = normalize(levelDb);
|
||||
setBuffer((prev) => [...prev.slice(1), norm]);
|
||||
}, [levelDb, active]);
|
||||
|
||||
// Decay back to flat when not recording.
|
||||
useEffect(() => {
|
||||
if (active) return;
|
||||
setBuffer(Array(BAR_COUNT).fill(MIN_BAR));
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-row items-center justify-center"
|
||||
style={{ height: 48, gap: 3 }}
|
||||
>
|
||||
{buffer.map((h, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={{
|
||||
width: 3,
|
||||
height: `${Math.round(h * 100)}%`,
|
||||
backgroundColor: active ? colors.destructive : colors.mutedForeground,
|
||||
borderRadius: 1.5,
|
||||
opacity: active ? 0.85 : 0.4,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map dB (-160 → 0) to display height (0.1 → 1.0).
|
||||
*
|
||||
* Speech typically lives in the -40 dB → -10 dB range; we compress that
|
||||
* into the visible part of the bar so quiet speech still moves the bars
|
||||
* visibly. Returns MIN_BAR for null/undefined so a missing metering field
|
||||
* just shows a flat baseline rather than breaking the waveform.
|
||||
*/
|
||||
function normalize(db: number | undefined): number {
|
||||
if (db == null || !Number.isFinite(db)) return MIN_BAR;
|
||||
// Clamp to a useful range and normalize.
|
||||
const clamped = Math.max(-60, Math.min(0, db));
|
||||
// Linear in dB (close enough — psychoacoustic-correct curves are V2).
|
||||
const linear = (clamped + 60) / 60;
|
||||
// Power curve to favor mid-range — keeps the bars feeling responsive.
|
||||
const shaped = Math.pow(linear, 1.6);
|
||||
return Math.max(MIN_BAR, Math.min(1, shaped));
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { StoredSession } from './storage';
|
||||
|
||||
// Voicebox HTTP client. V0 transport is plain HTTP-over-LAN/Tailscale with a
|
||||
// bearer header. Phase 2 will wrap payloads in XChaCha20-Poly1305 — this
|
||||
// module is the single chokepoint for that future change.
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export type PairCompleteResponse = {
|
||||
device_id: string;
|
||||
bearer: string;
|
||||
device_name: string;
|
||||
};
|
||||
|
||||
export type MeResponse = {
|
||||
device_id: string;
|
||||
device_name: string;
|
||||
last_seen_at: string | null;
|
||||
};
|
||||
|
||||
function buildUrl(host: string, path: string): string {
|
||||
// host may include a port (e.g. "192.168.1.5:17494"); just slap http:// on.
|
||||
// Phase 2 should require https or self-signed-with-pinned-fp; for V0 plain
|
||||
// HTTP over a trusted LAN/Tailnet is the deal we made.
|
||||
return `http://${host}${path}`;
|
||||
}
|
||||
|
||||
async function parseError(res: Response): Promise<string> {
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (typeof body?.detail === 'string') return body.detail;
|
||||
return JSON.stringify(body);
|
||||
} catch {
|
||||
return res.statusText || `HTTP ${res.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Unauthenticated (pre-pair) ---------------------------------------------
|
||||
|
||||
export async function completePair(
|
||||
host: string,
|
||||
token: string,
|
||||
deviceName: string,
|
||||
): Promise<PairCompleteResponse> {
|
||||
const res = await fetch(buildUrl(host, '/pair/complete'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, device_name: deviceName }),
|
||||
});
|
||||
if (!res.ok) throw new ApiError(res.status, await parseError(res));
|
||||
return (await res.json()) as PairCompleteResponse;
|
||||
}
|
||||
|
||||
// --- Authenticated (post-pair) ----------------------------------------------
|
||||
|
||||
async function authedFetch(
|
||||
session: StoredSession,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Authorization', `Bearer ${session.bearer}`);
|
||||
if (init.body && !headers.has('Content-Type') && !(init.body instanceof FormData)) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
const res = await fetch(buildUrl(session.host, path), { ...init, headers });
|
||||
if (!res.ok) throw new ApiError(res.status, await parseError(res));
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function getMe(session: StoredSession): Promise<MeResponse> {
|
||||
const res = await authedFetch(session, '/me');
|
||||
return (await res.json()) as MeResponse;
|
||||
}
|
||||
|
||||
// --- Captures ---------------------------------------------------------------
|
||||
|
||||
export type CaptureSource = 'dictation' | 'recording' | 'file';
|
||||
|
||||
export type CaptureResponse = {
|
||||
id: string;
|
||||
audio_path: string;
|
||||
source: CaptureSource;
|
||||
language: string | null;
|
||||
duration_ms: number | null;
|
||||
transcript_raw: string;
|
||||
transcript_refined: string | null;
|
||||
stt_model: string | null;
|
||||
llm_model: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CaptureListResponse = {
|
||||
items: CaptureResponse[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type CaptureCreateResponse = CaptureResponse & {
|
||||
auto_refine: boolean;
|
||||
allow_auto_paste: boolean;
|
||||
};
|
||||
|
||||
export async function listCaptures(
|
||||
session: StoredSession,
|
||||
opts: { limit?: number; offset?: number } = {},
|
||||
): Promise<CaptureListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||
const qs = params.toString();
|
||||
const res = await authedFetch(session, `/captures${qs ? `?${qs}` : ''}`);
|
||||
return (await res.json()) as CaptureListResponse;
|
||||
}
|
||||
|
||||
export async function uploadCapture(
|
||||
session: StoredSession,
|
||||
audioUri: string,
|
||||
opts: { filename?: string; mimeType?: string; source?: CaptureSource } = {},
|
||||
): Promise<CaptureCreateResponse> {
|
||||
const filename = opts.filename ?? 'capture.m4a';
|
||||
const mimeType = opts.mimeType ?? 'audio/m4a';
|
||||
const source = opts.source ?? 'dictation';
|
||||
|
||||
const form = new FormData();
|
||||
// React Native's FormData accepts {uri, name, type} for file fields; the
|
||||
// typing pretends it doesn't, so we cast.
|
||||
form.append('file', {
|
||||
uri: audioUri,
|
||||
name: filename,
|
||||
type: mimeType,
|
||||
} as unknown as Blob);
|
||||
form.append('source', source);
|
||||
|
||||
const res = await authedFetch(session, '/captures', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
return (await res.json()) as CaptureCreateResponse;
|
||||
}
|
||||
|
||||
export async function deleteCapture(
|
||||
session: StoredSession,
|
||||
captureId: string,
|
||||
): Promise<void> {
|
||||
await authedFetch(session, `/captures/${captureId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function buildCaptureAudioUrl(session: StoredSession, captureId: string): string {
|
||||
return buildUrl(session.host, `/captures/${captureId}/audio`);
|
||||
}
|
||||
|
||||
// --- Voice profiles ---------------------------------------------------------
|
||||
|
||||
export type VoiceProfileResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
language: string;
|
||||
avatar_path: string | null;
|
||||
voice_type: 'cloned' | 'preset' | 'designed';
|
||||
preset_engine: string | null;
|
||||
preset_voice_id: string | null;
|
||||
default_engine: string | null;
|
||||
generation_count: number;
|
||||
sample_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export async function listProfiles(session: StoredSession): Promise<VoiceProfileResponse[]> {
|
||||
const res = await authedFetch(session, '/profiles');
|
||||
return (await res.json()) as VoiceProfileResponse[];
|
||||
}
|
||||
|
||||
// --- Generations ------------------------------------------------------------
|
||||
|
||||
export type GenerationStatus = 'pending' | 'completed' | 'failed';
|
||||
|
||||
export type GenerationResponse = {
|
||||
id: string;
|
||||
profile_id: string;
|
||||
text: string;
|
||||
language: string;
|
||||
audio_path: string | null;
|
||||
duration: number | null;
|
||||
seed: number | null;
|
||||
engine: string | null;
|
||||
status: GenerationStatus | string;
|
||||
error: string | null;
|
||||
is_favorited: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type HistoryResponse = GenerationResponse & {
|
||||
profile_name: string;
|
||||
};
|
||||
|
||||
export type HistoryListResponse = {
|
||||
items: HistoryResponse[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type GenerateRequest = {
|
||||
profile_id: string;
|
||||
text: string;
|
||||
language?: string;
|
||||
engine?: string;
|
||||
seed?: number;
|
||||
personality?: boolean;
|
||||
};
|
||||
|
||||
export async function generate(
|
||||
session: StoredSession,
|
||||
req: GenerateRequest,
|
||||
): Promise<GenerationResponse> {
|
||||
const res = await authedFetch(session, '/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
return (await res.json()) as GenerationResponse;
|
||||
}
|
||||
|
||||
export async function getHistoryItem(
|
||||
session: StoredSession,
|
||||
generationId: string,
|
||||
): Promise<HistoryResponse> {
|
||||
const res = await authedFetch(session, `/history/${generationId}`);
|
||||
return (await res.json()) as HistoryResponse;
|
||||
}
|
||||
|
||||
export async function listHistory(
|
||||
session: StoredSession,
|
||||
opts: { limit?: number; offset?: number; profile_id?: string } = {},
|
||||
): Promise<HistoryListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||
if (opts.profile_id) params.set('profile_id', opts.profile_id);
|
||||
const qs = params.toString();
|
||||
const res = await authedFetch(session, `/history${qs ? `?${qs}` : ''}`);
|
||||
return (await res.json()) as HistoryListResponse;
|
||||
}
|
||||
|
||||
export function buildGenerationAudioUrl(
|
||||
session: StoredSession,
|
||||
generationId: string,
|
||||
): string {
|
||||
return buildUrl(session.host, `/audio/${generationId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication header for streaming sources where a separate fetch isn't
|
||||
* possible (expo-audio's source object accepts headers directly).
|
||||
*/
|
||||
export function authHeaders(session: StoredSession): Record<string, string> {
|
||||
return { Authorization: `Bearer ${session.bearer}` };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Color tokens mirroring the desktop dark theme (`app/src/index.css` `.dark`).
|
||||
// Use NativeWind classes (`bg-accent`, `text-foreground`, …) in components;
|
||||
// this constants file is for places NativeWind doesn't reach — StatusBar tint,
|
||||
// native module props, Skia, animated colors.
|
||||
|
||||
export const colors = {
|
||||
background: '#0F0F0F', // hsl(0 0% 6%)
|
||||
foreground: '#F2F2F2', // hsl(0 0% 95%)
|
||||
card: '#141414', // hsl(0 0% 8%)
|
||||
border: '#1F1F1F', // hsl(0 0% 12%)
|
||||
muted: '#1F1F1F',
|
||||
mutedForeground: '#999999', // hsl(0 0% 60%)
|
||||
accent: '#AC8C39', // hsl(43 50% 45%) — the gold
|
||||
accentFaint: '#916F2D', // hsl(43 50% 38%)
|
||||
accentForeground: '#F2F2F2',
|
||||
destructive: '#CD3535', // hsl(0 62.8% 50%)
|
||||
ring: '#666666', // hsl(0 0% 40%)
|
||||
} as const;
|
||||
|
||||
export type ColorKey = keyof typeof colors;
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
AudioModule,
|
||||
RecordingPresets,
|
||||
setAudioModeAsync,
|
||||
useAudioRecorder,
|
||||
useAudioRecorderState,
|
||||
} from 'expo-audio';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { uploadCapture, type CaptureCreateResponse } from './api';
|
||||
import { useSession } from './session';
|
||||
|
||||
export type DictationPhase = 'idle' | 'recording' | 'processing' | 'error';
|
||||
|
||||
export type DictationFlow = {
|
||||
phase: DictationPhase;
|
||||
durationSec: number;
|
||||
/** Instantaneous mic level in dB (typically -160 → 0). undefined when not recording or metering disabled. */
|
||||
meteringDb: number | undefined;
|
||||
error: string | null;
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<CaptureCreateResponse | null>;
|
||||
isBusy: boolean;
|
||||
};
|
||||
|
||||
// HIGH_QUALITY + metering enabled so the screen can render a live waveform
|
||||
// while recording. Metering is per-poll-interval, so we ask for ~80ms ticks.
|
||||
const RECORDING_OPTIONS = {
|
||||
...RecordingPresets.HIGH_QUALITY,
|
||||
isMeteringEnabled: true,
|
||||
};
|
||||
const RECORDER_POLL_INTERVAL_MS = 80;
|
||||
|
||||
/**
|
||||
* Wraps expo-audio's recorder + the /captures upload into a single
|
||||
* lifecycle: tap-to-start → tap-to-stop → upload → transcribed capture.
|
||||
*
|
||||
* Synchronous from the caller's POV: ``stop()`` resolves with the new
|
||||
* capture (transcript and all) or null on failure.
|
||||
*/
|
||||
export function useDictation(): DictationFlow {
|
||||
const session = useSession((s) => s.session);
|
||||
const recorder = useAudioRecorder(RECORDING_OPTIONS);
|
||||
const recState = useAudioRecorderState(recorder, RECORDER_POLL_INTERVAL_MS);
|
||||
const [phase, setPhase] = useState<DictationPhase>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!session) {
|
||||
setError('Not paired');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const perm = await AudioModule.requestRecordingPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
setError('Microphone permission denied');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
|
||||
await recorder.prepareToRecordAsync();
|
||||
recorder.record();
|
||||
setPhase('recording');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setPhase('error');
|
||||
}
|
||||
}, [recorder, session]);
|
||||
|
||||
const stop = useCallback(async (): Promise<CaptureCreateResponse | null> => {
|
||||
if (!session) return null;
|
||||
setPhase('processing');
|
||||
try {
|
||||
await recorder.stop();
|
||||
const uri = recorder.uri;
|
||||
if (!uri) throw new Error('No audio captured');
|
||||
const result = await uploadCapture(session, uri, {
|
||||
filename: `dictation-${Date.now()}.m4a`,
|
||||
mimeType: 'audio/m4a',
|
||||
source: 'dictation',
|
||||
});
|
||||
setPhase('idle');
|
||||
return result;
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: 'Upload failed — is the desktop reachable?',
|
||||
);
|
||||
setPhase('error');
|
||||
return null;
|
||||
}
|
||||
}, [recorder, session]);
|
||||
|
||||
return {
|
||||
phase,
|
||||
durationSec: Math.floor((recState.durationMillis ?? 0) / 1000),
|
||||
meteringDb: recState.metering,
|
||||
error,
|
||||
start,
|
||||
stop,
|
||||
isBusy: phase === 'processing',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
generate,
|
||||
getHistoryItem,
|
||||
listCaptures,
|
||||
listHistory,
|
||||
listProfiles,
|
||||
type CaptureListResponse,
|
||||
type GenerateRequest,
|
||||
type GenerationResponse,
|
||||
type HistoryListResponse,
|
||||
type HistoryResponse,
|
||||
type VoiceProfileResponse,
|
||||
} from './api';
|
||||
import { useSession } from './session';
|
||||
|
||||
export const CAPTURES_KEY = ['captures'] as const;
|
||||
export const PROFILES_KEY = ['profiles'] as const;
|
||||
export const HISTORY_KEY = ['history'] as const;
|
||||
|
||||
/**
|
||||
* Paginated capture list. V0 just fetches the first 50 and refetches on
|
||||
* window focus / explicit invalidation. Pagination + virtualized list
|
||||
* land when the user actually accumulates enough captures to need them.
|
||||
*/
|
||||
export function useCaptures() {
|
||||
const session = useSession((s) => s.session);
|
||||
return useQuery({
|
||||
queryKey: CAPTURES_KEY,
|
||||
queryFn: async (): Promise<CaptureListResponse> => {
|
||||
if (!session) return { items: [], total: 0 };
|
||||
return listCaptures(session, { limit: 50 });
|
||||
},
|
||||
enabled: !!session,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvalidateCaptures() {
|
||||
const qc = useQueryClient();
|
||||
return () => qc.invalidateQueries({ queryKey: CAPTURES_KEY });
|
||||
}
|
||||
|
||||
/** Voice profiles (cloned + preset). */
|
||||
export function useProfiles() {
|
||||
const session = useSession((s) => s.session);
|
||||
return useQuery({
|
||||
queryKey: PROFILES_KEY,
|
||||
queryFn: async (): Promise<VoiceProfileResponse[]> => {
|
||||
if (!session) return [];
|
||||
return listProfiles(session);
|
||||
},
|
||||
enabled: !!session,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Recent generations (most recent first). */
|
||||
export function useHistory(limit = 20) {
|
||||
const session = useSession((s) => s.session);
|
||||
return useQuery({
|
||||
queryKey: [...HISTORY_KEY, limit] as const,
|
||||
queryFn: async (): Promise<HistoryListResponse> => {
|
||||
if (!session) return { items: [], total: 0 };
|
||||
return listHistory(session, { limit });
|
||||
},
|
||||
enabled: !!session,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a new generation. The mutation returns the generation row in its
|
||||
* initial state — callers should follow up with ``useGenerationPolling``
|
||||
* to await completion.
|
||||
*/
|
||||
export function useGenerate() {
|
||||
const session = useSession((s) => s.session);
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (req: GenerateRequest): Promise<GenerationResponse> => {
|
||||
if (!session) throw new Error('Not paired');
|
||||
return generate(session, req);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: HISTORY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a single generation row until it completes or fails. Stops polling
|
||||
* automatically once a terminal state is reached, so it's cheap to leave
|
||||
* mounted with a null id.
|
||||
*/
|
||||
export function useGenerationPolling(generationId: string | null) {
|
||||
const session = useSession((s) => s.session);
|
||||
return useQuery({
|
||||
queryKey: ['generation', generationId] as const,
|
||||
queryFn: async (): Promise<HistoryResponse | null> => {
|
||||
if (!session || !generationId) return null;
|
||||
return getHistoryItem(session, generationId);
|
||||
},
|
||||
enabled: !!session && !!generationId,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
if (!data) return 1500;
|
||||
if (data.status === 'completed' || data.status === 'failed') return false;
|
||||
return 1500;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Parsers for the voicebox:// pair URL scheme. The desktop UI generates
|
||||
// these as QR codes; the mobile app accepts them via QR scan or manual paste.
|
||||
|
||||
export type PairPayload = {
|
||||
host: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
const SCHEME_RE = /^voicebox:\/\/pair\?(.+)$/;
|
||||
|
||||
export function parsePairUrl(input: string): PairPayload | null {
|
||||
const trimmed = input.trim();
|
||||
const match = trimmed.match(SCHEME_RE);
|
||||
if (!match) return null;
|
||||
const params = new URLSearchParams(match[1]);
|
||||
const host = params.get('host');
|
||||
const token = params.get('token');
|
||||
if (!host || !token) return null;
|
||||
return { host, token };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
clearStoredSession,
|
||||
loadSession,
|
||||
saveSession,
|
||||
type StoredSession,
|
||||
} from './storage';
|
||||
|
||||
type SessionState = {
|
||||
session: StoredSession | null;
|
||||
hydrated: boolean; // true after the first SecureStore read completes
|
||||
hydrate: () => Promise<void>;
|
||||
setSession: (s: StoredSession) => Promise<void>;
|
||||
clear: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const useSession = create<SessionState>((set, get) => ({
|
||||
session: null,
|
||||
hydrated: false,
|
||||
async hydrate() {
|
||||
if (get().hydrated) return;
|
||||
const session = await loadSession();
|
||||
set({ session, hydrated: true });
|
||||
},
|
||||
async setSession(s) {
|
||||
await saveSession(s);
|
||||
set({ session: s });
|
||||
},
|
||||
async clear() {
|
||||
await clearStoredSession();
|
||||
set({ session: null });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const SESSION_KEY = 'voicebox.session.v1';
|
||||
|
||||
export type StoredSession = {
|
||||
host: string; // e.g. "192.168.1.5:17494" or "mac.tail-xxxx.ts.net:17493"
|
||||
bearer: string; // long-lived bearer returned from POST /pair/complete
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
};
|
||||
|
||||
export async function loadSession(): Promise<StoredSession | null> {
|
||||
const raw = await SecureStore.getItemAsync(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as StoredSession;
|
||||
} catch {
|
||||
// Stored blob is corrupt — wipe so the user re-pairs cleanly
|
||||
await SecureStore.deleteItemAsync(SESSION_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSession(session: StoredSession): Promise<void> {
|
||||
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
export async function clearStoredSession(): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(SESSION_KEY);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Image, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
type Props = {
|
||||
onGetStarted: () => void;
|
||||
};
|
||||
|
||||
export function WelcomeScreen({ onGetStarted }: Props) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background">
|
||||
<View className="flex-1 px-8 items-center justify-between py-12">
|
||||
<View className="flex-1 items-center justify-center gap-8">
|
||||
<View
|
||||
className="items-center justify-center"
|
||||
style={{
|
||||
shadowColor: '#AC8C39',
|
||||
shadowOpacity: 0.45,
|
||||
shadowRadius: 40,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={require('../../assets/icon.png')}
|
||||
style={{ width: 120, height: 120, borderRadius: 28 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="items-center gap-3">
|
||||
<Text className="text-foreground text-4xl font-bold tracking-tight text-center">
|
||||
Welcome to Voicebox
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-base text-center max-w-[280px] leading-snug">
|
||||
The open-source AI voice studio, in your pocket.
|
||||
Clone voices, dictate anywhere, and talk to agents in voices you own.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="w-full items-center gap-4">
|
||||
<Button label="Get started" size="lg" onPress={onGetStarted} />
|
||||
<Text className="text-muted-foreground text-xs text-center">
|
||||
You'll pair with your Voicebox desktop in the next step.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}'],
|
||||
presets: [require('nativewind/preset')],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: 'hsl(0 0% 6%)',
|
||||
foreground: 'hsl(0 0% 95%)',
|
||||
card: {
|
||||
DEFAULT: 'hsl(0 0% 8%)',
|
||||
foreground: 'hsl(0 0% 95%)',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(0 0% 8%)',
|
||||
foreground: 'hsl(0 0% 95%)',
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(0 0% 18%)',
|
||||
foreground: 'hsl(0 0% 95%)',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(0 0% 12%)',
|
||||
foreground: 'hsl(0 0% 95%)',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(0 0% 12%)',
|
||||
foreground: 'hsl(0 0% 60%)',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(43 50% 45%)',
|
||||
foreground: 'hsl(0 0% 95%)',
|
||||
faint: 'hsl(43 50% 38%)',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(0 62.8% 50%)',
|
||||
foreground: 'hsl(0 0% 95%)',
|
||||
},
|
||||
border: 'hsl(0 0% 12%)',
|
||||
input: 'hsl(0 0% 12%)',
|
||||
ring: 'hsl(0 0% 40%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", "nativewind-env.d.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user