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:
James Pine
2026-04-25 17:09:32 -07:00
parent 2bcb98d1a8
commit f4d21504e3
49 changed files with 5229 additions and 30 deletions
+1
View File
@@ -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",
+219
View File
@@ -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' },
+23
View File
@@ -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();
+24
View File
@@ -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;
}
+57
View File
@@ -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 });
},
});
}
+8
View File
@@ -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,
+4
View File
@@ -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",
+34
View File
@@ -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)
+58
View File
@@ -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
View File
@@ -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)
+92
View File
@@ -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)
+201
View File
@@ -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
+103
View File
@@ -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"},
)
+9 -4
View File
@@ -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=="],
+25 -7
View File
@@ -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:
+41
View File
@@ -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
View File
@@ -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?
+44
View File
@@ -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"
]
}
}
+56
View File
@@ -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>
);
}
+323
View File
@@ -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>
);
}
+394
View File
@@ -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>
);
}
+178
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}
+164
View File
@@ -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>
);
}
+7
View File
@@ -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

+9
View File
@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
};
};
+1763
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+6
View File
@@ -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' });
+1
View File
@@ -0,0 +1 @@
/// <reference types="nativewind/types" />
+39
View File
@@ -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
}
+108
View File
@@ -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>
);
});
+78
View File
@@ -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));
}
+262
View File
@@ -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}` };
}
+20
View File
@@ -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;
+105
View File
@@ -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',
};
}
+110
View File
@@ -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;
},
});
}
+20
View File
@@ -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 };
}
+33
View File
@@ -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 });
},
}));
+30
View File
@@ -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);
}
+49
View File
@@ -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>
);
}
+46
View File
@@ -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: [],
};
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx", "nativewind-env.d.ts"]
}