Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
901ffcc93b | ||
|
|
a3fa9a2784 | ||
|
|
cc9c905b55 | ||
|
|
7d4844fb10 | ||
|
|
309120ec89 | ||
|
|
29dadb5543 | ||
|
|
2a206cd09f | ||
|
|
dbd57dfb60 | ||
|
|
0f2032f058 | ||
|
|
46fe1c3608 | ||
|
|
7346c9e652 | ||
|
|
cbca21ac77 | ||
|
|
12ce7a4c35 | ||
|
|
3f4631c865 | ||
|
|
e001439c06 | ||
|
|
47d9ce908f | ||
|
|
b57cfed3ef | ||
|
|
cf173ae837 | ||
|
|
89d489f711 | ||
|
|
f7c08477a0 | ||
|
|
7d3f7b96d7 | ||
|
|
7e424a20a3 | ||
|
|
9b1beba3e1 | ||
|
|
0070c04bcf |
@@ -1,2 +0,0 @@
|
||||
package.json text eol=lf
|
||||
scripts/*.sh text eol=lf
|
||||
@@ -133,7 +133,7 @@ bun run convert:assets
|
||||
This script:
|
||||
- Converts PNG → WebP (better compression, same quality)
|
||||
- Converts MOV → WebM (VP9 codec, smaller file size)
|
||||
- Processes files in `landing/public/` and `docs/public/`
|
||||
- Processes files in `docs/public/`
|
||||
- **Deletes original files** after successful conversion
|
||||
|
||||
**Requirements:** Install `webp` and `ffmpeg`:
|
||||
|
||||
@@ -20,11 +20,8 @@ COPY package.json bun.lock CHANGELOG.md ./
|
||||
COPY app/ ./app/
|
||||
COPY web/ ./web/
|
||||
|
||||
# Normalize line endings first (a Windows CRLF checkout would otherwise
|
||||
# defeat the `-z 's/,\n ]/…/'` match below, since it's LF-anchored), then
|
||||
# strip workspaces not needed for web build, and fix trailing comma
|
||||
RUN sed -i 's/\r$//' package.json && \
|
||||
sed -i '/"tauri"/d; /"landing"/d' package.json && \
|
||||
# Strip workspaces not needed for web build, and fix trailing comma
|
||||
RUN sed -i '/"tauri"/d' package.json && \
|
||||
sed -i -z 's/,\n ]/\n ]/' package.json
|
||||
RUN bun install --no-save
|
||||
# Build frontend (skip tsc — upstream has pre-existing type errors)
|
||||
@@ -103,11 +100,7 @@ EXPOSE 17493
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
|
||||
CMD curl -f http://localhost:17493/health || exit 1
|
||||
|
||||
# Entrypoint joins GPU groups then drops to the voicebox user.
|
||||
# Normalize CRLF (a Windows checkout otherwise leaves the shebang as
|
||||
# `#!/bin/sh\r`, which Linux can't resolve — reported as a misleading
|
||||
# "no such file or directory" even though the file exists).
|
||||
# Entrypoint joins GPU groups then drops to the voicebox user
|
||||
COPY --chmod=755 scripts/rocm-entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://voicebox.sh">
|
||||
<img src="landing/public/assets/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
|
||||
<img src="docs/public/images/readme/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -56,11 +56,11 @@
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<img src="landing/public/assets/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
|
||||
<img src="docs/public/images/readme/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="landing/public/assets/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
|
||||
<img src="docs/public/images/readme/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
@@ -442,7 +442,6 @@ voicebox/
|
||||
├── tauri/ # Desktop app (Tauri + Rust)
|
||||
├── web/ # Web deployment
|
||||
├── backend/ # Python FastAPI server
|
||||
├── landing/ # Marketing website
|
||||
└── scripts/ # Build & release scripts
|
||||
```
|
||||
|
||||
|
||||
@@ -35,19 +35,17 @@ export function DictateWindow() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Snapshot of the focused UI element at chord-start, shipped over from
|
||||
// Rust on the ``dictate:start`` payload. Held in a ref so it survives
|
||||
// the 1–2 s transcribe + refine window — the paste only fires once the
|
||||
// final text comes back.
|
||||
const focusRef = useRef<FocusSnapshot | null>(null);
|
||||
// Mirrored from the main window: true only when dictation is armed and the
|
||||
// user opted into keeping the microphone ready.
|
||||
const [micWarm, setMicWarm] = useState(false);
|
||||
|
||||
const session = useCaptureRecordingSession({
|
||||
onFinalText: async (text, _capture, allowAutoPaste) => {
|
||||
const focus = focusRef.current;
|
||||
// Consume-once: a second chord before this fires would overwrite
|
||||
// focusRef, but nulling it here guards against the late-arriving
|
||||
// refine-result firing a paste after the user has moved on.
|
||||
focusRef.current = null;
|
||||
keepMicWarm: micWarm,
|
||||
onFinalText: async (text, _capture, allowAutoPaste, context) => {
|
||||
// Focus is the snapshot taken at chord-start and threaded through as this
|
||||
// take's context, so it survives the 1–2 s transcribe + refine window and
|
||||
// overlapping dictations can't paste into each other's target.
|
||||
const focus = context as FocusSnapshot | null;
|
||||
if (!allowAutoPaste) return;
|
||||
if (!focus || !text.trim()) return;
|
||||
try {
|
||||
@@ -72,23 +70,41 @@ export function DictateWindow() {
|
||||
sessionRef.current = session;
|
||||
|
||||
useEffect(() => {
|
||||
const unlistens: Promise<UnlistenFn>[] = [];
|
||||
unlistens.push(
|
||||
let disposed = false;
|
||||
const unlistens: UnlistenFn[] = [];
|
||||
const registrations = [
|
||||
listen<{ focus: FocusSnapshot | null }>('dictate:start', (event) => {
|
||||
focusRef.current = event.payload?.focus ?? null;
|
||||
sessionRef.current.startRecording();
|
||||
sessionRef.current.startRecording(event.payload?.focus ?? null);
|
||||
}),
|
||||
);
|
||||
unlistens.push(
|
||||
listen('dictate:stop', () => {
|
||||
if (sessionRef.current.isRecording) sessionRef.current.stopRecording();
|
||||
// Forward stops that arrive while getUserMedia is still resolving.
|
||||
sessionRef.current.stopRecording();
|
||||
}),
|
||||
);
|
||||
listen<boolean>('dictate:warm', (event) => {
|
||||
setMicWarm(Boolean(event.payload));
|
||||
}),
|
||||
];
|
||||
Promise.all(registrations)
|
||||
.then((registered) => {
|
||||
if (disposed) {
|
||||
for (const unlisten of registered) unlisten();
|
||||
return;
|
||||
}
|
||||
unlistens.push(...registered);
|
||||
emit('dictate:warm-request').catch(() => {});
|
||||
})
|
||||
.catch((err) => console.warn('[dictate] event listener registration failed:', err));
|
||||
return () => {
|
||||
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
|
||||
disposed = true;
|
||||
for (const unlisten of unlistens) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (micWarm) void session.prewarm();
|
||||
else session.releaseWarm();
|
||||
}, [micWarm, session.prewarm, session.releaseWarm]);
|
||||
|
||||
// --- Agent-speak cycle ---------------------------------------------------
|
||||
|
||||
const [speaking, setSpeaking] = useState<{
|
||||
|
||||
@@ -139,7 +139,7 @@ export function EngineModelSelector({ form, compact, selectedProfile }: EngineMo
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent side={compact ? 'top' : undefined}>
|
||||
<SelectContent>
|
||||
{availableOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className={itemClass}>
|
||||
{opt.label}
|
||||
|
||||
@@ -555,7 +555,7 @@ export function FloatingGenerateBox({
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all w-full">
|
||||
<SelectValue placeholder={t('generation.voiceSelector.placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
<SelectContent>
|
||||
{profiles?.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id} className="text-xs">
|
||||
{profile.name}
|
||||
@@ -582,7 +582,7 @@ export function FloatingGenerateBox({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent side="top">
|
||||
<SelectContent>
|
||||
{engineLangs.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value} className="text-xs">
|
||||
{lang.label}
|
||||
@@ -610,7 +610,7 @@ export function FloatingGenerateBox({
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||
<SelectValue placeholder={t('generation.effects.none')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
<SelectContent>
|
||||
<SelectItem value="none" className="text-xs">
|
||||
{t('generation.effects.none')}
|
||||
</SelectItem>
|
||||
|
||||
@@ -138,6 +138,7 @@ export function CapturesPage() {
|
||||
const allowAutoPaste = settings?.allow_auto_paste ?? true;
|
||||
const defaultVoiceId = settings?.default_playback_voice_id ?? null;
|
||||
const hotkeyEnabled = settings?.hotkey_enabled ?? false;
|
||||
const keepMicWarm = settings?.keep_mic_warm ?? false;
|
||||
const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? defaultChordKeys('push');
|
||||
const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? defaultChordKeys('toggle');
|
||||
|
||||
@@ -221,6 +222,22 @@ export function CapturesPage() {
|
||||
<InputMonitoringNotice enabled={hotkeyEnabled} />
|
||||
</div>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.dictation.keepMicWarm.title')}
|
||||
description={t('settings.captures.dictation.keepMicWarm.description')}
|
||||
htmlFor="keepMicWarm"
|
||||
action={
|
||||
<Toggle
|
||||
id="keepMicWarm"
|
||||
checked={keepMicWarm}
|
||||
disabled={!hotkeyEnabled}
|
||||
onCheckedChange={(v) => {
|
||||
update({ keep_mic_warm: v });
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.dictation.pushToTalk.title')}
|
||||
description={t('settings.captures.dictation.pushToTalk.description')}
|
||||
|
||||
@@ -887,6 +887,10 @@
|
||||
"title": "Global shortcut",
|
||||
"description": "Hold the shortcut to record from anywhere on your machine. Release to transcribe."
|
||||
},
|
||||
"keepMicWarm": {
|
||||
"title": "Keep microphone ready",
|
||||
"description": "Hold the microphone open while dictation is enabled so the first words are never clipped. The macOS microphone indicator stays lit while it's on."
|
||||
},
|
||||
"pushToTalk": {
|
||||
"title": "Push-to-talk shortcut",
|
||||
"description": "Hold these keys anywhere on your system to record. Release to stop and transcribe.",
|
||||
|
||||
@@ -213,6 +213,10 @@ export interface CaptureSettings {
|
||||
/** Whether the global keyboard hotkey is armed. Off by default — turning
|
||||
* this on triggers the macOS Input Monitoring TCC prompt. */
|
||||
hotkey_enabled: boolean;
|
||||
/** Hold the mic open while dictation is enabled so push-to-talk doesn't clip
|
||||
* the first words. Off by default — when on, the OS mic indicator stays lit
|
||||
* the whole time dictation is enabled. */
|
||||
keep_mic_warm: boolean;
|
||||
/** keytap key names. Defaults are platform-specific right-hand modifiers. */
|
||||
chord_push_to_talk_keys: string[];
|
||||
/** keytap key names. Toggle adds Space to the platform-specific PTT chord. */
|
||||
|
||||
@@ -4,12 +4,45 @@ import { convertToWav } from '@/lib/utils/audio';
|
||||
|
||||
interface UseAudioRecordingOptions {
|
||||
maxDurationSeconds?: number;
|
||||
onRecordingComplete?: (blob: Blob, duration?: number) => void;
|
||||
// ``context`` is whatever was handed to ``startRecording`` for this take,
|
||||
// threaded back untouched so callers can correlate the result with the
|
||||
// recording it came from (the dictate window pairs it with the focus
|
||||
// snapshot captured at chord-start).
|
||||
onRecordingComplete?: (blob: Blob, duration?: number, context?: unknown) => void;
|
||||
/**
|
||||
* Keep the microphone ``MediaStream`` open between recordings instead of
|
||||
* tearing it down on every stop. This is what removes the "first words get
|
||||
* clipped" problem on push-to-talk dictation: ``getUserMedia`` on macOS can
|
||||
* take several hundred ms — up to a second cold — to hand back a stream, and
|
||||
* ``MediaRecorder`` only starts capturing *after* it resolves, so everything
|
||||
* spoken in that window is lost. With a warm stream already open, the next
|
||||
* ``startRecording`` skips ``getUserMedia`` entirely.
|
||||
*
|
||||
* Off by default: the voice-clone sample recorders release the device
|
||||
* immediately, and the dictation session only opts in when the user enables
|
||||
* the "keep microphone ready" setting. While on, the warm stream stays open —
|
||||
* and the OS mic-in-use indicator stays lit — until it's explicitly released
|
||||
* (dictation disabled or the setting turned off), so the trade-off is visible
|
||||
* and user-controlled rather than a background mic that's always warm.
|
||||
*/
|
||||
keepWarm?: boolean;
|
||||
}
|
||||
|
||||
// Audio constraints for capture. Kept identical to the previous inline value so
|
||||
// this change is purely about *when* the stream is opened, not *how*.
|
||||
const AUDIO_CONSTRAINTS: MediaTrackConstraints = {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
};
|
||||
|
||||
const streamHasLiveAudio = (stream: MediaStream | null): stream is MediaStream =>
|
||||
!!stream && stream.getAudioTracks().some((t) => t.readyState === 'live');
|
||||
|
||||
export function useAudioRecording({
|
||||
maxDurationSeconds,
|
||||
onRecordingComplete,
|
||||
keepWarm = false,
|
||||
}: UseAudioRecordingOptions = {}) {
|
||||
const platform = usePlatform();
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
@@ -17,195 +50,392 @@ export function useAudioRecording({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
// The stream currently backing the MediaRecorder. When ``keepWarm`` is set
|
||||
// this is the same object as ``warmStreamRef`` and is *not* torn down on
|
||||
// stop; otherwise it's stopped as soon as the recording completes.
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
// Persistent pre-opened stream reused across recordings when ``keepWarm``.
|
||||
const warmStreamRef = useRef<MediaStream | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
const cancelledRef = useRef<boolean>(false);
|
||||
// Mirror of ``isRecording`` for reads inside callbacks that would otherwise
|
||||
// close over a stale render.
|
||||
const isRecordingRef = useRef(false);
|
||||
// A ``getUserMedia`` call in flight, shared so concurrent acquirers (prewarm
|
||||
// plus an immediate chord) coalesce onto one stream instead of each opening —
|
||||
// and orphaning — their own.
|
||||
const acquiringRef = useRef<Promise<MediaStream> | null>(null);
|
||||
// True from ``startRecording`` entry until the recorder is actually running
|
||||
// (or has failed), so a stop that arrives mid-acquisition can be deferred.
|
||||
const startingRef = useRef(false);
|
||||
// True from MediaRecorder.stop() until onstop has snapshotted the take's
|
||||
// shared refs. React state and MediaRecorder.state both flip before onstop,
|
||||
// so without this gate a rapid next chord can clear chunks/duration/cancel
|
||||
// state out from under the recorder that is still finalising.
|
||||
const finishingRef = useRef(false);
|
||||
const pendingStopRef = useRef(false);
|
||||
// Bumped per recording so a stale recorder's ``onstop`` can tell it's no
|
||||
// longer the active one before it touches the shared stream refs.
|
||||
const recordingCounterRef = useRef(0);
|
||||
// Bumped whenever the warm stream is released/aborted so a ``getUserMedia``
|
||||
// still in flight can tell its result is stale and stop it instead of
|
||||
// adopting a live mic after disable/unmount.
|
||||
const acquireGenRef = useRef(0);
|
||||
// Set when a release is requested mid-recording; the onstop path performs the
|
||||
// deferred release once capture finishes rather than yanking the device now.
|
||||
const releaseAfterStopRef = useRef(false);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
chunksRef.current = [];
|
||||
cancelledRef.current = false;
|
||||
setDuration(0);
|
||||
// Keeps the ref in lockstep with the state so the synchronous stop path reads
|
||||
// a fresh value without waiting for a rerender.
|
||||
const setRecording = useCallback((next: boolean) => {
|
||||
isRecordingRef.current = next;
|
||||
setIsRecording(next);
|
||||
}, []);
|
||||
|
||||
// Check if getUserMedia is available
|
||||
// In Tauri, navigator.mediaDevices might not be available immediately
|
||||
if (typeof navigator === 'undefined') {
|
||||
const errorMsg =
|
||||
'Navigator API is not available. This might be a Tauri configuration issue.';
|
||||
setError(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
const releaseWarmStream = useCallback(() => {
|
||||
// Invalidate any getUserMedia still in flight so its stream is stopped on
|
||||
// resolve rather than adopted as the warm stream.
|
||||
acquireGenRef.current += 1;
|
||||
// Don't tear the device out from under an active/starting recording — the
|
||||
// warm stream is the one backing it; defer to the onstop path instead.
|
||||
if (isRecordingRef.current || startingRef.current) {
|
||||
releaseAfterStopRef.current = true;
|
||||
return;
|
||||
}
|
||||
warmStreamRef.current?.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
warmStreamRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Assert that getUserMedia is reachable, mirroring the previous inline guard
|
||||
// (Tauri webviews occasionally expose ``navigator.mediaDevices`` a beat late).
|
||||
const assertMediaDevices = useCallback(async () => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
throw new Error('Navigator API is not available. This might be a Tauri configuration issue.');
|
||||
}
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
// Try waiting a bit for Tauri webview to initialize
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
console.error('MediaDevices check:', {
|
||||
hasNavigator: typeof navigator !== 'undefined',
|
||||
hasMediaDevices: !!navigator?.mediaDevices,
|
||||
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
|
||||
isTauri: platform.metadata.isTauri,
|
||||
});
|
||||
|
||||
const errorMsg = platform.metadata.isTauri
|
||||
throw new Error(
|
||||
platform.metadata.isTauri
|
||||
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
|
||||
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
|
||||
setError(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [platform.metadata.isTauri]);
|
||||
|
||||
// Request microphone access
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
// Return a live capture stream, reusing the warm one when available so the
|
||||
// hot path (chord-down → record) never waits on getUserMedia.
|
||||
const acquireStream = useCallback(async (): Promise<MediaStream> => {
|
||||
// Captured separately so it stays typed as the full stream after the live
|
||||
// check narrows ``warmStreamRef.current`` itself.
|
||||
const existing = warmStreamRef.current;
|
||||
if (streamHasLiveAudio(warmStreamRef.current)) {
|
||||
return warmStreamRef.current;
|
||||
}
|
||||
// Coalesce concurrent acquirers onto one getUserMedia call so prewarm and
|
||||
// an immediate chord can't open two streams.
|
||||
if (acquiringRef.current) return acquiringRef.current;
|
||||
// A dead warm stream (device unplugged / tracks ended) — drop it and reopen.
|
||||
if (existing) {
|
||||
existing.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
|
||||
streamRef.current = stream;
|
||||
|
||||
// Create MediaRecorder with preferred MIME type
|
||||
const options: MediaRecorderOptions = {
|
||||
mimeType: 'audio/webm;codecs=opus',
|
||||
};
|
||||
|
||||
// Fallback to default if webm not supported
|
||||
if (!MediaRecorder.isTypeSupported(options.mimeType!)) {
|
||||
delete options.mimeType;
|
||||
}
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream, options);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
// Snapshot the cancellation flag and recorded duration immediately —
|
||||
// cancelRecording() clears chunks and sets cancelledRef synchronously
|
||||
// before this async handler runs, so we must check it first.
|
||||
const wasCancelled = cancelledRef.current;
|
||||
const recordedDuration = startTimeRef.current
|
||||
? (Date.now() - startTimeRef.current) / 1000
|
||||
: undefined;
|
||||
|
||||
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
|
||||
// Stop all tracks now that we have the data
|
||||
streamRef.current?.getTracks().forEach((track) => {
|
||||
warmStreamRef.current = null;
|
||||
}
|
||||
const gen = acquireGenRef.current;
|
||||
const acquisition = (async () => {
|
||||
await assertMediaDevices();
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: AUDIO_CONSTRAINTS,
|
||||
});
|
||||
// Released / disabled / unmounted while acquiring — this stream is stale,
|
||||
// so stop it instead of leaving a live mic open, and abort the caller.
|
||||
if (gen !== acquireGenRef.current) {
|
||||
stream.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
streamRef.current = null;
|
||||
throw new Error('microphone acquisition aborted');
|
||||
}
|
||||
if (keepWarm) warmStreamRef.current = stream;
|
||||
return stream;
|
||||
})();
|
||||
acquiringRef.current = acquisition;
|
||||
try {
|
||||
return await acquisition;
|
||||
} finally {
|
||||
if (acquiringRef.current === acquisition) acquiringRef.current = null;
|
||||
}
|
||||
}, [assertMediaDevices, keepWarm]);
|
||||
|
||||
// Don't fire completion callback if the recording was cancelled
|
||||
if (wasCancelled) return;
|
||||
/**
|
||||
* Open the microphone ahead of the first recording so the initial dictation
|
||||
* doesn't clip. No-op unless ``keepWarm`` is set. Safe to call repeatedly and
|
||||
* safe to fail (e.g. permission not yet granted) — ``startRecording`` still
|
||||
* surfaces a real error if capture is genuinely unavailable.
|
||||
*/
|
||||
const prewarm = useCallback(async () => {
|
||||
if (!keepWarm) return;
|
||||
try {
|
||||
await acquireStream();
|
||||
} catch {
|
||||
// Permission missing / device busy / aborted — recording will report a
|
||||
// real error if capture is genuinely unavailable.
|
||||
}
|
||||
}, [keepWarm, acquireStream]);
|
||||
|
||||
// Convert to WAV format to avoid needing ffmpeg on backend
|
||||
try {
|
||||
const wavBlob = await convertToWav(webmBlob);
|
||||
onRecordingComplete?.(wavBlob, recordedDuration);
|
||||
} catch (err) {
|
||||
console.error('Error converting audio to WAV:', err);
|
||||
// Fallback to original blob if conversion fails
|
||||
onRecordingComplete?.(webmBlob, recordedDuration);
|
||||
const startRecording = useCallback(
|
||||
async (context?: unknown) => {
|
||||
// A second chord can arrive while the first one is still waiting on
|
||||
// getUserMedia. Never create overlapping MediaRecorders on the same
|
||||
// coalesced stream; the original take will honor any deferred stop.
|
||||
if (
|
||||
startingRef.current ||
|
||||
finishingRef.current ||
|
||||
mediaRecorderRef.current?.state === 'recording'
|
||||
)
|
||||
return;
|
||||
startingRef.current = true;
|
||||
pendingStopRef.current = false;
|
||||
// A new recording supersedes any release deferred from a prior take.
|
||||
releaseAfterStopRef.current = false;
|
||||
const recordingId = ++recordingCounterRef.current;
|
||||
try {
|
||||
setError(null);
|
||||
chunksRef.current = [];
|
||||
cancelledRef.current = false;
|
||||
setDuration(0);
|
||||
|
||||
// Reuse the warm stream when present (instant); otherwise open one now.
|
||||
const stream = await acquireStream();
|
||||
streamRef.current = stream;
|
||||
|
||||
// Create MediaRecorder with preferred MIME type
|
||||
const options: MediaRecorderOptions = {
|
||||
mimeType: 'audio/webm;codecs=opus',
|
||||
};
|
||||
|
||||
// Fallback to default if webm not supported
|
||||
if (!MediaRecorder.isTypeSupported(options.mimeType!)) {
|
||||
delete options.mimeType;
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onerror = (event) => {
|
||||
setError('Recording error occurred');
|
||||
console.error('MediaRecorder error:', event);
|
||||
};
|
||||
const mediaRecorder = new MediaRecorder(stream, options);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
|
||||
// WebKit's MediaRecorder drops the WebM EBML header from chunks when
|
||||
// started with a timeslice, so concatenated blobs fail to parse in
|
||||
// both AudioContext and ffmpeg. Starting with no timeslice produces
|
||||
// exactly one dataavailable on stop() with a valid container.
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
startTimeRef.current = Date.now();
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
// Start timer
|
||||
timerRef.current = window.setInterval(() => {
|
||||
if (startTimeRef.current) {
|
||||
const elapsed = (Date.now() - startTimeRef.current) / 1000;
|
||||
setDuration(elapsed);
|
||||
mediaRecorder.onstop = async () => {
|
||||
// Whether this recorder is still the active one. A stale onstop (an
|
||||
// older recorder stopping after a newer startRecording) must not touch
|
||||
// the shared stream refs.
|
||||
const isCurrent = recordingCounterRef.current === recordingId;
|
||||
// Snapshot the cancellation flag and recorded duration immediately —
|
||||
// cancelRecording() clears chunks and sets cancelledRef synchronously
|
||||
// before this async handler runs, so we must check it first.
|
||||
const wasCancelled = cancelledRef.current;
|
||||
const recordedDuration = startTimeRef.current
|
||||
? (Date.now() - startTimeRef.current) / 1000
|
||||
: undefined;
|
||||
|
||||
// Auto-stop at max duration when the caller opts in — dictation
|
||||
// sessions pass undefined and run until the user releases the
|
||||
// chord or hits stop; voice-clone sample recorders pass 29s to
|
||||
// keep reference clips short.
|
||||
if (maxDurationSeconds !== undefined && elapsed >= maxDurationSeconds) {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
|
||||
// Release the device unless we're keeping it warm for the next capture.
|
||||
// Act on this recorder's own stream; only touch the shared refs when
|
||||
// this is still the current recording.
|
||||
if (keepWarm) {
|
||||
if (isCurrent) {
|
||||
streamRef.current = null;
|
||||
// A release requested mid-recording (dictation disabled) is
|
||||
// honored now that capture has finished; otherwise the warm
|
||||
// stream stays open for the next take.
|
||||
if (releaseAfterStopRef.current) {
|
||||
releaseAfterStopRef.current = false;
|
||||
releaseWarmStream();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stream.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
if (isCurrent) streamRef.current = null;
|
||||
}
|
||||
|
||||
// All shared per-take refs have now been snapshotted and stream
|
||||
// cleanup is complete. A new take may begin while WAV conversion and
|
||||
// upload continue using the local values above.
|
||||
finishingRef.current = false;
|
||||
|
||||
// Don't fire completion callback if the recording was cancelled
|
||||
if (wasCancelled) return;
|
||||
|
||||
// Convert to WAV format to avoid needing ffmpeg on backend
|
||||
try {
|
||||
const wavBlob = await convertToWav(webmBlob);
|
||||
onRecordingComplete?.(wavBlob, recordedDuration, context);
|
||||
} catch (err) {
|
||||
console.error('Error converting audio to WAV:', err);
|
||||
// Fallback to original blob if conversion fails
|
||||
onRecordingComplete?.(webmBlob, recordedDuration, context);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onerror = (event) => {
|
||||
setError('Recording error occurred');
|
||||
console.error('MediaRecorder error:', event);
|
||||
};
|
||||
|
||||
// WebKit's MediaRecorder drops the WebM EBML header from chunks when
|
||||
// started with a timeslice, so concatenated blobs fail to parse in
|
||||
// both AudioContext and ffmpeg. Starting with no timeslice produces
|
||||
// exactly one dataavailable on stop() with a valid container.
|
||||
mediaRecorder.start();
|
||||
setRecording(true);
|
||||
startTimeRef.current = Date.now();
|
||||
startingRef.current = false;
|
||||
|
||||
// A stop (chord release) that landed while the mic was still opening —
|
||||
// honor it now that capture has actually begun.
|
||||
if (pendingStopRef.current) {
|
||||
pendingStopRef.current = false;
|
||||
finishingRef.current = true;
|
||||
mediaRecorder.stop();
|
||||
setRecording(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start timer
|
||||
timerRef.current = window.setInterval(() => {
|
||||
if (startTimeRef.current) {
|
||||
const elapsed = (Date.now() - startTimeRef.current) / 1000;
|
||||
setDuration(elapsed);
|
||||
|
||||
// Auto-stop at max duration when the caller opts in — dictation
|
||||
// sessions pass undefined and run until the user releases the
|
||||
// chord or hits stop; voice-clone sample recorders pass 29s to
|
||||
// keep reference clips short.
|
||||
if (maxDurationSeconds !== undefined && elapsed >= maxDurationSeconds) {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
finishingRef.current = true;
|
||||
mediaRecorderRef.current.stop();
|
||||
setRecording(false);
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to access microphone. Please check permissions.';
|
||||
// A fresh (non-warm) stream opened before the failure must be released
|
||||
// so the mic doesn't stay lit; a warm stream is reusable, so it's kept.
|
||||
if (!keepWarm) {
|
||||
streamRef.current?.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
streamRef.current = null;
|
||||
}
|
||||
}, 100);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to access microphone. Please check permissions.';
|
||||
setError(errorMessage);
|
||||
setIsRecording(false);
|
||||
}
|
||||
}, [maxDurationSeconds, onRecordingComplete]);
|
||||
startingRef.current = false;
|
||||
finishingRef.current = false;
|
||||
pendingStopRef.current = false;
|
||||
setError(errorMessage);
|
||||
setRecording(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
maxDurationSeconds,
|
||||
onRecordingComplete,
|
||||
acquireStream,
|
||||
keepWarm,
|
||||
releaseWarmStream,
|
||||
setRecording,
|
||||
],
|
||||
);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
// The recorder's own state is the lifecycle authority — React ``isRecording``
|
||||
// lags a render behind ``mediaRecorder.start()``, so a chord release in that
|
||||
// window would otherwise be dropped.
|
||||
const recorder = mediaRecorderRef.current;
|
||||
if (recorder && recorder.state === 'recording') {
|
||||
finishingRef.current = true;
|
||||
recorder.stop();
|
||||
setRecording(false);
|
||||
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
} else if (startingRef.current) {
|
||||
// Stop arrived before capture began (mic still opening) — defer it so
|
||||
// startRecording stops as soon as the recorder goes live.
|
||||
pendingStopRef.current = true;
|
||||
}
|
||||
}, [isRecording]);
|
||||
}, [setRecording]);
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current) {
|
||||
cancelledRef.current = true; // Must be set before stop() triggers onstop
|
||||
cancelledRef.current = true; // Must be set before stop() triggers onstop
|
||||
const recorder = mediaRecorderRef.current;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
chunksRef.current = [];
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
finishingRef.current = true;
|
||||
recorder.stop();
|
||||
setRecording(false);
|
||||
setDuration(0);
|
||||
} else if (startingRef.current) {
|
||||
// Cancel during mic acquisition — stop as soon as capture begins; the
|
||||
// cancelled flag suppresses the completion callback.
|
||||
pendingStopRef.current = true;
|
||||
}
|
||||
|
||||
// Stop all tracks
|
||||
streamRef.current?.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
streamRef.current = null;
|
||||
// Keep the device warm for the next capture when opted in; otherwise stop
|
||||
// the tracks so the mic is released immediately.
|
||||
if (keepWarm) {
|
||||
streamRef.current = null;
|
||||
if (releaseAfterStopRef.current) {
|
||||
releaseAfterStopRef.current = false;
|
||||
releaseWarmStream();
|
||||
}
|
||||
} else {
|
||||
streamRef.current?.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
}, [keepWarm, releaseWarmStream, setRecording]);
|
||||
|
||||
// Cleanup on unmount
|
||||
// Cleanup on unmount — always fully release the device, warm or not.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Invalidate any in-flight acquisition so a stream resolving after unmount
|
||||
// stops itself instead of leaking a live mic.
|
||||
acquireGenRef.current += 1;
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
streamRef.current?.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
warmStreamRef.current?.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -216,5 +446,7 @@ export function useAudioRecording({
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
prewarm,
|
||||
releaseWarm: releaseWarmStream,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,11 +54,15 @@ const SHORT_RECORDING_MESSAGE = 'Recording too short, canceled';
|
||||
export type CapturePillState = PillState | 'hidden';
|
||||
|
||||
export interface UseCaptureRecordingSessionOptions {
|
||||
/** Keep the microphone stream open between dictations when explicitly
|
||||
* enabled. Off by default so normal recorders release the device. */
|
||||
keepMicWarm?: boolean;
|
||||
/**
|
||||
* Fired after a capture row is created on the server. Callers can use this
|
||||
* to select the new capture or emit a Tauri event to a sibling window.
|
||||
* ``context`` is whatever was passed to ``startRecording`` for this take.
|
||||
*/
|
||||
onCaptureCreated?: (capture: CaptureResponse) => void;
|
||||
onCaptureCreated?: (capture: CaptureResponse, context?: unknown) => void;
|
||||
/**
|
||||
* Fired with the final delivered text — refined if ``auto_refine`` was on
|
||||
* for this capture, raw transcript otherwise. Used by the floating
|
||||
@@ -66,12 +70,14 @@ export interface UseCaptureRecordingSessionOptions {
|
||||
*
|
||||
* ``allowAutoPaste`` snapshots the setting at chord-start so a refine that
|
||||
* lands after the user flips the toggle still uses the value the capture
|
||||
* was created under.
|
||||
* was created under. ``context`` is the value passed to ``startRecording``
|
||||
* for this take, so overlapping dictations can't cross their targets.
|
||||
*/
|
||||
onFinalText?: (
|
||||
text: string,
|
||||
capture: CaptureResponse,
|
||||
allowAutoPaste: boolean,
|
||||
context?: unknown,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -82,12 +88,14 @@ export interface UseCaptureRecordingSessionResult {
|
||||
isRecording: boolean;
|
||||
isUploading: boolean;
|
||||
isRefining: boolean;
|
||||
startRecording: () => void;
|
||||
startRecording: (context?: unknown) => void;
|
||||
stopRecording: () => void;
|
||||
toggleRecording: () => void;
|
||||
dismissError: () => void;
|
||||
uploadFile: (file: File, source: CaptureSource) => void;
|
||||
refine: (captureId: string) => void;
|
||||
prewarm: () => Promise<void>;
|
||||
releaseWarm: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,10 +131,13 @@ export function useCaptureRecordingSession(
|
||||
const onFinalTextRef = useRef(options.onFinalText);
|
||||
onFinalTextRef.current = options.onFinalText;
|
||||
|
||||
// Snapshot of ``allow_auto_paste`` from the capture-create response —
|
||||
// held so the refine onSuccess (which only sees the plain CaptureResponse)
|
||||
// can still pass the original setting through to onFinalText.
|
||||
const allowAutoPasteRef = useRef<boolean>(true);
|
||||
// Per-capture recording context and its ``allow_auto_paste`` snapshot, keyed
|
||||
// by capture id so a refine that resolves after another dictation started
|
||||
// still delivers to the right target with the setting the capture was created
|
||||
// under. Populated on capture-create and consumed once the final text lands.
|
||||
const captureDeliveryRef = useRef<Map<string, { context: unknown; allowAutoPaste: boolean }>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const clearRestTimer = useCallback(() => {
|
||||
if (restTimerRef.current !== null) {
|
||||
@@ -192,20 +203,34 @@ export function useCaptureRecordingSession(
|
||||
queryClient.invalidateQueries({ queryKey: ['captures'] });
|
||||
broadcastUpdated(captureId);
|
||||
if (pillStateRef.current === 'refining') scheduleHidePill();
|
||||
const delivery = captureDeliveryRef.current.get(captureId);
|
||||
captureDeliveryRef.current.delete(captureId);
|
||||
const finalText = data.transcript_refined ?? data.transcript_raw;
|
||||
if (finalText) {
|
||||
onFinalTextRef.current?.(finalText, data, allowAutoPasteRef.current);
|
||||
onFinalTextRef.current?.(
|
||||
finalText,
|
||||
data,
|
||||
delivery?.allowAutoPaste ?? true,
|
||||
delivery?.context,
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
onError: (err: Error, captureId) => {
|
||||
captureDeliveryRef.current.delete(captureId);
|
||||
showError(err.message || 'Refinement failed');
|
||||
},
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async ({ file, source }: { file: File; source: CaptureSource }) =>
|
||||
apiClient.createCapture(file, { source }),
|
||||
onSuccess: (capture) => {
|
||||
mutationFn: async ({
|
||||
file,
|
||||
source,
|
||||
}: {
|
||||
file: File;
|
||||
source: CaptureSource;
|
||||
context?: unknown;
|
||||
}) => apiClient.createCapture(file, { source }),
|
||||
onSuccess: (capture, { context }) => {
|
||||
queryClient.setQueryData<CaptureListResponse>(['captures'], (prev) => {
|
||||
if (!prev) return prev;
|
||||
if (prev.items.some((c) => c.id === capture.id)) return prev;
|
||||
@@ -213,9 +238,12 @@ export function useCaptureRecordingSession(
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['captures'] });
|
||||
broadcastCreated(capture);
|
||||
onCaptureCreatedRef.current?.(capture);
|
||||
allowAutoPasteRef.current = capture.allow_auto_paste;
|
||||
onCaptureCreatedRef.current?.(capture, context);
|
||||
if (capture.auto_refine) {
|
||||
captureDeliveryRef.current.set(capture.id, {
|
||||
context,
|
||||
allowAutoPaste: capture.allow_auto_paste,
|
||||
});
|
||||
setPillState('refining');
|
||||
refineMutation.mutate(capture.id);
|
||||
} else {
|
||||
@@ -225,6 +253,7 @@ export function useCaptureRecordingSession(
|
||||
capture.transcript_raw,
|
||||
capture,
|
||||
capture.allow_auto_paste,
|
||||
context,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -249,8 +278,11 @@ export function useCaptureRecordingSession(
|
||||
startRecording: beginAudioRecording,
|
||||
stopRecording,
|
||||
error: recordError,
|
||||
prewarm,
|
||||
releaseWarm,
|
||||
} = useAudioRecording({
|
||||
onRecordingComplete: (blob, recordedDuration) => {
|
||||
keepWarm: options.keepMicWarm ?? false,
|
||||
onRecordingComplete: (blob, recordedDuration, context) => {
|
||||
// Trigger-happy tap — MediaRecorder hasn't emitted a usable chunk yet
|
||||
// so the blob is empty or unparseable. Surface it as a transient pill
|
||||
// so the user sees their recording was recognised and canceled.
|
||||
@@ -268,7 +300,7 @@ export function useCaptureRecordingSession(
|
||||
const file = new File([blob], `dictation-${Date.now()}.${extension}`, {
|
||||
type: blob.type,
|
||||
});
|
||||
uploadMutation.mutate({ file, source: 'dictation' });
|
||||
uploadMutation.mutate({ file, source: 'dictation', context });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -278,13 +310,16 @@ export function useCaptureRecordingSession(
|
||||
}
|
||||
}, [recordError, showError]);
|
||||
|
||||
const startRecording = useCallback(() => {
|
||||
if (isRecording) return;
|
||||
clearRestTimer();
|
||||
setFrozenElapsedMs(0);
|
||||
setPillState('recording');
|
||||
beginAudioRecording();
|
||||
}, [isRecording, beginAudioRecording, clearRestTimer]);
|
||||
const startRecording = useCallback(
|
||||
(context?: unknown) => {
|
||||
if (isRecording) return;
|
||||
clearRestTimer();
|
||||
setFrozenElapsedMs(0);
|
||||
setPillState('recording');
|
||||
beginAudioRecording(context);
|
||||
},
|
||||
[isRecording, beginAudioRecording, clearRestTimer],
|
||||
);
|
||||
|
||||
const toggleRecording = useCallback(() => {
|
||||
if (isRecording) {
|
||||
@@ -324,5 +359,7 @@ export function useCaptureRecordingSession(
|
||||
dismissError,
|
||||
uploadFile,
|
||||
refine,
|
||||
prewarm,
|
||||
releaseWarm,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useEffect } from 'react';
|
||||
import { emit, listen } from '@tauri-apps/api/event';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
|
||||
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
@@ -30,21 +31,45 @@ export function useChordSync() {
|
||||
const { settings } = useCaptureSettings();
|
||||
const { canRecord } = useDictationReadiness();
|
||||
const enabled = settings?.hotkey_enabled;
|
||||
const keepMicWarm = settings?.keep_mic_warm;
|
||||
const pushKeys = settings?.chord_push_to_talk_keys;
|
||||
const toggleKeys = settings?.chord_toggle_to_talk_keys;
|
||||
|
||||
// Latest warm state, so the dictate window's mount-time request can be
|
||||
// answered even between the dep-driven emits below.
|
||||
const shouldWarmRef = useRef(false);
|
||||
|
||||
// The floating dictate window holds the mic warm ahead of the first chord to
|
||||
// avoid clipping, but it's a separate webview with no view of settings. Mirror
|
||||
// the decision to it: warm only when dictation is armed AND the user enabled
|
||||
// "keep microphone ready". Gating here is what stops the always-mounted pill
|
||||
// from opening the mic — or prompting for access — when the user hasn't asked.
|
||||
useEffect(() => {
|
||||
if (!platform.metadata.isTauri) return;
|
||||
const unlisten = listen('dictate:warm-request', () => {
|
||||
emit('dictate:warm', shouldWarmRef.current).catch(() => {});
|
||||
});
|
||||
return () => {
|
||||
unlisten.then((fn) => fn()).catch(() => {});
|
||||
};
|
||||
}, [platform.metadata.isTauri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!platform.metadata.isTauri) return;
|
||||
if (enabled === undefined || !pushKeys || !toggleKeys) return;
|
||||
const shouldArm = enabled && canRecord;
|
||||
const shouldWarm = shouldArm && (keepMicWarm ?? false);
|
||||
shouldWarmRef.current = shouldWarm;
|
||||
const command = shouldArm ? 'enable_hotkey' : 'disable_hotkey';
|
||||
const args = shouldArm ? { pushToTalk: pushKeys, toggleToTalk: toggleKeys } : {};
|
||||
invoke(command, args).catch((err) => {
|
||||
console.warn(`[chord-sync] ${command} failed:`, err);
|
||||
});
|
||||
emit('dictate:warm', shouldWarm).catch(() => {});
|
||||
}, [
|
||||
platform.metadata.isTauri,
|
||||
enabled,
|
||||
keepMicWarm,
|
||||
canRecord,
|
||||
// Stringify so a referentially-new array with the same content
|
||||
// doesn't fire a redundant invoke on every settings refetch.
|
||||
|
||||
@@ -47,14 +47,12 @@ export function useExportGeneration() {
|
||||
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||
const blob = await apiClient.exportGeneration(generationId);
|
||||
|
||||
// Create safe filename from text. Append a short id so exports of
|
||||
// similarly-worded generations don't collide on the same filename
|
||||
// (the first 30 chars are frequently identical).
|
||||
// Create safe filename from text
|
||||
const safeText = text
|
||||
.substring(0, 30)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `generation-${safeText}-${generationId.substring(0, 8)}.voicebox.zip`;
|
||||
const filename = `generation-${safeText}.voicebox.zip`;
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
@@ -75,14 +73,12 @@ export function useExportGenerationAudio() {
|
||||
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||
const blob = await apiClient.exportGenerationAudio(generationId);
|
||||
|
||||
// Create safe filename from text. Append a short id so exports of
|
||||
// similarly-worded generations don't collide on the same filename
|
||||
// (the first 30 chars are frequently identical).
|
||||
// Create safe filename from text
|
||||
const safeText = text
|
||||
.substring(0, 30)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `${safeText}-${generationId.substring(0, 8)}.wav`;
|
||||
const filename = `${safeText}.wav`;
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
|
||||
@@ -25,28 +25,27 @@ function getDateLocale() {
|
||||
}
|
||||
}
|
||||
|
||||
// Backend timestamps are naive UTC — append `Z` so JS doesn't parse a
|
||||
// timezone-less date-time string as local time.
|
||||
function parseServerDate(date: string | Date): Date {
|
||||
if (typeof date !== 'string') {
|
||||
return date;
|
||||
}
|
||||
const dateStr = date.trim();
|
||||
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
|
||||
return new Date(`${dateStr}Z`);
|
||||
}
|
||||
return new Date(dateStr);
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date): string {
|
||||
return formatDistance(parseServerDate(date), new Date(), {
|
||||
let dateObj: Date;
|
||||
if (typeof date === 'string') {
|
||||
const dateStr = date.trim();
|
||||
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
|
||||
dateObj = new Date(`${dateStr}Z`);
|
||||
} else {
|
||||
dateObj = new Date(dateStr);
|
||||
}
|
||||
} else {
|
||||
dateObj = date;
|
||||
}
|
||||
|
||||
return formatDistance(dateObj, new Date(), {
|
||||
addSuffix: true,
|
||||
locale: getDateLocale(),
|
||||
}).replace(/^about /i, '');
|
||||
}
|
||||
|
||||
export function formatAbsoluteDate(date: string | Date): string {
|
||||
const dateObj = parseServerDate(date);
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
return dateObj.toLocaleString(i18n.language, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -360,15 +360,15 @@ async def _run_shutdown() -> None:
|
||||
"""Unload models on lifespan exit."""
|
||||
logger.info("Voicebox server shutting down...")
|
||||
try:
|
||||
tts.unload_tts_model()
|
||||
await tts.unload_tts_model()
|
||||
except Exception:
|
||||
logger.exception("Failed to unload TTS model")
|
||||
try:
|
||||
transcribe.unload_whisper_model()
|
||||
await transcribe.unload_whisper_model()
|
||||
except Exception:
|
||||
logger.exception("Failed to unload Whisper model")
|
||||
try:
|
||||
llm.unload_llm_model()
|
||||
await llm.unload_llm_model()
|
||||
except Exception:
|
||||
logger.exception("Failed to unload LLM model")
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ class ModelConfig:
|
||||
model_size: str = "default"
|
||||
size_mb: int = 0
|
||||
needs_trim: bool = False
|
||||
retries_runaway: bool = False
|
||||
supports_instruct: bool = False
|
||||
languages: list[str] = field(default_factory=lambda: ["en"])
|
||||
|
||||
@@ -233,10 +232,6 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
|
||||
repo_1_7b = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||
repo_0_6b = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
|
||||
|
||||
# mlx-audio can continue after an EOS miss with silence followed by
|
||||
# codec noise. Retry only the affected text as smaller chunks.
|
||||
retries_runaway = backend_type == "mlx"
|
||||
|
||||
return [
|
||||
ModelConfig(
|
||||
model_name="qwen-tts-1.7B",
|
||||
@@ -245,7 +240,6 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
|
||||
hf_repo_id=repo_1_7b,
|
||||
model_size="1.7B",
|
||||
size_mb=3500,
|
||||
retries_runaway=retries_runaway,
|
||||
supports_instruct=False, # Base model drops instruct silently
|
||||
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
|
||||
),
|
||||
@@ -256,7 +250,6 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
|
||||
hf_repo_id=repo_0_6b,
|
||||
model_size="0.6B",
|
||||
size_mb=1200,
|
||||
retries_runaway=retries_runaway,
|
||||
supports_instruct=False,
|
||||
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
|
||||
),
|
||||
@@ -511,14 +504,6 @@ def engine_needs_trim(engine: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def engine_retries_runaway(engine: str) -> bool:
|
||||
"""Whether unstable output should be retried in smaller chunks."""
|
||||
for cfg in get_tts_model_configs():
|
||||
if cfg.engine == engine:
|
||||
return cfg.retries_runaway
|
||||
return False
|
||||
|
||||
|
||||
def engine_has_model_sizes(engine: str) -> bool:
|
||||
"""Whether this engine supports multiple model sizes (only Qwen currently)."""
|
||||
configs = [c for c in get_tts_model_configs() if c.engine == engine]
|
||||
@@ -562,7 +547,21 @@ async def ensure_model_cached_or_raise(engine: str, model_size: str = "default")
|
||||
)
|
||||
|
||||
|
||||
def unload_model_by_config(config: ModelConfig) -> bool:
|
||||
async def unload_backend(backend) -> None:
|
||||
"""Free a backend's model, serialized onto the MLX worker when it has one.
|
||||
|
||||
MLX backends expose an async ``unload`` that runs the free on the dedicated
|
||||
MLX thread so it can't collide with an in-flight load/generate. Other
|
||||
backends only carry the synchronous ``unload_model``.
|
||||
"""
|
||||
unload = getattr(backend, "unload", None)
|
||||
if unload is not None:
|
||||
await unload()
|
||||
else:
|
||||
backend.unload_model()
|
||||
|
||||
|
||||
async def unload_model_by_config(config: ModelConfig) -> bool:
|
||||
"""Unload a model given its config. Returns True if it was loaded, False otherwise."""
|
||||
from . import get_tts_backend_for_engine
|
||||
from ..services import tts, transcribe, llm as llm_service
|
||||
@@ -570,7 +569,7 @@ def unload_model_by_config(config: ModelConfig) -> bool:
|
||||
if config.engine == "whisper":
|
||||
whisper_model = transcribe.get_whisper_model()
|
||||
if whisper_model.is_loaded() and whisper_model.model_size == config.model_size:
|
||||
transcribe.unload_whisper_model()
|
||||
await unload_backend(whisper_model)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -578,7 +577,7 @@ def unload_model_by_config(config: ModelConfig) -> bool:
|
||||
backend = llm_service.get_llm_model()
|
||||
loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None)
|
||||
if backend.is_loaded() and loaded_size == config.model_size:
|
||||
backend.unload_model()
|
||||
await unload_backend(backend)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -586,7 +585,7 @@ def unload_model_by_config(config: ModelConfig) -> bool:
|
||||
tts_model = tts.get_tts_model()
|
||||
loaded_size = getattr(tts_model, "_current_model_size", None) or getattr(tts_model, "model_size", None)
|
||||
if tts_model.is_loaded() and loaded_size == config.model_size:
|
||||
tts.unload_tts_model()
|
||||
await unload_backend(tts_model)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -594,14 +593,14 @@ def unload_model_by_config(config: ModelConfig) -> bool:
|
||||
backend = get_tts_backend_for_engine(config.engine)
|
||||
loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None)
|
||||
if backend.is_loaded() and loaded_size == config.model_size:
|
||||
backend.unload_model()
|
||||
await unload_backend(backend)
|
||||
return True
|
||||
return False
|
||||
|
||||
# All other TTS engines
|
||||
backend = get_tts_backend_for_engine(config.engine)
|
||||
if backend.is_loaded():
|
||||
backend.unload_model()
|
||||
await unload_backend(backend)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -248,13 +248,9 @@ class HumeTadaBackend:
|
||||
audio = audio.T # (samples, channels) -> (channels, samples)
|
||||
audio = audio.to(device)
|
||||
|
||||
# Encode with forced alignment.
|
||||
# Must run under inference_mode: encoder params still require
|
||||
# grad by default, and an autograd graph across the DAC/Snake
|
||||
# stack can balloon VRAM far past the model footprint (#890).
|
||||
# Encode with forced alignment
|
||||
text_arg = [reference_text] if reference_text else None
|
||||
with torch.inference_mode():
|
||||
prompt = self.encoder(audio, text=text_arg, sample_rate=sr)
|
||||
prompt = self.encoder(audio, text=text_arg, sample_rate=sr)
|
||||
|
||||
# Serialize EncoderOutput to a dict of CPU tensors for caching
|
||||
prompt_dict = {}
|
||||
|
||||
@@ -3,7 +3,6 @@ MLX backend implementation for TTS and STT using mlx-audio.
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Tuple
|
||||
import asyncio
|
||||
import logging
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
@@ -19,6 +18,7 @@ ensure_original_qwen_config_cached()
|
||||
|
||||
from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
|
||||
from .base import is_model_cached, combine_voice_prompts as _combine_voice_prompts, model_load_progress
|
||||
from ..services.mlx_thread import run_on_mlx_thread, clear_mlx_cache
|
||||
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
||||
|
||||
|
||||
@@ -63,6 +63,22 @@ class MLXTTSBackend:
|
||||
weight_extensions=(".safetensors", ".bin", ".npz"),
|
||||
)
|
||||
|
||||
def _ensure_loaded_sync(self, model_size: Optional[str]):
|
||||
"""Load the model if the requested size isn't already resident.
|
||||
|
||||
Runs on the MLX worker thread so it stays serialized with generation.
|
||||
"""
|
||||
if model_size is None:
|
||||
model_size = self.model_size
|
||||
|
||||
if self.model is not None and self._current_model_size == model_size:
|
||||
return
|
||||
|
||||
if self.model is not None and self._current_model_size != model_size:
|
||||
self.unload_model()
|
||||
|
||||
self._load_model_sync(model_size)
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
"""
|
||||
Lazy load the MLX TTS model.
|
||||
@@ -70,23 +86,15 @@ class MLXTTSBackend:
|
||||
Args:
|
||||
model_size: Model size to load (1.7B or 0.6B)
|
||||
"""
|
||||
if model_size is None:
|
||||
model_size = self.model_size
|
||||
|
||||
# If already loaded with correct size, return
|
||||
if self.model is not None and self._current_model_size == model_size:
|
||||
return
|
||||
|
||||
# Unload existing model if different size requested
|
||||
if self.model is not None and self._current_model_size != model_size:
|
||||
self.unload_model()
|
||||
|
||||
# Run blocking load in thread pool
|
||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||
await run_on_mlx_thread(self._ensure_loaded_sync, model_size)
|
||||
|
||||
# Alias for compatibility
|
||||
load_model = load_model_async
|
||||
|
||||
async def unload(self):
|
||||
"""Free the model, serialized onto the MLX worker thread."""
|
||||
await run_on_mlx_thread(self.unload_model)
|
||||
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
model_path = self._get_model_path(model_size)
|
||||
@@ -110,6 +118,7 @@ class MLXTTSBackend:
|
||||
del self.model
|
||||
self.model = None
|
||||
self._current_model_size = None
|
||||
clear_mlx_cache()
|
||||
logger.info("MLX TTS model unloaded")
|
||||
|
||||
async def create_voice_prompt(
|
||||
@@ -187,8 +196,6 @@ class MLXTTSBackend:
|
||||
Returns:
|
||||
Tuple of (audio_array, sample_rate)
|
||||
"""
|
||||
await self.load_model_async(None)
|
||||
|
||||
logger.info("Generating audio for text: %s", text)
|
||||
|
||||
def _generate_sync():
|
||||
@@ -258,8 +265,13 @@ class MLXTTSBackend:
|
||||
|
||||
return audio, sample_rate
|
||||
|
||||
# Run blocking inference in thread pool
|
||||
audio, sample_rate = await asyncio.to_thread(_generate_sync)
|
||||
# Load-if-needed and inference run as one job on the MLX worker so a
|
||||
# concurrent unload or different-size load can't land between them.
|
||||
def _load_and_generate():
|
||||
self._ensure_loaded_sync(None)
|
||||
return _generate_sync()
|
||||
|
||||
audio, sample_rate = await run_on_mlx_thread(_load_and_generate)
|
||||
|
||||
return audio, sample_rate
|
||||
|
||||
@@ -279,12 +291,10 @@ class MLXSTTBackend:
|
||||
hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||
return is_model_cached(hf_repo, weight_extensions=(".safetensors", ".bin", ".npz"))
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
"""
|
||||
Lazy load the MLX Whisper model.
|
||||
def _ensure_loaded_sync(self, model_size: Optional[str]):
|
||||
"""Load the model if the requested size isn't already resident.
|
||||
|
||||
Args:
|
||||
model_size: Model size (tiny, base, small, medium, large)
|
||||
Runs on the MLX worker thread so it stays serialized with transcription.
|
||||
"""
|
||||
if model_size is None:
|
||||
model_size = self.model_size
|
||||
@@ -292,12 +302,24 @@ class MLXSTTBackend:
|
||||
if self.model is not None and self.model_size == model_size:
|
||||
return
|
||||
|
||||
# Run blocking load in thread pool
|
||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||
self._load_model_sync(model_size)
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
"""
|
||||
Lazy load the MLX Whisper model.
|
||||
|
||||
Args:
|
||||
model_size: Model size (tiny, base, small, medium, large)
|
||||
"""
|
||||
await run_on_mlx_thread(self._ensure_loaded_sync, model_size)
|
||||
|
||||
# Alias for compatibility
|
||||
load_model = load_model_async
|
||||
|
||||
async def unload(self):
|
||||
"""Free the model, serialized onto the MLX worker thread."""
|
||||
await run_on_mlx_thread(self.unload_model)
|
||||
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
@@ -319,6 +341,7 @@ class MLXSTTBackend:
|
||||
if self.model is not None:
|
||||
del self.model
|
||||
self.model = None
|
||||
clear_mlx_cache()
|
||||
logger.info("MLX Whisper model unloaded")
|
||||
|
||||
async def transcribe(
|
||||
@@ -338,8 +361,6 @@ class MLXSTTBackend:
|
||||
Returns:
|
||||
Transcribed text
|
||||
"""
|
||||
await self.load_model_async(model_size)
|
||||
|
||||
def _transcribe_sync():
|
||||
"""Run synchronous transcription in thread pool."""
|
||||
# MLX Whisper transcription using generate method
|
||||
@@ -363,5 +384,10 @@ class MLXSTTBackend:
|
||||
else:
|
||||
return str(result).strip()
|
||||
|
||||
# Run blocking transcription in thread pool
|
||||
return await asyncio.to_thread(_transcribe_sync)
|
||||
# Load-if-needed and transcription run as one job on the MLX worker so
|
||||
# a concurrent unload or load can't land between them.
|
||||
def _load_and_transcribe():
|
||||
self._ensure_loaded_sync(model_size)
|
||||
return _transcribe_sync()
|
||||
|
||||
return await run_on_mlx_thread(_load_and_transcribe)
|
||||
|
||||
@@ -19,6 +19,7 @@ from .base import (
|
||||
manual_seed,
|
||||
model_load_progress,
|
||||
)
|
||||
from ..services.mlx_thread import run_on_mlx_thread, clear_mlx_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -205,7 +206,11 @@ class MLXQwenLLMBackend:
|
||||
weight_extensions=(".safetensors", ".bin", ".npz"),
|
||||
)
|
||||
|
||||
async def load_model(self, model_size: Optional[str] = None) -> None:
|
||||
def _ensure_loaded_sync(self, model_size: Optional[str]) -> None:
|
||||
"""Load the model if the requested size isn't already resident.
|
||||
|
||||
Runs on the MLX worker thread so it stays serialized with generation.
|
||||
"""
|
||||
if model_size is None:
|
||||
model_size = self.model_size
|
||||
|
||||
@@ -215,7 +220,14 @@ class MLXQwenLLMBackend:
|
||||
if self.model is not None and self._current_model_size != model_size:
|
||||
self.unload_model()
|
||||
|
||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||
self._load_model_sync(model_size)
|
||||
|
||||
async def load_model(self, model_size: Optional[str] = None) -> None:
|
||||
await run_on_mlx_thread(self._ensure_loaded_sync, model_size)
|
||||
|
||||
async def unload(self) -> None:
|
||||
"""Free the model, serialized onto the MLX worker thread."""
|
||||
await run_on_mlx_thread(self.unload_model)
|
||||
|
||||
def _load_model_sync(self, model_size: str) -> None:
|
||||
from mlx_lm import load as mlx_load
|
||||
@@ -246,6 +258,7 @@ class MLXQwenLLMBackend:
|
||||
self.model = None
|
||||
self.tokenizer = None
|
||||
self._current_model_size = None
|
||||
clear_mlx_cache()
|
||||
logger.info("Qwen3 (MLX) unloaded")
|
||||
|
||||
async def generate(
|
||||
@@ -257,10 +270,13 @@ class MLXQwenLLMBackend:
|
||||
model_size: Optional[str] = None,
|
||||
examples: Optional[list[tuple[str, str]]] = None,
|
||||
) -> str:
|
||||
await self.load_model(model_size)
|
||||
return await asyncio.to_thread(
|
||||
self._generate_sync, prompt, system, max_tokens, temperature, examples
|
||||
)
|
||||
# Load-if-needed and inference run as one job on the MLX worker so a
|
||||
# concurrent unload or different-size load can't land between them.
|
||||
def _load_and_generate() -> str:
|
||||
self._ensure_loaded_sync(model_size)
|
||||
return self._generate_sync(prompt, system, max_tokens, temperature, examples)
|
||||
|
||||
return await run_on_mlx_thread(_load_and_generate)
|
||||
|
||||
def _generate_sync(
|
||||
self,
|
||||
|
||||
@@ -243,6 +243,13 @@ def _migrate_capture_settings(engine, inspector, tables: set[str]) -> None:
|
||||
"hotkey_enabled BOOLEAN NOT NULL DEFAULT 0",
|
||||
"hotkey_enabled",
|
||||
)
|
||||
if "keep_mic_warm" not in columns:
|
||||
_add_column(
|
||||
engine,
|
||||
"capture_settings",
|
||||
"keep_mic_warm BOOLEAN NOT NULL DEFAULT 0",
|
||||
"keep_mic_warm",
|
||||
)
|
||||
|
||||
|
||||
def _migrate_mcp_bindings(engine, inspector, tables: set[str]) -> None:
|
||||
|
||||
@@ -210,6 +210,10 @@ class CaptureSettings(Base):
|
||||
# "Voicebox would like to receive keystrokes from any application" dialog
|
||||
# before they've even opened the Captures tab.
|
||||
hotkey_enabled = Column(Boolean, nullable=False, default=False)
|
||||
# Hold the microphone open while dictation is enabled so push-to-talk
|
||||
# doesn't clip the first words. Off by default — when on, the OS mic-in-use
|
||||
# indicator stays lit the whole time dictation is enabled.
|
||||
keep_mic_warm = Column(Boolean, nullable=False, default=False)
|
||||
# Lists of keytap key names (e.g. "MetaRight", "ControlRight"). Right-hand
|
||||
# modifiers by default so they don't collide with left-hand shortcuts.
|
||||
chord_push_to_talk_keys = Column(
|
||||
|
||||
@@ -12,7 +12,7 @@ import base64 as b64
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
@@ -49,7 +49,6 @@ def register_tools(mcp: FastMCP) -> None:
|
||||
engine: str | None = None,
|
||||
personality: bool | None = None,
|
||||
language: str | None = None,
|
||||
model_size: Literal["1.7B", "0.6B", "1B", "3B"] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Speak ``text`` in a voice profile.
|
||||
|
||||
@@ -62,12 +61,6 @@ def register_tools(mcp: FastMCP) -> None:
|
||||
LLM before TTS. When omitted, the per-client binding's
|
||||
``default_personality`` flag decides; when that is unset, the
|
||||
default is plain TTS.
|
||||
|
||||
``model_size`` selects a model variant for engines that ship more
|
||||
than one — ``qwen`` and ``qwen_custom_voice`` accept "1.7B" (default)
|
||||
or "0.6B"; ``tada`` accepts "1B" or "3B". Other engines ignore it.
|
||||
Omit to use the engine default. Requesting a smaller variant (e.g.
|
||||
"0.6B") is faster and avoids reloading a heavier model between calls.
|
||||
"""
|
||||
from ..database.models import MCPClientBinding
|
||||
|
||||
@@ -106,7 +99,6 @@ def register_tools(mcp: FastMCP) -> None:
|
||||
engine=resolved_engine,
|
||||
language=language,
|
||||
personality=use_persona,
|
||||
model_size=model_size,
|
||||
db=db,
|
||||
)
|
||||
finally:
|
||||
@@ -236,23 +228,18 @@ async def _speak(
|
||||
engine: str | None,
|
||||
language: str | None,
|
||||
personality: bool,
|
||||
model_size: str | None = None,
|
||||
db,
|
||||
) -> dict[str, Any]:
|
||||
"""Delegate to POST /generate — the route handles personality-rewrite
|
||||
internally when ``personality=true`` and the profile has a prompt."""
|
||||
from ..routes.generations import generate_speech
|
||||
|
||||
# model_size=None is intentional: generate_speech normalizes it to the
|
||||
# engine default (see routes/generations.py), so an omitted size behaves
|
||||
# exactly like the REST /generate endpoint with no model_size in the body.
|
||||
req = models.GenerationRequest(
|
||||
profile_id=profile_id,
|
||||
text=text,
|
||||
language=language or "en",
|
||||
engine=engine,
|
||||
personality=personality,
|
||||
model_size=model_size,
|
||||
)
|
||||
generation = await generate_speech(req, db)
|
||||
return _speak_response(generation, profile_name, source="mcp")
|
||||
|
||||
@@ -258,6 +258,7 @@ class CaptureSettingsResponse(BaseModel):
|
||||
allow_auto_paste: bool = True
|
||||
default_playback_voice_id: Optional[str] = None
|
||||
hotkey_enabled: bool = False
|
||||
keep_mic_warm: bool = False
|
||||
chord_push_to_talk_keys: List[str] = Field(
|
||||
default_factory=default_push_to_talk_chord
|
||||
)
|
||||
@@ -282,6 +283,7 @@ class CaptureSettingsUpdate(BaseModel):
|
||||
allow_auto_paste: Optional[bool] = None
|
||||
default_playback_voice_id: Optional[str] = None
|
||||
hotkey_enabled: Optional[bool] = None
|
||||
keep_mic_warm: Optional[bool] = None
|
||||
chord_push_to_talk_keys: Optional[List[str]] = Field(default=None, min_length=1, max_length=6)
|
||||
chord_toggle_to_talk_keys: Optional[List[str]] = Field(default=None, min_length=1, max_length=6)
|
||||
|
||||
|
||||
@@ -321,13 +321,7 @@ async def stream_speech(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Generate speech and stream the WAV audio directly without saving to disk."""
|
||||
from ..backends import (
|
||||
engine_needs_trim,
|
||||
engine_retries_runaway,
|
||||
ensure_model_cached_or_raise,
|
||||
get_tts_backend_for_engine,
|
||||
load_engine_model,
|
||||
)
|
||||
from ..backends import get_tts_backend_for_engine, ensure_model_cached_or_raise, load_engine_model, engine_needs_trim
|
||||
|
||||
profile = await profiles.get_profile(data.profile_id, db)
|
||||
if not profile:
|
||||
@@ -353,15 +347,10 @@ async def stream_speech(
|
||||
from ..utils.chunked_tts import generate_chunked
|
||||
|
||||
trim_fn = None
|
||||
runaway_detector = None
|
||||
if engine_needs_trim(engine):
|
||||
from ..utils.audio import trim_tts_output
|
||||
|
||||
trim_fn = trim_tts_output
|
||||
if engine_retries_runaway(engine):
|
||||
from ..utils.audio import has_tts_runaway
|
||||
|
||||
runaway_detector = has_tts_runaway
|
||||
|
||||
audio, sample_rate = await generate_chunked(
|
||||
tts_model,
|
||||
@@ -373,7 +362,6 @@ async def stream_speech(
|
||||
max_chunk_chars=data.max_chunk_chars,
|
||||
crossfade_ms=data.crossfade_ms,
|
||||
trim_fn=trim_fn,
|
||||
runaway_detector=runaway_detector,
|
||||
)
|
||||
|
||||
effects_chain_config = None
|
||||
|
||||
@@ -151,9 +151,7 @@ async def export_generation(
|
||||
safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
|
||||
if not safe_text:
|
||||
safe_text = "generation"
|
||||
# Append a short id so exports of similarly-worded generations don't collide
|
||||
# on the same filename (the first 30 chars are frequently identical).
|
||||
filename = f"generation-{safe_text}-{generation_id[:8]}.voicebox.zip"
|
||||
filename = f"generation-{safe_text}.voicebox.zip"
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(zip_bytes),
|
||||
@@ -182,9 +180,7 @@ async def export_generation_audio(
|
||||
safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
|
||||
if not safe_text:
|
||||
safe_text = "generation"
|
||||
# Append a short id so exports of similarly-worded generations don't collide
|
||||
# on the same filename (the first 30 chars are frequently identical).
|
||||
filename = f"{safe_text}-{generation_id[:8]}.wav"
|
||||
filename = f"{safe_text}.wav"
|
||||
|
||||
return FileResponse(
|
||||
audio_path,
|
||||
|
||||
@@ -66,7 +66,7 @@ async def unload_model():
|
||||
from ..services import tts
|
||||
|
||||
try:
|
||||
tts.unload_tts_model()
|
||||
await tts.unload_tts_model()
|
||||
return {"message": "Model unloaded successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -82,7 +82,7 @@ async def unload_model_by_name(model_name: str):
|
||||
raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}")
|
||||
|
||||
try:
|
||||
was_loaded = unload_model_by_config(config)
|
||||
was_loaded = await unload_model_by_config(config)
|
||||
if not was_loaded:
|
||||
return {"message": f"Model {model_name} is not loaded"}
|
||||
return {"message": f"Model {model_name} unloaded successfully"}
|
||||
@@ -457,7 +457,7 @@ async def delete_model(model_name: str):
|
||||
hf_repo_id = config.hf_repo_id
|
||||
|
||||
try:
|
||||
unload_model_by_config(config)
|
||||
await unload_model_by_config(config)
|
||||
|
||||
cache_dir = hf_constants.HF_HUB_CACHE
|
||||
repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--"))
|
||||
|
||||
@@ -232,7 +232,7 @@ async def upload_profile_avatar(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Upload or update avatar image for a profile."""
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename or "").suffix) as tmp:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp:
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
@@ -35,25 +35,13 @@ async def transcribe_audio(
|
||||
tmp.write(chunk)
|
||||
tmp_path = tmp.name
|
||||
|
||||
stt_path = tmp_path
|
||||
try:
|
||||
from ..utils.audio import load_audio, save_audio
|
||||
from ..utils.audio import load_audio
|
||||
from ..backends import WHISPER_HF_REPOS
|
||||
|
||||
audio, sr = await asyncio.to_thread(load_audio, tmp_path)
|
||||
duration = len(audio) / sr
|
||||
|
||||
# The STT backend (mlx_audio.stt -> miniaudio) only decodes
|
||||
# WAV/FLAC/MP3/Vorbis, so browser recordings uploaded as WebM/Opus
|
||||
# fail with "unsupported file format" (issue: web-mode dictation).
|
||||
# librosa already decoded the file above (it falls back to
|
||||
# audioread/ffmpeg for exotic containers), so re-encode that PCM to a
|
||||
# temp WAV and hand *that* to Whisper. WAV inputs pass through
|
||||
# unchanged.
|
||||
if file_suffix != ".wav":
|
||||
stt_path = f"{tmp_path}.stt.wav"
|
||||
await asyncio.to_thread(save_audio, audio, stt_path, sr)
|
||||
|
||||
whisper_model = transcribe.get_whisper_model()
|
||||
model_size = model if model else whisper_model.model_size
|
||||
|
||||
@@ -88,7 +76,7 @@ async def transcribe_audio(
|
||||
},
|
||||
)
|
||||
|
||||
text = await whisper_model.transcribe(stt_path, language, model_size)
|
||||
text = await whisper_model.transcribe(tmp_path, language, model_size)
|
||||
|
||||
return models.TranscriptionResponse(
|
||||
text=text,
|
||||
@@ -101,5 +89,3 @@ async def transcribe_audio(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
if stt_path != tmp_path:
|
||||
Path(stt_path).unlink(missing_ok=True)
|
||||
|
||||
@@ -48,14 +48,9 @@ async def run_generation(
|
||||
This is the single entry point for all background generation work.
|
||||
It is designed to be enqueued via ``services.task_queue.enqueue_generation``.
|
||||
"""
|
||||
from ..backends import (
|
||||
engine_needs_trim,
|
||||
engine_retries_runaway,
|
||||
get_tts_backend_for_engine,
|
||||
load_engine_model,
|
||||
)
|
||||
from ..backends import load_engine_model, get_tts_backend_for_engine, engine_needs_trim
|
||||
from ..utils.chunked_tts import generate_chunked
|
||||
from ..utils.audio import has_tts_runaway, normalize_audio, save_audio, trim_tts_output
|
||||
from ..utils.audio import normalize_audio, save_audio, trim_tts_output
|
||||
|
||||
task_manager = get_task_manager()
|
||||
bg_db = next(get_db())
|
||||
@@ -77,14 +72,12 @@ async def run_generation(
|
||||
|
||||
await history.update_generation_status(generation_id, "generating", bg_db)
|
||||
trim_fn = trim_tts_output if engine_needs_trim(engine) else None
|
||||
runaway_detector = has_tts_runaway if engine_retries_runaway(engine) else None
|
||||
|
||||
gen_kwargs: dict = dict(
|
||||
language=language,
|
||||
seed=seed if mode != "regenerate" else None,
|
||||
instruct=instruct,
|
||||
trim_fn=trim_fn,
|
||||
runaway_detector=runaway_detector,
|
||||
)
|
||||
if max_chunk_chars is not None:
|
||||
gen_kwargs["max_chunk_chars"] = max_chunk_chars
|
||||
@@ -274,14 +267,9 @@ async def generate_audio_sync(
|
||||
normalize, then encodes in-memory via :func:`tts.audio_to_wav_bytes`
|
||||
(same helper ``/generate/stream`` uses).
|
||||
"""
|
||||
from ..backends import (
|
||||
engine_needs_trim,
|
||||
engine_retries_runaway,
|
||||
get_tts_backend_for_engine,
|
||||
load_engine_model,
|
||||
)
|
||||
from ..backends import load_engine_model, get_tts_backend_for_engine, engine_needs_trim
|
||||
from ..utils.chunked_tts import generate_chunked
|
||||
from ..utils.audio import has_tts_runaway, normalize_audio, trim_tts_output
|
||||
from ..utils.audio import normalize_audio, trim_tts_output
|
||||
from . import tts
|
||||
|
||||
bg_db = next(get_db())
|
||||
@@ -299,14 +287,12 @@ async def generate_audio_sync(
|
||||
bg_db.close()
|
||||
|
||||
trim_fn = trim_tts_output if engine_needs_trim(engine) else None
|
||||
runaway_detector = has_tts_runaway if engine_retries_runaway(engine) else None
|
||||
|
||||
gen_kwargs: dict = dict(
|
||||
language=language,
|
||||
seed=seed,
|
||||
instruct=instruct,
|
||||
trim_fn=trim_fn,
|
||||
runaway_detector=runaway_detector,
|
||||
)
|
||||
if max_chunk_chars is not None:
|
||||
gen_kwargs["max_chunk_chars"] = max_chunk_chars
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
LLM inference module - delegates to backend abstraction layer.
|
||||
"""
|
||||
|
||||
from ..backends import get_llm_backend, LLMBackend
|
||||
from ..backends import LLMBackend, get_llm_backend, unload_backend
|
||||
|
||||
|
||||
def get_llm_model() -> LLMBackend:
|
||||
@@ -10,6 +10,6 @@ def get_llm_model() -> LLMBackend:
|
||||
return get_llm_backend()
|
||||
|
||||
|
||||
def unload_llm_model() -> None:
|
||||
"""Unload LLM model to free memory."""
|
||||
get_llm_backend().unload_model()
|
||||
async def unload_llm_model() -> None:
|
||||
"""Unload LLM model to free memory, serialized onto the MLX worker."""
|
||||
await unload_backend(get_llm_backend())
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Single dedicated worker thread for all MLX GPU work.
|
||||
|
||||
MLX's Metal command encoder/stream is thread-local: it binds to whichever
|
||||
thread first touches the GPU device. ``asyncio.to_thread()`` uses the event
|
||||
loop's default executor, which hands successive calls to different worker
|
||||
threads — a model loaded on one thread and generated on another raises
|
||||
"There is no Stream(gpu, N) in current thread" (issue #699).
|
||||
|
||||
Routing every MLX load, generate, transcribe and unload through this one
|
||||
worker keeps them on a single thread. Because the pool has a single worker,
|
||||
submitted jobs also run to completion one at a time in submission order, so a
|
||||
load-then-infer pair submitted as one job cannot be interleaved with an unload
|
||||
or a different-size load from another request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
_mlx_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="mlx-worker")
|
||||
|
||||
|
||||
def run_on_mlx_thread(func, *args):
|
||||
"""Run ``func(*args)`` on the single dedicated MLX worker thread."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return loop.run_in_executor(_mlx_executor, func, *args)
|
||||
|
||||
|
||||
def clear_mlx_cache() -> None:
|
||||
"""Return MLX's cached unified memory to the OS after a model is freed.
|
||||
|
||||
Must run on the MLX worker thread (call it from an unload that is already
|
||||
routed through ``run_on_mlx_thread``). ``clear_cache`` moved out of the
|
||||
``mlx.core.metal`` namespace in newer MLX, so resolve it from either.
|
||||
"""
|
||||
import mlx.core as mx
|
||||
|
||||
clear = getattr(mx, "clear_cache", None) or getattr(getattr(mx, "metal", None), "clear_cache", None)
|
||||
if clear is not None:
|
||||
clear()
|
||||
@@ -2,21 +2,19 @@
|
||||
STT (Speech-to-Text) module - delegates to backend abstraction layer.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from ..backends import get_stt_backend, STTBackend
|
||||
from ..backends import STTBackend, get_stt_backend, unload_backend
|
||||
|
||||
|
||||
def get_whisper_model() -> STTBackend:
|
||||
"""
|
||||
Get STT backend instance (MLX or PyTorch based on platform).
|
||||
|
||||
|
||||
Returns:
|
||||
STT backend instance
|
||||
"""
|
||||
return get_stt_backend()
|
||||
|
||||
|
||||
def unload_whisper_model():
|
||||
"""Unload Whisper model to free memory."""
|
||||
backend = get_stt_backend()
|
||||
backend.unload_model()
|
||||
async def unload_whisper_model():
|
||||
"""Unload Whisper model to free memory, serialized onto the MLX worker."""
|
||||
await unload_backend(get_stt_backend())
|
||||
|
||||
@@ -2,28 +2,27 @@
|
||||
TTS inference module - delegates to backend abstraction layer.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import io
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
from ..backends import get_tts_backend, TTSBackend
|
||||
from ..backends import TTSBackend, get_tts_backend, unload_backend
|
||||
|
||||
|
||||
def get_tts_model() -> TTSBackend:
|
||||
"""
|
||||
Get TTS backend instance (MLX or PyTorch based on platform).
|
||||
|
||||
|
||||
Returns:
|
||||
TTS backend instance
|
||||
"""
|
||||
return get_tts_backend()
|
||||
|
||||
|
||||
def unload_tts_model():
|
||||
"""Unload TTS model to free memory."""
|
||||
backend = get_tts_backend()
|
||||
backend.unload_model()
|
||||
async def unload_tts_model():
|
||||
"""Unload TTS model to free memory, serialized onto the MLX worker."""
|
||||
await unload_backend(get_tts_backend())
|
||||
|
||||
|
||||
def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Ensure TADA voice-prompt encoding disables autograd (#890)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
import torch
|
||||
|
||||
from backend.backends.hume_backend import HumeTadaBackend
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeEncoderOutput:
|
||||
emb: torch.Tensor
|
||||
|
||||
|
||||
class _GradTrackingEncoder:
|
||||
"""Raises unless called under torch.inference_mode()."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.called_under_inference_mode = False
|
||||
|
||||
def __call__(self, audio, text=None, sample_rate=None):
|
||||
self.called_under_inference_mode = torch.is_inference_mode_enabled()
|
||||
if not self.called_under_inference_mode:
|
||||
raise AssertionError("encoder forward must run under inference_mode")
|
||||
# Touch a requires_grad tensor the way Snake1d alpha would.
|
||||
alpha = torch.nn.Parameter(torch.ones(1, device=audio.device))
|
||||
_ = audio.mean() * alpha
|
||||
return _FakeEncoderOutput(emb=torch.zeros(1, 4, device=audio.device))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_voice_prompt_runs_encoder_under_inference_mode(tmp_path, monkeypatch):
|
||||
wav = tmp_path / "ref.wav"
|
||||
sf.write(str(wav), np.zeros(24000, dtype=np.float32), 24000)
|
||||
|
||||
backend = HumeTadaBackend()
|
||||
backend.model = object() # mark loaded
|
||||
backend.model_size = "1B"
|
||||
backend._device = "cpu"
|
||||
encoder = _GradTrackingEncoder()
|
||||
backend.encoder = encoder
|
||||
|
||||
monkeypatch.setattr(backend, "load_model", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
"backend.backends.hume_backend.get_cached_voice_prompt",
|
||||
lambda key: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"backend.backends.hume_backend.cache_voice_prompt",
|
||||
lambda key, value: None,
|
||||
)
|
||||
|
||||
prompt, from_cache = await backend.create_voice_prompt(
|
||||
str(wav),
|
||||
reference_text="hello world",
|
||||
use_cache=False,
|
||||
)
|
||||
|
||||
assert from_cache is False
|
||||
assert encoder.called_under_inference_mode is True
|
||||
assert isinstance(prompt["emb"], torch.Tensor)
|
||||
assert prompt["emb"].device.type == "cpu"
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Tests for the voicebox.speak MCP tool's ``model_size`` plumbing (issue #884).
|
||||
|
||||
The MCP speak path used to build its ``GenerationRequest`` without a
|
||||
``model_size``, so every agent-triggered generation silently fell back to the
|
||||
schema default ("1.7B") — there was no way to reach 0.6B (or TADA's 1B/3B)
|
||||
through MCP. These tests pin the fix: ``_speak`` now forwards ``model_size``
|
||||
straight into the request, matching the REST ``/generate`` surface.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
import backend.routes.generations as generations
|
||||
from backend.mcp_server import tools
|
||||
|
||||
|
||||
class _FakeGeneration:
|
||||
"""Minimal stand-in for GenerationResponse consumed by ``_speak_response``."""
|
||||
|
||||
def model_dump(self, mode="json"):
|
||||
return {"id": "gen-test", "status": "generating"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured_request(monkeypatch):
|
||||
"""Replace the real (torch-backed) generate_speech with a capturing stub.
|
||||
|
||||
``_speak`` imports ``generate_speech`` lazily from ``routes.generations``,
|
||||
so patching the attribute on that module intercepts the call and lets us
|
||||
inspect the ``GenerationRequest`` it would have run.
|
||||
"""
|
||||
captured = {}
|
||||
|
||||
async def fake_generate_speech(req, db):
|
||||
captured["req"] = req
|
||||
return _FakeGeneration()
|
||||
|
||||
monkeypatch.setattr(generations, "generate_speech", fake_generate_speech)
|
||||
# Isolate the unit from the MCP event bus — _speak_response fires a
|
||||
# speak-start event we don't care about here.
|
||||
monkeypatch.setattr(tools.mcp_events, "publish", lambda *a, **k: None)
|
||||
return captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_speak_forwards_explicit_model_size(captured_request):
|
||||
await tools._speak(
|
||||
profile_id="p1",
|
||||
profile_name="Morgan",
|
||||
text="hello",
|
||||
engine="qwen",
|
||||
language="en",
|
||||
personality=False,
|
||||
model_size="0.6B",
|
||||
db=None,
|
||||
)
|
||||
assert captured_request["req"].model_size == "0.6B"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_speak_omitted_model_size_is_none(captured_request):
|
||||
# Omitted → None; generate_speech normalizes None to the engine default,
|
||||
# so this reproduces the pre-fix behaviour for callers that don't ask.
|
||||
await tools._speak(
|
||||
profile_id="p1",
|
||||
profile_name="Morgan",
|
||||
text="hello",
|
||||
engine="qwen",
|
||||
language="en",
|
||||
personality=False,
|
||||
db=None,
|
||||
)
|
||||
assert captured_request["req"].model_size is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_speak_rejects_invalid_model_size(captured_request):
|
||||
# The GenerationRequest schema pattern is the single source of truth for
|
||||
# valid sizes; a bad value is rejected before any generation runs.
|
||||
with pytest.raises(ValidationError):
|
||||
await tools._speak(
|
||||
profile_id="p1",
|
||||
profile_name="Morgan",
|
||||
text="hello",
|
||||
engine="qwen",
|
||||
language="en",
|
||||
personality=False,
|
||||
model_size="9B",
|
||||
db=None,
|
||||
)
|
||||
assert "req" not in captured_request
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Regression tests for MLX single-thread serialization.
|
||||
|
||||
MLX's Metal stream is thread-local, so every load/generate/unload must run on
|
||||
one dedicated worker thread (issue #699), and a load+infer pair must run as one
|
||||
atomic job so a concurrent unload or different-size load can't land between the
|
||||
load and the inference that reads the model.
|
||||
|
||||
These drive the real async orchestration on ``MLXQwenLLMBackend`` with the
|
||||
heavy mlx-lm calls faked, so they exercise the shipped code paths without
|
||||
needing MLX installed.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.backends.qwen_llm_backend import MLXQwenLLMBackend
|
||||
from backend.services import llm as llm_service
|
||||
from backend.services.mlx_thread import run_on_mlx_thread
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_on_mlx_thread_uses_a_single_worker():
|
||||
idents = set()
|
||||
|
||||
def record():
|
||||
idents.add(threading.get_ident())
|
||||
|
||||
await asyncio.gather(*(run_on_mlx_thread(record) for _ in range(12)))
|
||||
|
||||
assert len(idents) == 1, "MLX work must stay pinned to one worker thread"
|
||||
assert idents.pop() != threading.get_ident(), "MLX work must not run on the event loop thread"
|
||||
|
||||
|
||||
def _install_fakes(backend, worker_threads):
|
||||
"""Replace the heavy sync internals with fakes that record their thread.
|
||||
|
||||
``_load_model_sync`` and ``_generate_sync`` sleep briefly so that, if the
|
||||
load and inference of one request were ever split into separate jobs, a
|
||||
second request could interleave and be observed.
|
||||
"""
|
||||
|
||||
def fake_load(model_size):
|
||||
worker_threads.add(threading.get_ident())
|
||||
time.sleep(0.02)
|
||||
backend.model = {"size": model_size}
|
||||
backend._current_model_size = model_size
|
||||
backend.model_size = model_size
|
||||
|
||||
def fake_unload():
|
||||
worker_threads.add(threading.get_ident())
|
||||
backend.model = None
|
||||
backend._current_model_size = None
|
||||
|
||||
def fake_generate(prompt, system, max_tokens, temperature, examples=None):
|
||||
worker_threads.add(threading.get_ident())
|
||||
# Capture the resident model, do "work", then confirm it wasn't
|
||||
# swapped or freed underneath us — that is exactly the interleave the
|
||||
# atomic load+infer job is meant to prevent.
|
||||
resident = backend.model
|
||||
assert resident is not None, "model was freed mid-generation"
|
||||
time.sleep(0.02)
|
||||
assert backend.model is resident, "model was swapped mid-generation"
|
||||
return resident["size"]
|
||||
|
||||
backend._load_model_sync = fake_load
|
||||
backend.unload_model = fake_unload
|
||||
backend._generate_sync = fake_generate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_generate_does_not_cross_models():
|
||||
backend = MLXQwenLLMBackend()
|
||||
worker_threads = set()
|
||||
_install_fakes(backend, worker_threads)
|
||||
|
||||
small, large = await asyncio.gather(
|
||||
backend.generate("a", model_size="0.6B"),
|
||||
backend.generate("b", model_size="4B"),
|
||||
)
|
||||
|
||||
assert small == "0.6B"
|
||||
assert large == "4B"
|
||||
assert len(worker_threads) == 1, "load and generate must share the one MLX thread"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unload_cannot_free_model_mid_generation():
|
||||
backend = MLXQwenLLMBackend()
|
||||
worker_threads = set()
|
||||
_install_fakes(backend, worker_threads)
|
||||
|
||||
await backend.load_model("0.6B")
|
||||
|
||||
# An unload issued while a generation is in flight must serialize behind it
|
||||
# on the worker rather than free the model out from under it.
|
||||
size, _ = await asyncio.gather(
|
||||
backend.generate("a", model_size="0.6B"),
|
||||
backend.unload(),
|
||||
)
|
||||
|
||||
assert size == "0.6B"
|
||||
assert backend.model is None, "unload should still take effect once generation completes"
|
||||
assert len(worker_threads) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_path_unload_serializes_with_generation(monkeypatch):
|
||||
# The service unload helpers (tts/stt/llm) all route through unload_backend,
|
||||
# which must serialize on the MLX worker rather than free the model on the
|
||||
# event-loop thread mid-generation.
|
||||
backend = MLXQwenLLMBackend()
|
||||
worker_threads = set()
|
||||
_install_fakes(backend, worker_threads)
|
||||
monkeypatch.setattr(llm_service, "get_llm_backend", lambda: backend)
|
||||
|
||||
await backend.load_model("0.6B")
|
||||
|
||||
size, _ = await asyncio.gather(
|
||||
backend.generate("a", model_size="0.6B"),
|
||||
llm_service.unload_llm_model(),
|
||||
)
|
||||
|
||||
assert size == "0.6B"
|
||||
assert backend.model is None
|
||||
assert len(worker_threads) == 1
|
||||
@@ -1,117 +0,0 @@
|
||||
"""Regression coverage for runaway MLX Qwen TTS output."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from backend.backends import engine_needs_trim, engine_retries_runaway
|
||||
from backend.utils.audio import has_tts_runaway
|
||||
from backend.utils.chunked_tts import generate_chunked
|
||||
|
||||
SAMPLE_RATE = 1000
|
||||
|
||||
|
||||
def test_mlx_qwen_enables_runaway_retry_without_aggressive_trim():
|
||||
with patch("backend.backends.get_backend_type", return_value="mlx"):
|
||||
assert engine_needs_trim("qwen") is False
|
||||
assert engine_retries_runaway("qwen") is True
|
||||
|
||||
|
||||
def test_pytorch_qwen_keeps_runaway_retry_disabled():
|
||||
with patch("backend.backends.get_backend_type", return_value="pytorch"):
|
||||
assert engine_needs_trim("qwen") is False
|
||||
assert engine_retries_runaway("qwen") is False
|
||||
|
||||
|
||||
def test_detector_flags_long_internal_silence():
|
||||
speech = np.full(2 * SAMPLE_RATE, 0.2, dtype=np.float32)
|
||||
runaway_gap = np.zeros(2500, dtype=np.float32)
|
||||
hallucinated_noise = np.full(2 * SAMPLE_RATE, 0.8, dtype=np.float32)
|
||||
audio = np.concatenate([speech, runaway_gap, hallucinated_noise])
|
||||
|
||||
assert has_tts_runaway(audio, SAMPLE_RATE) is True
|
||||
|
||||
|
||||
def test_detector_ignores_normal_internal_pause():
|
||||
speech = np.full(SAMPLE_RATE, 0.2, dtype=np.float32)
|
||||
normal_pause = np.zeros(1200, dtype=np.float32)
|
||||
audio = np.concatenate([speech, normal_pause, speech])
|
||||
|
||||
assert has_tts_runaway(audio, SAMPLE_RATE) is False
|
||||
|
||||
|
||||
def test_trailing_silence_is_not_a_runaway():
|
||||
speech = np.full(SAMPLE_RATE, 0.2, dtype=np.float32)
|
||||
trailing_silence = np.zeros(2 * SAMPLE_RATE, dtype=np.float32)
|
||||
|
||||
assert (
|
||||
has_tts_runaway(
|
||||
np.concatenate([speech, trailing_silence]),
|
||||
SAMPLE_RATE,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runaway_chunk_is_retried_as_smaller_chunks():
|
||||
class FakeBackend:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def generate(self, text, *_args):
|
||||
self.calls.append(text)
|
||||
if len(text) > 200:
|
||||
speech = np.full(SAMPLE_RATE, 0.2, dtype=np.float32)
|
||||
silence = np.zeros(2500, dtype=np.float32)
|
||||
noise = np.full(SAMPLE_RATE, 0.8, dtype=np.float32)
|
||||
return np.concatenate([speech, silence, noise]), SAMPLE_RATE
|
||||
return np.full(SAMPLE_RATE, 0.2, dtype=np.float32), SAMPLE_RATE
|
||||
|
||||
backend = FakeBackend()
|
||||
text = f"{'A' * 119}. {'B' * 119}."
|
||||
|
||||
audio, sample_rate = await generate_chunked(
|
||||
backend,
|
||||
text,
|
||||
{},
|
||||
max_chunk_chars=800,
|
||||
crossfade_ms=50,
|
||||
runaway_detector=has_tts_runaway,
|
||||
)
|
||||
|
||||
assert sample_rate == SAMPLE_RATE
|
||||
assert backend.calls == [text, f"{'A' * 119}.", f"{'B' * 119}."]
|
||||
assert len(audio) == 1950
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persistent_runaway_fails_instead_of_returning_corrupt_audio():
|
||||
class AlwaysRunawayBackend:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def generate(self, text, *_args):
|
||||
self.calls.append(text)
|
||||
speech = np.full(SAMPLE_RATE, 0.2, dtype=np.float32)
|
||||
silence = np.zeros(2500, dtype=np.float32)
|
||||
noise = np.full(SAMPLE_RATE, 0.8, dtype=np.float32)
|
||||
return np.concatenate([speech, silence, noise]), SAMPLE_RATE
|
||||
|
||||
backend = AlwaysRunawayBackend()
|
||||
text = f"{'A' * 119}. {'B' * 119}."
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="remained unstable after retrying smaller text chunks",
|
||||
):
|
||||
await generate_chunked(
|
||||
backend,
|
||||
text,
|
||||
{},
|
||||
max_chunk_chars=800,
|
||||
runaway_detector=has_tts_runaway,
|
||||
)
|
||||
|
||||
assert [len(call) for call in backend.calls] == [241, 120, 100]
|
||||
@@ -110,43 +110,6 @@ def save_audio(
|
||||
raise OSError(f"Failed to save audio to {path}: {e}") from e
|
||||
|
||||
|
||||
def has_tts_runaway(
|
||||
audio: np.ndarray,
|
||||
sample_rate: int = 24000,
|
||||
frame_ms: int = 20,
|
||||
silence_threshold_db: float = -40.0,
|
||||
max_internal_silence_ms: int = 2000,
|
||||
) -> bool:
|
||||
"""Detect speech followed by a long silence and then more output.
|
||||
|
||||
This shape is a reliable signal that a TTS model missed EOS and resumed
|
||||
with hallucinated speech or codec noise. Leading and trailing silence do
|
||||
not count because they are not bounded by non-silent audio.
|
||||
"""
|
||||
frame_len = int(sample_rate * frame_ms / 1000)
|
||||
if frame_len == 0 or len(audio) < frame_len:
|
||||
return False
|
||||
|
||||
n_frames = len(audio) // frame_len
|
||||
threshold_linear = 10 ** (silence_threshold_db / 20)
|
||||
max_silence_frames = int(max_internal_silence_ms / frame_ms)
|
||||
seen_speech = False
|
||||
consecutive_silence = 0
|
||||
|
||||
for i in range(n_frames):
|
||||
frame = audio[i * frame_len : (i + 1) * frame_len]
|
||||
is_speech = np.sqrt(np.mean(frame**2)) >= threshold_linear
|
||||
if is_speech:
|
||||
if seen_speech and consecutive_silence >= max_silence_frames:
|
||||
return True
|
||||
seen_speech = True
|
||||
consecutive_silence = 0
|
||||
elif seen_speech:
|
||||
consecutive_silence += 1
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def trim_tts_output(
|
||||
audio: np.ndarray,
|
||||
sample_rate: int = 24000,
|
||||
|
||||
@@ -20,8 +20,6 @@ logger = logging.getLogger("voicebox.chunked-tts")
|
||||
# Default chunk size in characters. Can be overridden per-request via
|
||||
# the ``max_chunk_chars`` field on GenerationRequest.
|
||||
DEFAULT_MAX_CHUNK_CHARS = 800
|
||||
MAX_RUNAWAY_RETRIES = 2
|
||||
MIN_RUNAWAY_RETRY_CHARS = 100
|
||||
|
||||
# Common abbreviations that should NOT be treated as sentence endings.
|
||||
# Lowercase for case-insensitive matching.
|
||||
@@ -213,7 +211,6 @@ async def generate_chunked(
|
||||
max_chunk_chars: int = DEFAULT_MAX_CHUNK_CHARS,
|
||||
crossfade_ms: int = 50,
|
||||
trim_fn=None,
|
||||
runaway_detector=None,
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
"""Generate audio with automatic chunking for long text.
|
||||
|
||||
@@ -242,75 +239,25 @@ async def generate_chunked(
|
||||
Optional ``(audio, sample_rate) -> audio`` post-processing
|
||||
function applied to each chunk before concatenation (e.g.
|
||||
``trim_tts_output`` for Chatterbox engines).
|
||||
runaway_detector : callable | None
|
||||
Optional ``(audio, sample_rate) -> bool`` detector. When it flags
|
||||
unstable output, the affected text is split in half and retried.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(audio, sample_rate) : Tuple[np.ndarray, int]
|
||||
"""
|
||||
async def generate_one(
|
||||
chunk_text: str,
|
||||
chunk_seed: int | None,
|
||||
retry_depth: int = 0,
|
||||
) -> tuple[np.ndarray, int]:
|
||||
chunk_audio, chunk_sr = await backend.generate(
|
||||
chunk_text,
|
||||
voice_prompt,
|
||||
language,
|
||||
chunk_seed,
|
||||
instruct,
|
||||
)
|
||||
|
||||
if runaway_detector is not None and runaway_detector(chunk_audio, chunk_sr):
|
||||
if retry_depth >= MAX_RUNAWAY_RETRIES or len(chunk_text) <= MIN_RUNAWAY_RETRY_CHARS:
|
||||
raise RuntimeError(
|
||||
"TTS output remained unstable after retrying smaller text chunks"
|
||||
)
|
||||
|
||||
retry_max_chars = max(MIN_RUNAWAY_RETRY_CHARS, len(chunk_text) // 2)
|
||||
retry_chunks = split_text_into_chunks(chunk_text, retry_max_chars)
|
||||
if len(retry_chunks) <= 1:
|
||||
raise RuntimeError("Unable to split unstable TTS output for retry")
|
||||
|
||||
logger.warning(
|
||||
"Detected unstable TTS output for %d chars; retrying as %d smaller chunks",
|
||||
len(chunk_text),
|
||||
len(retry_chunks),
|
||||
)
|
||||
retry_audio: list[np.ndarray] = []
|
||||
for i, retry_text in enumerate(retry_chunks):
|
||||
retry_seed = (
|
||||
chunk_seed + ((retry_depth + 1) * 1000) + i
|
||||
if chunk_seed is not None
|
||||
else None
|
||||
)
|
||||
audio, sample_rate = await generate_one(
|
||||
retry_text,
|
||||
retry_seed,
|
||||
retry_depth + 1,
|
||||
)
|
||||
retry_audio.append(np.asarray(audio, dtype=np.float32))
|
||||
|
||||
return (
|
||||
concatenate_audio_chunks(
|
||||
retry_audio,
|
||||
sample_rate,
|
||||
crossfade_ms=crossfade_ms,
|
||||
),
|
||||
sample_rate,
|
||||
)
|
||||
|
||||
if trim_fn is not None:
|
||||
chunk_audio = trim_fn(chunk_audio, chunk_sr)
|
||||
return np.asarray(chunk_audio, dtype=np.float32), chunk_sr
|
||||
|
||||
chunks = split_text_into_chunks(text, max_chunk_chars)
|
||||
|
||||
if len(chunks) <= 1:
|
||||
# Short text — single-shot fast path
|
||||
return await generate_one(text, seed)
|
||||
audio, sample_rate = await backend.generate(
|
||||
text,
|
||||
voice_prompt,
|
||||
language,
|
||||
seed,
|
||||
instruct,
|
||||
)
|
||||
if trim_fn is not None:
|
||||
audio = trim_fn(audio, sample_rate)
|
||||
return audio, sample_rate
|
||||
|
||||
# Long text — chunked generation
|
||||
logger.info(
|
||||
@@ -334,12 +281,17 @@ async def generate_chunked(
|
||||
# always produces the same output.
|
||||
chunk_seed = (seed + i) if seed is not None else None
|
||||
|
||||
chunk_audio, chunk_sr = await generate_one(
|
||||
chunk_audio, chunk_sr = await backend.generate(
|
||||
chunk_text,
|
||||
voice_prompt,
|
||||
language,
|
||||
chunk_seed,
|
||||
instruct,
|
||||
)
|
||||
if trim_fn is not None:
|
||||
chunk_audio = trim_fn(chunk_audio, chunk_sr)
|
||||
|
||||
audio_chunks.append(chunk_audio)
|
||||
audio_chunks.append(np.asarray(chunk_audio, dtype=np.float32))
|
||||
if sample_rate is None:
|
||||
sample_rate = chunk_sr
|
||||
|
||||
|
||||
@@ -73,37 +73,6 @@
|
||||
"vite": "^5.4.0",
|
||||
},
|
||||
},
|
||||
"landing": {
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@fontsource/space-grotesk": "^5.2.10",
|
||||
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.36.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.316.0",
|
||||
"marked": "^18.0.5",
|
||||
"next": "^16.1.3",
|
||||
"postcss": "^8.4.33",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"wavesurfer.js": "^7.12.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"typescript": "^5.3.3",
|
||||
},
|
||||
},
|
||||
"tauri": {
|
||||
"name": "@voicebox/tauri",
|
||||
"version": "0.5.0",
|
||||
@@ -153,8 +122,6 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/[email protected]", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="],
|
||||
@@ -221,8 +188,6 @@
|
||||
|
||||
"@dnd-kit/utilities": ["@dnd-kit/[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
@@ -285,8 +250,6 @@
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/[email protected]", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@fontsource/space-grotesk": ["@fontsource/[email protected]", "", {}, "sha512-XNXEbT74OIITPqw2H6HXwPDp85fy43uxfBwFR5PU+9sLnjuLj12KlhVM9nZVN6q6dlKjkuN8JisW/OBxwxgUew=="],
|
||||
|
||||
"@hookform/resolvers": ["@hookform/[email protected]", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="],
|
||||
|
||||
"@humanwhocodes/config-array": ["@humanwhocodes/[email protected]", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="],
|
||||
@@ -295,58 +258,6 @@
|
||||
|
||||
"@humanwhocodes/object-schema": ["@humanwhocodes/[email protected]", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
|
||||
|
||||
"@icons-pack/react-simple-icons": ["@icons-pack/[email protected]", "", { "peerDependencies": { "react": "^16.13 || ^17 || ^18 || ^19" } }, "sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g=="],
|
||||
|
||||
"@img/colour": ["@img/[email protected]", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/[email protected]", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -357,24 +268,6 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@next/env": ["@next/[email protected]", "", {}, "sha512-gkrXnZyxPUy0Gg6SrPQPccbNVLSP3vmW8LU5dwEttEEC1RwDivk8w4O+sZIjFvPrSICXyhQDCG+y3VmjlJf+9A=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-T8atLKuvk13XQUdVLCv1ZzMPgLPW0+DWWbHSQXs0/3TjPrKNxTmUIhOEaoEyl3Z82k8h/gEtqyuoZGv6+Ugawg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-AKC/qVjUGUQDSPI6gESTx0xOnOPQ5gttogNS3o6bA83yiaSZJek0Am5yXy82F1KcZCx3DdOwdGPZpQCluonuxg=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-POQ65+pnYOkZNdngWfMEt7r53bzWiKkVNbjpmCt1Zb3V6lxJNXSsjwRuTQ8P/kguxDC8LRkqaL3vvsFrce4dMQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-3Wm0zGYVCs6qDFAiSSDL+Z+r46EdtCv/2l+UlIdMbAq9hPJBvGu/rZOeuvCaIUjbArkmXac8HnTyQPJFzFWA0Q=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-lWAYAezFinaJiD5Gv8HDidtsZdT3CDaCeqoPoJjeB57OqzvMajpIhlZFce5sCAH6VuX4mdkxCRqecCJFwfm2nQ=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-fHaIpT7x4gA6VQbdEpYUXRGyge/YbRrkG6DXM60XiBqDM2g2NcrsQaIuj375egnGFkJow4RHacgBOEsHfGbiUw=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-MCrXxrTSE7jPN1NyXJr39E+aNFBrQZtO154LoCz7n99FuKqJDekgxipoodLNWdQP7/DZ5tKMc/efybx1l159hw=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-JSVlm9MDhmTXw/sO2PE/MRj+G6XOSMZB+BcZ0a7d6KwVFZVpkHcb2okyoYFBaco6LeiL53BBklRlOrDDbOeE5w=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
@@ -517,8 +410,6 @@
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="],
|
||||
|
||||
"@swc/helpers": ["@swc/[email protected]", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/[email protected]", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||
@@ -643,8 +534,6 @@
|
||||
|
||||
"@voicebox/app": ["@voicebox/app@workspace:app"],
|
||||
|
||||
"@voicebox/landing": ["@voicebox/landing@workspace:landing"],
|
||||
|
||||
"@voicebox/tauri": ["@voicebox/tauri@workspace:tauri"],
|
||||
|
||||
"@voicebox/web": ["@voicebox/web@workspace:web"],
|
||||
@@ -659,26 +548,16 @@
|
||||
|
||||
"ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"any-promise": ["[email protected]", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||
|
||||
"anymatch": ["[email protected]", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"arg": ["[email protected]", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"argparse": ["[email protected]", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
"argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-hidden": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"array-union": ["[email protected]", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
|
||||
|
||||
"autoprefixer": ["[email protected]", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
|
||||
|
||||
"balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="],
|
||||
|
||||
"binary-extensions": ["[email protected]", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
@@ -687,28 +566,20 @@
|
||||
|
||||
"callsites": ["[email protected]", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"camelcase-css": ["[email protected]", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||
|
||||
"caniuse-lite": ["[email protected]", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
|
||||
|
||||
"chalk": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"chokidar": ["[email protected]", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"class-variance-authority": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"classnames": ["[email protected]", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||
|
||||
"client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"color-convert": ["[email protected]", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"commander": ["[email protected]", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"concat-map": ["[email protected]", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
@@ -717,8 +588,6 @@
|
||||
|
||||
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"date-fns": ["[email protected]", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
|
||||
@@ -731,12 +600,8 @@
|
||||
|
||||
"detect-node-es": ["[email protected]", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"didyoumean": ["[email protected]", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||
|
||||
"dir-glob": ["[email protected]", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
|
||||
|
||||
"dlv": ["[email protected]", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
"doctrine": ["[email protected]", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="],
|
||||
|
||||
"electron-to-chromium": ["[email protected]", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="],
|
||||
@@ -761,8 +626,6 @@
|
||||
|
||||
"espree": ["[email protected]", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="],
|
||||
|
||||
"esprima": ["[email protected]", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"esquery": ["[email protected]", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrecurse": ["[email protected]", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
@@ -771,8 +634,6 @@
|
||||
|
||||
"esutils": ["[email protected]", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"extend-shallow": ["[email protected]", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
|
||||
|
||||
"fast-deep-equal": ["[email protected]", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
@@ -783,8 +644,6 @@
|
||||
|
||||
"fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
|
||||
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="],
|
||||
|
||||
"fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
@@ -795,16 +654,12 @@
|
||||
|
||||
"flatted": ["[email protected]", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"fraction.js": ["[email protected]", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.36.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-4PqYHAT7gev0ke0wos+PyrcFxI0HScjm3asgU8nSYa8YzJFuwgIvdj3/s3ZaxLq0bUSboIn19A2WS/MHwLCvfw=="],
|
||||
|
||||
"fs.realpath": ["[email protected]", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
||||
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-nonce": ["[email protected]", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
@@ -821,12 +676,8 @@
|
||||
|
||||
"graphemer": ["[email protected]", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
|
||||
|
||||
"gray-matter": ["[email protected]", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
|
||||
|
||||
"has-flag": ["[email protected]", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"html-parse-stringify": ["[email protected]", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||
|
||||
"i18next": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg=="],
|
||||
@@ -843,12 +694,6 @@
|
||||
|
||||
"inherits": ["[email protected]", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"is-binary-path": ["[email protected]", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-extendable": ["[email protected]", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
||||
|
||||
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
@@ -861,11 +706,11 @@
|
||||
|
||||
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="],
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
@@ -879,8 +724,6 @@
|
||||
|
||||
"keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"kind-of": ["[email protected]", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"levn": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||
@@ -907,10 +750,6 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||
|
||||
"lilconfig": ["[email protected]", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||
|
||||
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"loaders.css": ["[email protected]", "", {}, "sha512-Rhowlq24ey1VOeor+3wYOt9+MjaxBOJm1u4KlQgNC3+0xJ0LS4wq4iG57D/BPzvuD/7HHDGQOWJ+81oR2EI9bQ=="],
|
||||
|
||||
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
@@ -925,8 +764,6 @@
|
||||
|
||||
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
|
||||
|
||||
"merge2": ["[email protected]", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
@@ -941,22 +778,14 @@
|
||||
|
||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mz": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"natural-compare": ["[email protected]", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"next": ["[email protected]", "", { "dependencies": { "@next/env": "16.1.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.4", "@next/swc-darwin-x64": "16.1.4", "@next/swc-linux-arm64-gnu": "16.1.4", "@next/swc-linux-arm64-musl": "16.1.4", "@next/swc-linux-x64-gnu": "16.1.4", "@next/swc-linux-x64-musl": "16.1.4", "@next/swc-win32-arm64-msvc": "16.1.4", "@next/swc-win32-x64-msvc": "16.1.4", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-gKSecROqisnV7Buen5BfjmXAm7Xlpx9o2ueVQRo5DxQcjC8d330dOM1xiGWc2k3Dcnz0In3VybyRPOsudwgiqQ=="],
|
||||
|
||||
"node-releases": ["[email protected]", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"normalize-path": ["[email protected]", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"object-assign": ["[email protected]", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-hash": ["[email protected]", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||
|
||||
"once": ["[email protected]", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"optionator": ["[email protected]", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
@@ -973,32 +802,14 @@
|
||||
|
||||
"path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["[email protected]", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"path-type": ["[email protected]", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
|
||||
|
||||
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"pify": ["[email protected]", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
|
||||
|
||||
"pirates": ["[email protected]", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||
|
||||
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-import": ["[email protected]", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||
|
||||
"postcss-js": ["[email protected]", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
|
||||
|
||||
"postcss-load-config": ["[email protected]", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
|
||||
|
||||
"postcss-nested": ["[email protected]", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||
|
||||
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
"postcss-value-parser": ["[email protected]", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prop-types": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
@@ -1029,12 +840,6 @@
|
||||
|
||||
"react-style-singleton": ["[email protected]", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"read-cache": ["[email protected]", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||
|
||||
"readdirp": ["[email protected]", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"resolve": ["[email protected]", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"resolve-from": ["[email protected]", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"reusify": ["[email protected]", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
@@ -1047,16 +852,12 @@
|
||||
|
||||
"scheduler": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
"section-matter": ["[email protected]", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
|
||||
|
||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"seroval": ["[email protected]", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
|
||||
|
||||
"seroval-plugins": ["[email protected]", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
|
||||
|
||||
"sharp": ["[email protected]", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
@@ -1067,22 +868,12 @@
|
||||
|
||||
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sprintf-js": ["[email protected]", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-bom-string": ["[email protected]", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
|
||||
|
||||
"strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"styled-jsx": ["[email protected]", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"sucrase": ["[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||
|
||||
"supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"tailwind-merge": ["[email protected]", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
|
||||
|
||||
"tailwindcss": ["[email protected]", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
@@ -1093,22 +884,14 @@
|
||||
|
||||
"text-table": ["[email protected]", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="],
|
||||
|
||||
"thenify": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||
|
||||
"thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"tiny-invariant": ["[email protected]", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tiny-warning": ["[email protected]", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
|
||||
|
||||
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"ts-api-utils": ["[email protected]", "", { "peerDependencies": { "typescript": ">=4.2.0" } }, "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw=="],
|
||||
|
||||
"ts-interface-checker": ["[email protected]", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
@@ -1129,8 +912,6 @@
|
||||
|
||||
"use-sync-external-store": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"void-elements": ["[email protected]", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||
@@ -1151,8 +932,6 @@
|
||||
|
||||
"zustand": ["[email protected]", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||
|
||||
"@eslint/eslintrc/js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/[email protected]", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
|
||||
@@ -1179,8 +958,6 @@
|
||||
|
||||
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@tailwindcss/node/jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
@@ -1197,34 +974,14 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"@voicebox/landing/lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-dTmYX1H4IXsRfVcj/KUxworV6814ApTl7iXaS21AimK2RUEl4j4AfOmqD3VR8phe5V91m4vEJ8tCK4uT1jE5nA=="],
|
||||
|
||||
"@voicebox/landing/tailwind-merge": ["[email protected]", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||
|
||||
"@voicebox/landing/tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
||||
|
||||
"@voicebox/web/wavesurfer.js": ["[email protected]", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="],
|
||||
|
||||
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"eslint/js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"motion/framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.29.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg=="],
|
||||
|
||||
"next/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"sharp/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"tinyglobby/picomatch": ["[email protected]", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"@eslint/eslintrc/js-yaml/argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"eslint/js-yaml/argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"motion/framer-motion/motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.27.2" } }, "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA=="],
|
||||
|
||||
"motion/framer-motion/motion-utils": ["[email protected]", "", {}, "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q=="],
|
||||
|
||||
@@ -34,15 +34,3 @@ services:
|
||||
|
||||
# Tune the ROCm memory allocator
|
||||
- PYTORCH_HIP_ALLOC_CONF=garbage_collection_threshold:0.8,max_split_size_mb:512
|
||||
|
||||
# Redirect MIOpen kernel cache to a writable, persistent directory.
|
||||
# Without this, MIOpen may fail to write its cache and throw
|
||||
# miopenStatusUnknownError on fresh containers.
|
||||
- MIOPEN_USER_DB_PATH=/app/data/cache/miopen_db
|
||||
- MIOPEN_CUSTOM_CACHE_DIR=/app/data/cache/miopen_cache
|
||||
|
||||
# Use fast heuristics for kernel selection instead of exhaustive
|
||||
# benchmarking. On RDNA4, exhaustive mode tries kernels that fail to
|
||||
# allocate workspace memory (ptr: 0 size: 0), causing system stuttering
|
||||
# on every generation even when the cache is present.
|
||||
- MIOPEN_FIND_MODE=FAST
|
||||
|
||||
@@ -188,7 +188,6 @@ When creating a pull request:
|
||||
<File name="src-tauri/" />
|
||||
</Folder>
|
||||
<File name="web/" />
|
||||
<File name="landing/" />
|
||||
<File name="scripts/" />
|
||||
</Folder>
|
||||
</Files>
|
||||
|
||||
@@ -49,7 +49,6 @@ class ModelConfig:
|
||||
model_size: str = "default"
|
||||
size_mb: int = 0
|
||||
needs_trim: bool = False
|
||||
retries_runaway: bool = False
|
||||
supports_instruct: bool = False
|
||||
languages: list[str] = field(default_factory=lambda: ["en"])
|
||||
```
|
||||
@@ -60,7 +59,6 @@ Registry helpers in `backends/__init__.py` replace what used to be per-engine `i
|
||||
- `get_tts_model_configs()` — only TTS variants
|
||||
- `get_model_config(model_name)` — lookup by name
|
||||
- `engine_needs_trim(engine)` — whether output should run through `trim_tts_output()`
|
||||
- `engine_retries_runaway(engine)` — whether unstable output should be retried as smaller chunks
|
||||
- `load_engine_model(engine, model_size)` — downloads + loads, handles engines with multiple sizes
|
||||
- `get_tts_backend_for_engine(engine)` — thread-safe backend factory with double-checked locking
|
||||
|
||||
@@ -154,7 +152,7 @@ The request path from frontend to audio file:
|
||||
|
||||
6. **Inference** — the engine's `generate()` returns `(audio_array, sample_rate)`.
|
||||
|
||||
7. **Validate and post-process** — engines with `retries_runaway=True` retry unstable output as smaller chunks. If `engine_needs_trim(engine)` is True, `trim_tts_output()` strips trailing silence. Effects chains (if any) are applied per generation version, not the clean version.
|
||||
7. **Post-process** — if `engine_needs_trim(engine)` is True, `trim_tts_output()` strips trailing silence. Effects chains (if any) are applied per generation version, not the clean version.
|
||||
|
||||
8. **Persist** — audio is written to the generations directory, a row is inserted into the `generations` table, and the response includes the generation metadata.
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ Make sure you have [installed Voicebox](/overview/installation) and launched the
|
||||
Voice profiles are the foundation of Voicebox. Each profile contains voice samples that the AI uses to clone the voice.
|
||||
|
||||
<Steps>
|
||||
<Step title="Navigate to Voices">
|
||||
Click the **Voices** tab in the sidebar
|
||||
<Step title="Navigate to Profiles">
|
||||
Click the **Profiles** tab in the sidebar
|
||||
</Step>
|
||||
|
||||
<Step title="Create New Voice">
|
||||
Click the **+ New Voice** button
|
||||
<Step title="Create New Profile">
|
||||
Click the **+ New Profile** button
|
||||
|
||||
Fill in the details:
|
||||
- **Name:** A descriptive name (e.g., "John Smith")
|
||||
|
||||
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 114 KiB |
@@ -1,35 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -1,100 +0,0 @@
|
||||
# Voicebox Landing Page
|
||||
|
||||
Landing page for voicebox.sh - a modern Next.js 16 application.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Next.js 16** with App Router
|
||||
- **Bun** for package management
|
||||
- **Tailwind CSS** with shadcn/ui components
|
||||
- **TypeScript** with strict mode
|
||||
- **Railway** deployment ready
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Bun installed ([bun.sh](https://bun.sh))
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd landing
|
||||
bun install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) to view the landing page.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
bun run start
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Update Download Links
|
||||
|
||||
Edit `src/lib/constants.ts` to update:
|
||||
- `LATEST_VERSION` - Current release version
|
||||
- `DOWNLOAD_LINKS` - GitHub release download URLs
|
||||
- `GITHUB_REPO` - Repository URL
|
||||
|
||||
### Update GitHub Username
|
||||
|
||||
Replace `USERNAME` in `src/lib/constants.ts` with your actual GitHub username.
|
||||
|
||||
## Deployment to Railway
|
||||
|
||||
1. Connect your GitHub repository to Railway
|
||||
2. Railway will auto-detect `nixpacks.toml`
|
||||
3. Set root directory to `landing/`
|
||||
4. Railway will automatically:
|
||||
- Install dependencies with `bun install`
|
||||
- Build with `bun run build`
|
||||
- Start with `bun run start`
|
||||
5. Configure custom domain `voicebox.sh` in Railway settings
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
landing/
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── layout.tsx # Root layout with metadata
|
||||
│ │ ├── page.tsx # Landing page
|
||||
│ │ └── globals.css # Global styles
|
||||
│ ├── components/
|
||||
│ │ ├── Header.tsx # Top navigation
|
||||
│ │ ├── Footer.tsx # Footer
|
||||
│ │ ├── DownloadSection.tsx # Download buttons
|
||||
│ │ └── ui/ # shadcn/ui components
|
||||
│ └── lib/
|
||||
│ ├── utils.ts # Utility functions
|
||||
│ └── constants.ts # App constants
|
||||
├── public/
|
||||
│ └── voicebox-logo.png # Logo asset
|
||||
└── nixpacks.toml # Railway deployment config
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Responsive design (mobile-first)
|
||||
- Dark mode by default
|
||||
- SEO optimized metadata
|
||||
- Download links for Mac, Windows, Linux
|
||||
- Feature showcase
|
||||
- Platform highlights
|
||||
- GitHub integration
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: 'standalone',
|
||||
images: {
|
||||
unoptimized: false,
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
},
|
||||
turbopack: {},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -1,11 +0,0 @@
|
||||
[phases.setup]
|
||||
nixPkgs = ["nodejs_20", "bun"]
|
||||
|
||||
[phases.install]
|
||||
cmds = ["bun install"]
|
||||
|
||||
[phases.build]
|
||||
cmds = ["bun run build"]
|
||||
|
||||
[start]
|
||||
cmd = "bun run start"
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.5.0",
|
||||
"description": "Landing page for voicebox.sh",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"build": "bun --bun next build",
|
||||
"start": "bun --bun next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/space-grotesk": "^5.2.10",
|
||||
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.36.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.316.0",
|
||||
"marked": "^18.0.5",
|
||||
"next": "^16.1.3",
|
||||
"postcss": "^8.4.33",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"wavesurfer.js": "^7.12.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
Before Width: | Height: | Size: 860 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 594 KiB |
|
Before Width: | Height: | Size: 2.8 MiB |
@@ -1,14 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getLatestRelease } from '@/lib/releases';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const releaseInfo = await getLatestRelease();
|
||||
return NextResponse.json(releaseInfo);
|
||||
} catch (error) {
|
||||
console.error('Error fetching release info:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch release information' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getStarCount } from '@/lib/releases';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 600;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const count = await getStarCount();
|
||||
return NextResponse.json({ count });
|
||||
} catch (error) {
|
||||
console.error('Error fetching star count:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch star count' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import {readFileSync} from "node:fs";
|
||||
import {join} from "node:path";
|
||||
import {ImageResponse} from "next/og";
|
||||
import {formatDate, getPost, loadAllPosts} from "@/lib/blog";
|
||||
|
||||
// Per-post Open Graph image, generated with Satori at build time (static export
|
||||
// of each post route) and served as PNG. Note: this runs in the Satori renderer,
|
||||
// which only understands inline styles + flexbox and a subset of CSS — no
|
||||
// Tailwind classes, no `filter: blur()`. Glows are done with radial gradients.
|
||||
|
||||
export const size = {width: 1200, height: 630};
|
||||
export const contentType = "image/png";
|
||||
export const alt = "Voicebox Blog";
|
||||
|
||||
// Pre-build an image for every post route (mirrors the page's static params).
|
||||
export function generateStaticParams() {
|
||||
return loadAllPosts().map((post) => ({slug: post.slug}));
|
||||
}
|
||||
|
||||
// 8-bit PNG decodes reliably in Satori; the 1024px logos are 16-bit and don't.
|
||||
const logo = `data:image/png;base64,${readFileSync(
|
||||
join(process.cwd(), "public/apple-touch-icon.png"),
|
||||
).toString("base64")}`;
|
||||
|
||||
function titleFontSize(title: string): number {
|
||||
if (title.length <= 38) return 76;
|
||||
if (title.length <= 64) return 60;
|
||||
return 48;
|
||||
}
|
||||
|
||||
export default async function OgImage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{slug: string}>;
|
||||
}) {
|
||||
const {slug} = await params;
|
||||
const post = getPost(slug);
|
||||
const title = post?.title ?? "Voicebox Blog";
|
||||
const meta = post
|
||||
? `${post.author} · ${formatDate(post.date)}`
|
||||
: "Open source voice cloning. Local-first.";
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
padding: 80,
|
||||
background:
|
||||
"radial-gradient(ellipse 80% 70% at 30% 30%, hsla(43,60%,50%,0.14) 0%, hsla(43,60%,50%,0.04) 40%, transparent 70%), linear-gradient(180deg, hsl(30,4%,6%) 0%, hsl(30,4%,4%) 100%)",
|
||||
}}
|
||||
>
|
||||
{/* Top: logo + eyebrow */}
|
||||
<div style={{display: "flex", alignItems: "center", gap: 24}}>
|
||||
{/* biome-ignore lint/performance/noImgElement: Satori only renders <img> */}
|
||||
<img src={logo} width={88} height={88} alt="" />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 26,
|
||||
letterSpacing: 6,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
color: "hsl(43, 60%, 58%)",
|
||||
}}
|
||||
>
|
||||
Voicebox Blog
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: titleFontSize(title),
|
||||
lineHeight: 1.1,
|
||||
fontWeight: 700,
|
||||
letterSpacing: -1,
|
||||
color: "hsl(30, 10%, 94%)",
|
||||
maxWidth: 1000,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Footer meta */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 28,
|
||||
color: "hsl(30, 5%, 55%)",
|
||||
}}
|
||||
>
|
||||
{meta}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size,
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {notFound} from "next/navigation";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {formatDate, getPost, loadAllPosts} from "@/lib/blog";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return loadAllPosts().map((post) => ({slug: post.slug}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{slug: string}>;
|
||||
}): Promise<Metadata> {
|
||||
const {slug} = await params;
|
||||
const post = getPost(slug);
|
||||
if (!post) return {title: "Post not found — Voicebox"};
|
||||
return {
|
||||
title: `${post.title} — Voicebox`,
|
||||
description: post.excerpt,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
type: "article",
|
||||
url: `https://voicebox.sh/blog/${post.slug}`,
|
||||
// og:image / twitter:image come from the colocated opengraph-image.tsx
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{slug: string}>;
|
||||
}) {
|
||||
const {slug} = await params;
|
||||
const post = getPost(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
<main className="mx-auto w-full max-w-3xl px-6 pt-32 pb-20">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="font-mono text-sm text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
← Back to blog
|
||||
</Link>
|
||||
|
||||
<header className="mt-8 border-b border-border pb-10">
|
||||
{post.tags.length > 0 ? (
|
||||
<div className="mb-5 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-border/60 bg-card/40 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tighter text-foreground">
|
||||
{post.title}
|
||||
</h1>
|
||||
<p className="mt-5 font-mono text-sm text-muted-foreground">
|
||||
by <span className="text-foreground">{post.author}</span> ·{" "}
|
||||
{formatDate(post.date)} · {post.readingMinutes} min read
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<article
|
||||
className="blog-prose mt-10"
|
||||
// Content is authored markdown from this repo, not user input.
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: trusted local markdown
|
||||
dangerouslySetInnerHTML={{__html: post.html}}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {formatDate, listPosts} from "@/lib/blog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog — Voicebox",
|
||||
description: "Notes from building Voicebox — the open-source AI voice studio.",
|
||||
openGraph: {
|
||||
title: "Voicebox Blog",
|
||||
description: "Notes from building Voicebox — the open-source AI voice studio.",
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/blog",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
export default function BlogIndexPage() {
|
||||
const posts = listPosts();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
<main className="mx-auto w-full max-w-3xl px-6 pt-32 pb-20">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Blog
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tighter text-foreground">
|
||||
Notes from building Voicebox.
|
||||
</h1>
|
||||
<p className="mt-5 max-w-2xl text-lg text-muted-foreground">
|
||||
The story behind the project, what's shipping next, and the occasional
|
||||
look under the hood.
|
||||
</p>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<p className="mt-16 border-t border-border pt-10 text-muted-foreground">
|
||||
Nothing published yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-16 border-t border-border">
|
||||
{posts.map((post) => (
|
||||
<li key={post.slug}>
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="group grid gap-4 border-b border-border py-10 md:grid-cols-[11rem_1fr] md:gap-10"
|
||||
>
|
||||
<div className="font-mono text-sm text-muted-foreground md:pt-1.5">
|
||||
<p>{formatDate(post.date)}</p>
|
||||
<p className="mt-1">{post.readingMinutes} min read</p>
|
||||
</div>
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground transition-colors group-hover:text-accent">
|
||||
{post.title}
|
||||
</h2>
|
||||
{post.excerpt ? (
|
||||
<p className="mt-3 leading-7 text-muted-foreground">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
) : null}
|
||||
{post.tags.length > 0 ? (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-border/60 bg-card/40 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Github } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AgentIntegration } from '@/components/AgentIntegration';
|
||||
import { CaptureHero } from '@/components/CaptureHero';
|
||||
import { CapturesMockup } from '@/components/CapturesMockup';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Navbar } from '@/components/Navbar';
|
||||
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
|
||||
import { GITHUB_REPO } from '@/lib/constants';
|
||||
|
||||
export default function CapturePage() {
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [totalDownloads, setTotalDownloads] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/releases')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch releases');
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.version) setVersion(data.version);
|
||||
if (data.totalDownloads != null) setTotalDownloads(data.totalDownloads);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch release info:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* ── Hero ─────────────────────────────────────────────────── */}
|
||||
<CaptureHero version={version} totalDownloads={totalDownloads} />
|
||||
|
||||
{/* ── Captures mockup ─────────────────────────────────────── */}
|
||||
<section className="relative border-t border-border py-24">
|
||||
<div className="mx-auto max-w-5xl px-6 text-center mb-14">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
The Captures tab
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground mb-4">
|
||||
Every capture, paired with audio and transcript.
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Hold the shortcut, speak, release — a capture lands in the Captures tab. Replay the
|
||||
original audio, re-transcribe with a different model, refine with a local LLM, copy to
|
||||
clipboard, or send it straight to any MCP-aware agent. Nothing leaves your machine.
|
||||
</p>
|
||||
</div>
|
||||
<CapturesMockup />
|
||||
</section>
|
||||
|
||||
{/* ── Feature bullets ─────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
Whisper, sized for every machine
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Base, Small, Medium, Large, and Turbo. Pick per-capture — 99 languages at every
|
||||
tier, all local, all downloadable from inside the app.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
LLM refinement that respects your words
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
A local Qwen model cleans ums, self-corrections, and punctuation — without
|
||||
rephrasing. Keep raw and refined side-by-side; the original audio is always kept.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
Archived by default
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Every dictation keeps both the audio and the transcript. Search, re-run, or turn
|
||||
any capture into a voice sample for cloning from the Captures tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Agent voice output ──────────────────────────────────── */}
|
||||
<AgentIntegration />
|
||||
|
||||
{/* ── Bottom CTA ──────────────────────────────────────────── */}
|
||||
<section id="download" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Install Voicebox, start dictating.
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Free, open-source, local. No account, no API keys, no per-character fees.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl mx-auto">
|
||||
<a
|
||||
href="/download?platform=macArm"
|
||||
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
|
||||
>
|
||||
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">macOS</div>
|
||||
<div className="text-xs text-muted-foreground">Apple Silicon (ARM)</div>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="/download?platform=macIntel"
|
||||
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
|
||||
>
|
||||
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">macOS</div>
|
||||
<div className="text-xs text-muted-foreground">Intel (x64)</div>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="/download?platform=windows"
|
||||
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
|
||||
>
|
||||
<WindowsIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">Windows</div>
|
||||
<div className="text-xs text-muted-foreground">64-bit (MSI)</div>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="/linux-install"
|
||||
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
|
||||
>
|
||||
<LinuxIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">Linux</div>
|
||||
<div className="text-xs text-muted-foreground">Build from source</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<a
|
||||
href={`${GITHUB_REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
View all releases on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 text-center">
|
||||
<a
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground/70 hover:text-foreground transition-colors"
|
||||
>
|
||||
← See everything Voicebox can do
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import {ArrowRight, Cloud, KeyRound, Lock, ShieldCheck} from "lucide-react";
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {CLOUD_FEATURES, CLOUD_NOTIFY_URL} from "@/lib/pricing";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Cloud Backup & Sync — Voicebox",
|
||||
description:
|
||||
"End-to-end encrypted backup and sync for your Voicebox library. We can't read your data — only your devices can. Optional, local-first, free for $VOICEBOX holders.",
|
||||
openGraph: {
|
||||
title: "Voicebox Cloud — encrypted backup & sync",
|
||||
description:
|
||||
"End-to-end encrypted backup and sync across desktop and mobile. The server is blind — only your devices can decrypt.",
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/cloud",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
icon: Lock,
|
||||
title: "Encrypted on your device",
|
||||
body: "Profiles, generations, and captures are encrypted locally with keys only you hold — before anything is uploaded.",
|
||||
},
|
||||
{
|
||||
icon: Cloud,
|
||||
title: "Stored as opaque blobs",
|
||||
body: "The server keeps your encrypted objects and a sync feed. It can route and store them, but never decrypt them.",
|
||||
},
|
||||
{
|
||||
icon: KeyRound,
|
||||
title: "Only your devices decrypt",
|
||||
body: "Each device unwraps your master key on pairing. A recovery phrase you control lets you restore everything to a new one.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CloudPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* ── Hero ─────────────────────────────────────────────────── */}
|
||||
<section className="relative pt-32 pb-16">
|
||||
<div className="hero-glow hero-glow-fade pointer-events-none absolute inset-0 -top-32">
|
||||
<div className="absolute left-1/2 top-0 -translate-x-1/2 w-[900px] h-[500px] rounded-full bg-accent/12 blur-[140px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-4xl px-6 text-center">
|
||||
<div className="fade-in mb-6 inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 px-3 py-1">
|
||||
<Cloud className="h-3.5 w-3.5 text-accent" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Voicebox Cloud · coming soon
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="fade-in text-5xl font-bold tracking-tighter leading-[0.95] text-foreground md:text-6xl lg:text-7xl">
|
||||
Your studio, backed up and in sync.
|
||||
</h1>
|
||||
|
||||
<p className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
|
||||
Optional, end-to-end encrypted backup and sync for your entire
|
||||
Voicebox library. We can't read a byte of it — only your devices
|
||||
can. Free for{" "}
|
||||
<Link href="/token" className="text-foreground underline-offset-4 hover:underline">
|
||||
$VOICEBOX
|
||||
</Link>{" "}
|
||||
holders.
|
||||
</p>
|
||||
|
||||
<div className="fade-in mt-10 flex flex-row items-center justify-center gap-3 sm:gap-4">
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="rounded-full bg-accent px-8 py-3.5 text-sm font-semibold uppercase tracking-wider text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3),inset_0_2px_0_rgba(255,255,255,0.2),inset_0_-2px_0_rgba(0,0,0,0.1)] transition-all hover:bg-accent-faint"
|
||||
>
|
||||
See pricing
|
||||
</Link>
|
||||
<a
|
||||
href={CLOUD_NOTIFY_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-6 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-border"
|
||||
>
|
||||
Get notified
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Features ─────────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{CLOUD_FEATURES.map((f) => (
|
||||
<div
|
||||
key={f.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{f.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{f.body}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── How it works ─────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
How it works
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Zero-knowledge by design.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{STEPS.map((step, i) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<div
|
||||
key={step.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Icon className="h-5 w-5 text-accent" />
|
||||
<span className="font-mono text-xs text-muted-foreground/60">
|
||||
0{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{step.body}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Trust callout ────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="rounded-2xl border-2 border-accent/40 bg-card/60 backdrop-blur-sm p-8 md:p-10 text-center shadow-[0_8px_40px_hsl(43_60%_50%/0.08)]">
|
||||
<ShieldCheck className="h-7 w-7 text-accent mx-auto mb-4" />
|
||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3">
|
||||
We can't see your data. That's the point.
|
||||
</h2>
|
||||
<p className="text-muted-foreground leading-relaxed max-w-2xl mx-auto">
|
||||
Voicebox is local-first and privacy-first. The cloud keeps that
|
||||
promise: your library is encrypted before it leaves your device,
|
||||
the server stores only ciphertext, and the keys never leave your
|
||||
control. Same philosophy as the app — just backed up.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="rounded-full bg-accent px-6 py-3 text-sm font-semibold text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3)] transition-all hover:bg-accent-faint"
|
||||
>
|
||||
See pricing
|
||||
</Link>
|
||||
<Link
|
||||
href="/token"
|
||||
className="rounded-full border border-border/60 bg-card/40 px-6 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-border"
|
||||
>
|
||||
Free for holders →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Pretty URLs from README / docs (e.g. /download/mac-arm) are kept for
|
||||
// compatibility, but we now always route through the /download page so users
|
||||
// see context + a donate prompt + resources while the download kicks off.
|
||||
// The page handles the actual file trigger itself — no more silent redirects
|
||||
// to GitHub or direct asset URLs.
|
||||
const PLATFORM_ALIAS: Record<string, string> = {
|
||||
'mac-arm': 'macArm',
|
||||
macArm: 'macArm',
|
||||
'mac-intel': 'macIntel',
|
||||
macIntel: 'macIntel',
|
||||
windows: 'windows',
|
||||
};
|
||||
|
||||
function getPublicOrigin(request: NextRequest): string {
|
||||
const forwardedHost = request.headers.get('x-forwarded-host');
|
||||
const forwardedProto = request.headers.get('x-forwarded-proto');
|
||||
|
||||
if (forwardedHost && forwardedProto) {
|
||||
// Behind reverse proxies/CDNs, request.url can be an internal origin
|
||||
// (for example localhost:8080). Prefer forwarded headers so redirects
|
||||
// keep users on the public domain.
|
||||
return `${forwardedProto}://${forwardedHost}`;
|
||||
}
|
||||
|
||||
return new URL(request.url).origin;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ platform: string }> },
|
||||
) {
|
||||
const origin = getPublicOrigin(request);
|
||||
const { platform } = await params;
|
||||
// No prebuilt Linux binary yet — send straight to the build-from-source page.
|
||||
if (platform === 'linux') {
|
||||
return NextResponse.redirect(new URL('/linux-install', origin), 307);
|
||||
}
|
||||
const normalized = PLATFORM_ALIAS[platform];
|
||||
const target = new URL('/download', origin);
|
||||
if (normalized) target.searchParams.set('platform', normalized);
|
||||
return NextResponse.redirect(target, 307);
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
Coffee,
|
||||
Download as DownloadIcon,
|
||||
FileText,
|
||||
Github,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DONATE_URL, GITHUB_RELEASES_PAGE, GITHUB_REPO } from '@/lib/constants';
|
||||
import type { DownloadLinks } from '@/lib/releases';
|
||||
|
||||
type Platform = keyof DownloadLinks;
|
||||
|
||||
type PlatformMeta = {
|
||||
key: Platform;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
};
|
||||
|
||||
const PLATFORMS: PlatformMeta[] = [
|
||||
{ key: 'macArm', label: 'macOS', description: 'Apple Silicon', icon: AppleIcon },
|
||||
{ key: 'macIntel', label: 'macOS', description: 'Intel (x64)', icon: AppleIcon },
|
||||
{ key: 'windows', label: 'Windows', description: '64-bit (MSI)', icon: WindowsIcon },
|
||||
{ key: 'linux', label: 'Linux', description: 'Build from source', icon: LinuxIcon },
|
||||
];
|
||||
|
||||
function detectPlatform(): Platform | null {
|
||||
if (typeof navigator === 'undefined') return null;
|
||||
const ua = navigator.userAgent;
|
||||
if (/Windows/i.test(ua)) return 'windows';
|
||||
if (/Linux/i.test(ua) && !/Android/i.test(ua)) return 'linux';
|
||||
if (/Mac/i.test(ua)) {
|
||||
// Apple Silicon Safari reports "Intel" for compat; default to ARM since
|
||||
// M-series is the majority. Users can click the Intel button if needed.
|
||||
return 'macArm';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseQueryPlatform(search: string): Platform | null {
|
||||
const params = new URLSearchParams(search);
|
||||
const raw = params.get('platform');
|
||||
if (!raw) return null;
|
||||
// Accept both camelCase and hyphenated forms (/download/mac-arm → ?platform=mac-arm).
|
||||
const normalized = raw
|
||||
.toLowerCase()
|
||||
.replace(/[-_\s]/g, '')
|
||||
.replace('macarm', 'macArm')
|
||||
.replace('macintel', 'macIntel');
|
||||
const valid: Platform[] = ['macArm', 'macIntel', 'windows', 'linux'];
|
||||
return (valid as string[]).includes(normalized) ? (normalized as Platform) : null;
|
||||
}
|
||||
|
||||
export default function DownloadPage() {
|
||||
const [links, setLinks] = useState<DownloadLinks | null>(null);
|
||||
const [linksError, setLinksError] = useState(false);
|
||||
const [platform, setPlatform] = useState<Platform | null>(null);
|
||||
const [triggered, setTriggered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fromQuery = parseQueryPlatform(window.location.search);
|
||||
const resolved = fromQuery ?? detectPlatform();
|
||||
// No prebuilt Linux binary yet — send Linux users to the build-from-source
|
||||
// instructions instead of sitting on /download trying to trigger a
|
||||
// download that doesn't exist.
|
||||
if (resolved === 'linux') {
|
||||
window.location.replace('/linux-install');
|
||||
return;
|
||||
}
|
||||
setPlatform(resolved);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/api/releases')
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`releases ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
if (data.downloadLinks) setLinks(data.downloadLinks as DownloadLinks);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLinksError(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (triggered || !links || !platform) return;
|
||||
const url = links[platform];
|
||||
if (!url) return;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.rel = 'noopener';
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTriggered(true);
|
||||
}, [triggered, links, platform]);
|
||||
|
||||
const activeMeta = useMemo(
|
||||
() => PLATFORMS.find((p) => p.key === platform) ?? null,
|
||||
[platform],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Minimal branded header */}
|
||||
<header className="border-b border-border/50">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||
<Link href="/" className="flex items-center gap-2.5">
|
||||
<Image
|
||||
src="/voicebox-logo-app.webp"
|
||||
alt="Voicebox"
|
||||
width={28}
|
||||
height={28}
|
||||
className="h-7 w-7"
|
||||
/>
|
||||
<span className="text-[15px] font-semibold text-foreground">Voicebox</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to voicebox.sh
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-6 py-16 md:py-24">
|
||||
{/* Hero */}
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-10 md:gap-14">
|
||||
<Image
|
||||
src="/voicebox-logo-app.webp"
|
||||
alt="Voicebox"
|
||||
width={200}
|
||||
height={200}
|
||||
priority
|
||||
className="h-32 w-32 md:h-44 md:w-44 shrink-0 drop-shadow-2xl"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 text-center md:text-left">
|
||||
{triggered ? (
|
||||
<>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold tracking-tight text-foreground mb-4">
|
||||
Your download has started.
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
{activeMeta
|
||||
? `Downloading Voicebox for ${activeMeta.label} (${activeMeta.description}). Check your downloads folder.`
|
||||
: 'Check your downloads folder for Voicebox.'}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold tracking-tight text-foreground mb-4">
|
||||
{linksError ? "We couldn't load the latest release." : 'Download Voicebox'}
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
{linksError
|
||||
? 'Our release server is temporarily unreachable. Please try again in a moment.'
|
||||
: 'Pick your platform to get started.'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform buttons — always visible as a fallback */}
|
||||
{linksError ? (
|
||||
<div className="mt-12 rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
If this keeps happening, you can{' '}
|
||||
<a
|
||||
href={`${GITHUB_RELEASES_PAGE}/latest`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent underline underline-offset-2 hover:text-accent/80"
|
||||
>
|
||||
browse releases on GitHub
|
||||
</a>
|
||||
{' '}and grab the build for your platform manually.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-12 rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6">
|
||||
<h2 className="text-sm font-medium text-foreground mb-4">
|
||||
{triggered ? 'Download not working?' : 'Choose your platform'}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{PLATFORMS.map((meta) => {
|
||||
const isLinux = meta.key === 'linux';
|
||||
const url = isLinux ? '/linux-install' : links?.[meta.key];
|
||||
const isActive = meta.key === platform;
|
||||
const disabled = !isLinux && !url;
|
||||
return (
|
||||
<a
|
||||
key={meta.key}
|
||||
href={url ?? '#'}
|
||||
{...(isLinux ? {} : { download: true })}
|
||||
aria-disabled={disabled}
|
||||
onClick={(e) => {
|
||||
if (disabled) e.preventDefault();
|
||||
}}
|
||||
className={`flex items-center rounded-xl border px-5 py-4 transition-all group ${
|
||||
isActive
|
||||
? 'border-accent/40 bg-accent/5 hover:border-accent/60'
|
||||
: 'border-border bg-card/40 hover:border-accent/30 hover:bg-card'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<meta.icon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">{meta.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{meta.description}</div>
|
||||
</div>
|
||||
<DownloadIcon className="h-4 w-4 text-muted-foreground/60 group-hover:text-accent transition-colors" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Donate — prominent, heartfelt, post-click context */}
|
||||
<div className="mt-16 rounded-2xl border border-border bg-gradient-to-br from-card via-card/80 to-background backdrop-blur-sm p-8 md:p-10 overflow-hidden relative">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-[#FFDD00]/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
<div className="relative">
|
||||
<div className="inline-flex items-center gap-2 mb-4">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-[#FFDD00]">
|
||||
Hi from the maintainer
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-4">
|
||||
Jamie here — Voicebox is a side project.
|
||||
</h2>
|
||||
<p className="text-muted-foreground leading-relaxed mb-6 max-w-2xl">
|
||||
I build and maintain Voicebox in my spare time. It's completely
|
||||
free, open source, runs entirely on your machine — no accounts, no
|
||||
cloud, no subscriptions, no upsells. If it saves you an ElevenLabs
|
||||
bill or just made your day, a coffee genuinely helps me keep
|
||||
shipping updates, adding new models, and fixing bugs. Every little
|
||||
bit keeps the lights on.
|
||||
</p>
|
||||
<Button asChild size="lg" className="bg-[#FFDD00]/10 border-[#FFDD00]/30 text-[#FFDD00] hover:bg-[#FFDD00]/20 hover:border-[#FFDD00]/50">
|
||||
<a href={DONATE_URL} target="_blank" rel="noopener noreferrer">
|
||||
<Coffee className="h-4 w-4 mr-2" />
|
||||
Buy me a coffee
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<div className="mt-10">
|
||||
<h2 className="text-sm font-medium text-foreground mb-4">While you wait</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<a
|
||||
href="https://docs.voicebox.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 hover:border-accent/30 hover:bg-card transition-all group"
|
||||
>
|
||||
<FileText className="h-5 w-5 text-accent mb-3" />
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">Read the docs</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Get familiar with Voicebox — setup, voice cloning, the REST API.
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
href="https://deepwiki.com/jamiepine/voicebox"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 hover:border-accent/30 hover:bg-card transition-all group"
|
||||
>
|
||||
<Bot className="h-5 w-5 text-accent mb-3" />
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">Got questions? Ask AI.</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
DeepWiki is an AI that knows Voicebox inside-out. Ask anything.
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_REPO}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 hover:border-accent/30 hover:bg-card transition-all group"
|
||||
>
|
||||
<Github className="h-5 w-5 text-accent mb-3" />
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">Source on GitHub</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Star the repo, file issues, or contribute a PR.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 0%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 0%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 0%;
|
||||
--primary: 0 0% 0%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 0 0% 96%;
|
||||
--secondary-foreground: 0 0% 0%;
|
||||
--muted: 0 0% 96%;
|
||||
--muted-foreground: 0 0% 45%;
|
||||
--accent: 43 50% 50%;
|
||||
--accent-foreground: 0 0% 0%;
|
||||
--destructive: 0 0% 0%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 0 0% 90%;
|
||||
--input: 0 0% 90%;
|
||||
--ring: 0 0% 0%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Surfaces -- slightly warm-tinted darks */
|
||||
--background: 30 4% 4%;
|
||||
--foreground: 30 10% 94%;
|
||||
--card: 30 4% 7%;
|
||||
--card-foreground: 30 10% 94%;
|
||||
--popover: 30 4% 7%;
|
||||
--popover-foreground: 30 10% 94%;
|
||||
--primary: 30 10% 94%;
|
||||
--primary-foreground: 30 4% 7%;
|
||||
--secondary: 30 4% 10%;
|
||||
--secondary-foreground: 30 10% 94%;
|
||||
--muted: 30 3% 12%;
|
||||
--muted-foreground: 30 5% 55%;
|
||||
--accent: 43 50% 45%;
|
||||
--accent-foreground: 30 10% 94%;
|
||||
--destructive: 0 62% 50%;
|
||||
--destructive-foreground: 30 10% 94%;
|
||||
--border: 30 4% 13%;
|
||||
--input: 30 4% 13%;
|
||||
--ring: 30 10% 94% / 0.2;
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* App-specific surface tokens */
|
||||
--app: 30 4% 4%;
|
||||
--app-box: 30 4% 7%;
|
||||
--app-dark-box: 30 4% 5%;
|
||||
--app-darker-box: 30 4% 3%;
|
||||
--app-light-box: 30 4% 14%;
|
||||
--app-line: 30 4% 13%;
|
||||
--app-button: 30 4% 11%;
|
||||
--app-hover: 30 4% 15%;
|
||||
--app-selected: 30 4% 17%;
|
||||
|
||||
/* Text hierarchy */
|
||||
--ink: 30 10% 94%;
|
||||
--ink-dull: 30 5% 55%;
|
||||
--ink-faint: 30 3% 38%;
|
||||
|
||||
/* Accent shades */
|
||||
--accent-faint: 43 45% 55%;
|
||||
--accent-deep: 43 55% 35%;
|
||||
--accent-glow: 43 60% 50%;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: 30 4% 3%;
|
||||
--sidebar-line: 30 4% 10%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
overflow-x: hidden;
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
/* Staggered fade-in animation for hero elements */
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
opacity: 0;
|
||||
animation: fadeUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.hero-glow-fade {
|
||||
opacity: 0;
|
||||
animation: fadeIn 2s ease-out 0.3s forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Noise texture overlay for hero glow */
|
||||
/* .hero-glow::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2048' height='2048'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.5' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
|
||||
opacity: 0.35;
|
||||
mix-blend-mode: overlay;
|
||||
will-change: transform;
|
||||
} */
|
||||
|
||||
/* Blog post typography (rendered markdown via marked) */
|
||||
.blog-prose {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.blog-prose > * + * {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.blog-prose h2 {
|
||||
margin-top: 2.25em;
|
||||
margin-bottom: 0.75em;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.blog-prose h3 {
|
||||
margin-top: 1.75em;
|
||||
margin-bottom: 0.5em;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.blog-prose p,
|
||||
.blog-prose ul,
|
||||
.blog-prose ol,
|
||||
.blog-prose blockquote {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.blog-prose strong {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 600;
|
||||
}
|
||||
.blog-prose a {
|
||||
color: hsl(var(--foreground));
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: hsl(var(--accent) / 0.5);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.blog-prose a:hover {
|
||||
color: hsl(var(--accent));
|
||||
}
|
||||
.blog-prose ul,
|
||||
.blog-prose ol {
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
.blog-prose ul {
|
||||
list-style: disc;
|
||||
}
|
||||
.blog-prose ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.blog-prose li + li {
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
.blog-prose blockquote {
|
||||
border-left: 2px solid hsl(var(--accent) / 0.5);
|
||||
padding-left: 1.25em;
|
||||
font-style: italic;
|
||||
}
|
||||
.blog-prose code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.875em;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.blog-prose pre {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.1em 1.25em;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.blog-prose pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.blog-prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
margin: 2.5em 0;
|
||||
}
|
||||
.blog-prose img {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Scrollbar hiding */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL('https://voicebox.sh'),
|
||||
title: 'Voicebox - Open Source Voice Cloning Desktop App',
|
||||
description:
|
||||
'Near-perfect voice cloning with multiple TTS engines. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
|
||||
keywords: [
|
||||
'voice cloning',
|
||||
'TTS',
|
||||
'multi-engine',
|
||||
'desktop app',
|
||||
'AI voice',
|
||||
'open source',
|
||||
'text to speech',
|
||||
],
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.png', type: 'image/png' },
|
||||
{ url: '/favicon.ico', sizes: 'any' },
|
||||
],
|
||||
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Voicebox',
|
||||
description: 'Open source voice cloning. Local-first. Free forever.',
|
||||
type: 'website',
|
||||
url: 'https://voicebox.sh',
|
||||
images: [{ url: '/og.webp', width: 1200, height: 630 }],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Voicebox',
|
||||
description: 'Open source voice cloning. Local-first. Free forever.',
|
||||
images: ['/og.webp'],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning className="dark">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Caveat:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div className="relative min-h-screen bg-background font-sans">{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Navbar } from '@/components/Navbar';
|
||||
import { GITHUB_REPO } from '@/lib/constants';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Linux Install - Voicebox',
|
||||
description: 'Build Voicebox from source on Linux. Clone, setup, and build in three commands.',
|
||||
};
|
||||
|
||||
export default function LinuxInstall() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
<section className="relative pt-32 pb-24">
|
||||
<div className="mx-auto max-w-2xl px-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Install on Linux</h1>
|
||||
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
We're currently working through CI issues that prevent us from shipping a reliable
|
||||
pre-built binary for Linux. In the meantime, building from source is straightforward and
|
||||
takes just a few minutes.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 space-y-6">
|
||||
{/* Prerequisites */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">
|
||||
Prerequisites
|
||||
</h2>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://git-scm.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Git
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.rust-lang.org/tools/install"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Rust
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/casey/just#installation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
just
|
||||
</a>{' '}
|
||||
— install via{' '}
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">cargo install just</code>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://bun.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Bun
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
Tauri system deps —{' '}
|
||||
<a
|
||||
href="https://v2.tauri.app/start/prerequisites/#linux"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
see Tauri docs
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">
|
||||
Build from source
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-border bg-card/60 p-4 font-mono text-sm">
|
||||
<div className="text-muted-foreground select-none"># Clone the repo</div>
|
||||
<div>git clone https://github.com/jamiepine/voicebox.git</div>
|
||||
<div>cd voicebox</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card/60 p-4 font-mono text-sm">
|
||||
<div className="text-muted-foreground select-none">
|
||||
# Install all dependencies (Python venv, JS deps, etc.)
|
||||
</div>
|
||||
<div>just setup</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card/60 p-4 font-mono text-sm">
|
||||
<div className="text-muted-foreground select-none"># Build the app</div>
|
||||
<div>just build</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
The built app will be in{' '}
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
tauri/src-tauri/target/release/bundle/
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dev mode */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">
|
||||
Or run in dev mode
|
||||
</h2>
|
||||
<div className="rounded-lg border border-border bg-card/60 p-4 font-mono text-sm">
|
||||
<div className="text-muted-foreground select-none">
|
||||
# Start the dev server with hot reload
|
||||
</div>
|
||||
<div>just dev</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="mt-12 pt-8 border-t border-border flex flex-wrap gap-4 text-sm">
|
||||
<a
|
||||
href={GITHUB_REPO}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
GitHub Repo
|
||||
</a>
|
||||
<a
|
||||
href={`${GITHUB_REPO}/issues`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Report an issue
|
||||
</a>
|
||||
<a
|
||||
href={`${GITHUB_REPO}/blob/main/CONTRIBUTING.md`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Contributing guide
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
'use client';
|
||||
|
||||
export default function OgPreview() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#0a0a09] p-10">
|
||||
{/* The card — standard OG image dimensions */}
|
||||
<div
|
||||
id="og"
|
||||
className="relative flex items-center overflow-hidden"
|
||||
style={{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
background:
|
||||
'radial-gradient(ellipse 80% 70% at 50% 45%, hsla(43,60%,50%,0.12) 0%, hsla(43,60%,50%,0.04) 40%, transparent 70%), linear-gradient(180deg, hsl(30,4%,6%) 0%, hsl(30,4%,4%) 100%)',
|
||||
}}
|
||||
>
|
||||
{/* Logo + text — left-justified, horizontal */}
|
||||
<div className="relative z-10 flex items-center" style={{ paddingLeft: 20 }}>
|
||||
{/* Glow behind logo */}
|
||||
<div
|
||||
className="pointer-events-none absolute rounded-full blur-[100px]"
|
||||
style={{
|
||||
width: 300,
|
||||
height: 300,
|
||||
top: '50%',
|
||||
left: 0,
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'hsla(43, 60%, 50%, 0.15)',
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src="/voicebox-logo-app.webp"
|
||||
alt=""
|
||||
className="relative shrink-0 object-contain"
|
||||
style={{ width: 260, height: 260 }}
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="flex flex-col" style={{ marginLeft: -8 }}>
|
||||
<h1
|
||||
className="font-bold tracking-tight"
|
||||
style={{
|
||||
fontSize: 72,
|
||||
lineHeight: 1,
|
||||
color: 'hsl(30, 10%, 94%)',
|
||||
}}
|
||||
>
|
||||
Voicebox
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 24,
|
||||
lineHeight: 1.4,
|
||||
marginTop: 16,
|
||||
color: 'hsl(30, 5%, 55%)',
|
||||
}}
|
||||
>
|
||||
Open source voice cloning.
|
||||
<br />
|
||||
Local-first. Free forever.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App screenshot — right, overflowing */}
|
||||
<img
|
||||
src="/assets/app-screenshot-1.webp"
|
||||
alt=""
|
||||
className="pointer-events-none absolute top-1/2 -translate-y-1/2 z-10"
|
||||
style={{
|
||||
right: -300,
|
||||
width: 900,
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Border overlay */}
|
||||
<div className="pointer-events-none absolute inset-0 ring-1 ring-inset ring-white/[0.06]" />
|
||||
</div>
|
||||
|
||||
{/* Helper text */}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 text-xs text-white/30">
|
||||
1200 × 630 — Right-click the card or screenshot at 1:1 zoom
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {Github} from "lucide-react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {AgentIntegration} from "@/components/AgentIntegration";
|
||||
import {ApiSection} from "@/components/ApiSection";
|
||||
import {CaptureSection} from "@/components/CaptureSection";
|
||||
import {ControlUI} from "@/components/ControlUI";
|
||||
import {Features} from "@/components/Features";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {Personalities} from "@/components/Personalities";
|
||||
import {AppleIcon, LinuxIcon, WindowsIcon} from "@/components/PlatformIcons";
|
||||
import {SupportedModels} from "@/components/SupportedModels";
|
||||
import {Testimonials} from "@/components/Testimonials";
|
||||
import {TokenTeaser} from "@/components/TokenTeaser";
|
||||
import {TutorialsSection} from "@/components/TutorialsSection";
|
||||
import {VoiceCreator} from "@/components/VoiceCreator";
|
||||
import {GITHUB_REPO} from "@/lib/constants";
|
||||
|
||||
export default function Home() {
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [totalDownloads, setTotalDownloads] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/releases")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed to fetch releases");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.version) setVersion(data.version);
|
||||
if (data.totalDownloads != null) setTotalDownloads(data.totalDownloads);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to fetch release info:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* ── Hero Section ─────────────────────────────────────────────── */}
|
||||
<section className="relative pt-32 pb-16">
|
||||
{/* Background glow */}
|
||||
<div className="hero-glow hero-glow-fade pointer-events-none absolute inset-0 -top-32">
|
||||
<div className="absolute left-1/2 top-0 -translate-x-1/2 w-[800px] h-[600px] rounded-full bg-accent/15 blur-[150px]" />
|
||||
<div className="absolute left-1/2 top-12 -translate-x-1/2 w-[500px] h-[400px] rounded-full bg-accent/10 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-7xl px-6 text-center">
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="fade-in mx-auto mb-8 h-[120px] w-[120px] md:h-[160px] md:w-[160px]"
|
||||
style={{animationDelay: "0ms"}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/voicebox-logo-app.webp"
|
||||
alt="Voicebox"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Kicker */}
|
||||
<div
|
||||
className="fade-in mb-6 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent"
|
||||
style={{animationDelay: "50ms"}}
|
||||
>
|
||||
The open-source AI voice studio
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<div className="fade-in relative" style={{animationDelay: "100ms"}}>
|
||||
<h1 className="text-5xl font-bold tracking-tighter leading-[0.9] text-foreground md:text-7xl lg:text-8xl">
|
||||
Clone, dictate and create.
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p
|
||||
className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"
|
||||
style={{animationDelay: "200ms"}}
|
||||
>
|
||||
Clone voices, generate speech across seven TTS engines, dictate into
|
||||
any app, and talk to agents in voices you own. A free and local alternative
|
||||
to ElevenLabs and WisprFlow, running{" "}
|
||||
<b className="text-white">entirely on your machine.</b>
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div
|
||||
className="fade-in mt-10 flex flex-row items-center justify-center gap-3 sm:gap-4"
|
||||
style={{animationDelay: "300ms"}}
|
||||
>
|
||||
<a
|
||||
href="/download"
|
||||
className="rounded-full bg-accent px-8 py-3.5 text-sm font-semibold uppercase tracking-wider text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3),inset_0_2px_0_rgba(255,255,255,0.2),inset_0_-2px_0_rgba(0,0,0,0.1)] transition-all hover:bg-accent-faint active:shadow-[0_2px_10px_hsl(43_60%_50%/0.3),inset_0_4px_8px_rgba(0,0,0,0.3)]"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_REPO}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-6 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-border"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Version + downloads */}
|
||||
<p
|
||||
className="fade-in mt-4 text-xs text-muted-foreground/50"
|
||||
style={{animationDelay: "400ms"}}
|
||||
>
|
||||
{version ?? ""}
|
||||
{version && totalDownloads != null ? " \u00b7 " : ""}
|
||||
{totalDownloads != null
|
||||
? `${totalDownloads.toLocaleString()} downloads`
|
||||
: ""}
|
||||
{version || totalDownloads != null ? " \u00b7 " : ""}
|
||||
macOS, Windows, Linux
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── ControlUI mockup ─────────────────────────────────────── */}
|
||||
<div className="mt-16">
|
||||
<ControlUI />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Features ─────────────────────────────────────────────── */}
|
||||
<Features />
|
||||
|
||||
{/* ── Voice Creator ────────────────────────────────────────── */}
|
||||
<VoiceCreator />
|
||||
|
||||
{/* ── Capture (dictation + STT + play as voice) ───────────── */}
|
||||
<CaptureSection />
|
||||
|
||||
{/* ── Agent integration (speak primitive + MCP) ───────────── */}
|
||||
<AgentIntegration />
|
||||
|
||||
{/* ── Personalities (Compose / Rewrite / Respond) ──────────── */}
|
||||
<Personalities />
|
||||
|
||||
{/* ── API Section ──────────────────────────────────────────── */}
|
||||
<ApiSection />
|
||||
|
||||
{/* ── Tutorials ────────────────────────────────────────────── */}
|
||||
<TutorialsSection />
|
||||
|
||||
{/* ── Supported models ─────────────────────────────────────── */}
|
||||
<SupportedModels />
|
||||
|
||||
{/* ── Testimonials ─────────────────────────────────────────── */}
|
||||
<Testimonials />
|
||||
|
||||
{/* ── Download Section ─────────────────────────────────────── */}
|
||||
<section id="download" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Download Voicebox
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Available for macOS, Windows, and Linux. No dependencies required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl mx-auto">
|
||||
{/* macOS ARM */}
|
||||
<a
|
||||
href="/download?platform=macArm"
|
||||
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
|
||||
>
|
||||
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">macOS</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Apple Silicon (ARM)
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* macOS Intel */}
|
||||
<a
|
||||
href="/download?platform=macIntel"
|
||||
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
|
||||
>
|
||||
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">macOS</div>
|
||||
<div className="text-xs text-muted-foreground">Intel (x64)</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Windows */}
|
||||
<a
|
||||
href="/download?platform=windows"
|
||||
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
|
||||
>
|
||||
<WindowsIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">Windows</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
64-bit (MSI)
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Linux */}
|
||||
<a
|
||||
href="/linux-install"
|
||||
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
|
||||
>
|
||||
<LinuxIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">Linux</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Build from source
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* GitHub link */}
|
||||
<div className="mt-6 text-center">
|
||||
<a
|
||||
href={`${GITHUB_REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
View all releases on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── $VOICEBOX token (teaser → /token) ─────────────────────── */}
|
||||
<TokenTeaser />
|
||||
|
||||
{/* ── Footer ───────────────────────────────────────────────── */}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import {Coins} from "lucide-react";
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {PricingTiers} from "@/components/PricingTiers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing — Voicebox",
|
||||
description:
|
||||
"Voicebox is free and open source forever. Optional, end-to-end encrypted cloud backup & sync — free for $VOICEBOX holders.",
|
||||
openGraph: {
|
||||
title: "Voicebox Pricing",
|
||||
description:
|
||||
"The app is free forever. Cloud backup & sync is an optional add-on — free for $VOICEBOX holders.",
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/pricing",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
const FAQ = [
|
||||
{
|
||||
q: "Is the app really free?",
|
||||
a: "Yes — Voicebox is free and open source, forever. Cloning, dictation, every TTS engine, MCP, personalities: all of it runs locally with no account. The paid plans only add optional cloud backup & sync.",
|
||||
},
|
||||
{
|
||||
q: "What's encrypted in the cloud?",
|
||||
a: "Everything. Your profiles, generations, and captures are end-to-end encrypted on your device before upload. The server stores only ciphertext and can never read your data.",
|
||||
},
|
||||
{
|
||||
q: "Do $VOICEBOX holders really get Cloud free?",
|
||||
a: "Yes. Holding the token unlocks the Cloud tier at no cost. The app itself is free regardless — the token is an optional way to support the project.",
|
||||
},
|
||||
{
|
||||
q: "What counts toward storage?",
|
||||
a: "Your encrypted objects — generated audio, the original audio kept with each capture, and profile data. Plans differ mainly on storage, device count, and version-history length.",
|
||||
},
|
||||
{
|
||||
q: "Can I cancel anytime?",
|
||||
a: "Yes. Cloud is a subscription you can cancel whenever you like; your local library always stays on your machine and keeps working.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* ── Hero ─────────────────────────────────────────────────── */}
|
||||
<section className="relative pt-32 pb-12">
|
||||
<div className="hero-glow hero-glow-fade pointer-events-none absolute inset-0 -top-32">
|
||||
<div className="absolute left-1/2 top-0 -translate-x-1/2 w-[900px] h-[460px] rounded-full bg-accent/12 blur-[140px]" />
|
||||
</div>
|
||||
<div className="relative mx-auto max-w-4xl px-6 text-center">
|
||||
<div className="fade-in mb-4 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent">
|
||||
Pricing
|
||||
</div>
|
||||
<h1 className="fade-in text-5xl font-bold tracking-tighter text-foreground md:text-6xl">
|
||||
The app is free. Forever.
|
||||
</h1>
|
||||
<p className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">
|
||||
Everything that makes Voicebox great runs locally at no cost. Pay
|
||||
only if you want optional, encrypted cloud backup & sync — and
|
||||
holders get that free.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Tiers (with monthly/annual toggle) ───────────────────── */}
|
||||
<section className="pb-8">
|
||||
<PricingTiers />
|
||||
</section>
|
||||
|
||||
{/* ── Holder callout ───────────────────────────────────────── */}
|
||||
<section className="py-12">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<Link
|
||||
href="/token"
|
||||
className="group flex flex-col items-center gap-3 rounded-2xl border border-accent/30 bg-card/40 backdrop-blur-sm px-6 py-8 text-center transition-colors hover:border-accent/50"
|
||||
>
|
||||
<Coins className="h-6 w-6 text-accent" />
|
||||
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground">
|
||||
Hold $VOICEBOX, get Cloud free.
|
||||
</h2>
|
||||
<p className="max-w-xl text-sm text-muted-foreground">
|
||||
The token is an optional way to back the project — and holders get
|
||||
the Cloud tier at no cost. Learn how it works and verify everything
|
||||
on-chain.
|
||||
</p>
|
||||
<span className="mt-1 text-sm font-medium text-accent group-hover:underline underline-offset-4">
|
||||
View the token →
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FAQ ──────────────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Questions
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{FAQ.map((item) => (
|
||||
<div
|
||||
key={item.q}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{item.q}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{item.a}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground/70 mt-10 max-w-2xl mx-auto">
|
||||
Cloud pricing and limits are not final — they'll be confirmed at
|
||||
launch.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
Cloud,
|
||||
Flame,
|
||||
Heart,
|
||||
Lock,
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type {Metadata} from "next";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {TokenSection} from "@/components/TokenSection";
|
||||
import {TokenStatsSection} from "@/components/TokenStats";
|
||||
import {
|
||||
TOKEN_PROOFS,
|
||||
TOKEN_SOLSCAN_URL,
|
||||
TOKEN_TICKER,
|
||||
} from "@/lib/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `${TOKEN_TICKER} — The official Voicebox token`,
|
||||
description: `${TOKEN_TICKER} is the official community token for Voicebox on Solana. Entirely optional — Voicebox is, and always will be, free and open source.`,
|
||||
openGraph: {
|
||||
title: `${TOKEN_TICKER} on Solana`,
|
||||
description: `The official community token for Voicebox. Optional, just for fun — Voicebox stays free and open source.`,
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/token",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
// Re-fetch live on-chain stats at most every 10 minutes (matches the server
|
||||
// cache in token-stats.ts). Keeps the page static-fast while staying fresh.
|
||||
export const revalidate = 600;
|
||||
|
||||
const USE_OF_FUNDS = [
|
||||
{
|
||||
icon: Rocket,
|
||||
title: "Full-time development",
|
||||
body: "The token is the equivalent of a salary — it lets me work on Voicebox every day instead of squeezing it around other work.",
|
||||
},
|
||||
{
|
||||
icon: Cloud,
|
||||
title: "Mobile + cloud backup & sync",
|
||||
body: "Shipping the mobile app and encrypted cloud backup/sync so your generations and captures are safe and available anywhere.",
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
title: "More engines, more hardware",
|
||||
body: "Adding TTS engines and broadening GPU / OS support so Voicebox runs great on whatever you've got.",
|
||||
},
|
||||
];
|
||||
|
||||
const FAQ = [
|
||||
{
|
||||
q: "Do I need the token to use Voicebox?",
|
||||
a: "No. Voicebox is free and open source, and every feature works without ever touching the token. It exists purely for supporters who want to back the project and have some fun.",
|
||||
},
|
||||
{
|
||||
q: "Is this an investment?",
|
||||
a: "No. $VOICEBOX is a community token, not a security or a promise of returns. There is no roadmap of financial milestones, and nothing here is financial advice. Only spend what you're comfortable with.",
|
||||
},
|
||||
{
|
||||
q: "How do I buy it?",
|
||||
a: "Copy the contract address above, then buy on pump.fun with a Solana wallet. Always verify the address matches the one on this page — impersonators are common.",
|
||||
},
|
||||
{
|
||||
q: "Does buying it fund development?",
|
||||
a: "Yes — going full-time on Voicebox is funded by the token, alongside donations. The surest way to support the project either way is to use it, star the repo, and tell people about it.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function TokenPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* Top padding clears the fixed navbar; TokenSection carries the
|
||||
header, contract address, and buy CTA. */}
|
||||
<main className="pt-16">
|
||||
<TokenSection />
|
||||
|
||||
{/* ── Live on-chain stats ──────────────────────────────────── */}
|
||||
<TokenStatsSection />
|
||||
|
||||
{/* ── Why a token ──────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Why a token
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
So I can build this full-time.
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto mt-4">
|
||||
Voicebox grew to over a million downloads with zero marketing —
|
||||
but donations alone never made full-time work sustainable.{" "}
|
||||
{TOKEN_TICKER} changed that overnight, and it's already
|
||||
accelerating everything below. The app stays{" "}
|
||||
<b className="text-foreground">free, open source, and local-first</b>{" "}
|
||||
— forever.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{USE_OF_FUNDS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<Icon className="h-5 w-5 text-accent mb-3" />
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{item.body}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Holder utility ───────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="rounded-2xl border-2 border-accent/40 bg-card/60 backdrop-blur-sm p-8 md:p-10 shadow-[0_8px_40px_hsl(43_60%_50%/0.08)]">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Cloud className="h-5 w-5 text-accent" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent">
|
||||
Holder perk · coming soon
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3">
|
||||
Cloud backup & sync — free for holders.
|
||||
</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Encrypted cloud backup and sync (and the mobile cloud) will be a
|
||||
paid service — roughly{" "}
|
||||
<b className="text-foreground">$12/year</b> for everyone else, and{" "}
|
||||
<b className="text-foreground">free for {TOKEN_TICKER} holders</b>.
|
||||
Generate on the go, keep your captures and generations safe, and
|
||||
pick up on any device.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Official vs community ────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="rounded-2xl border border-border bg-card/40 backdrop-blur-sm p-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ShieldCheck className="h-5 w-5 text-accent" />
|
||||
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground">
|
||||
One official token. Accept no substitutes.
|
||||
</h2>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
<ProofRow text={`${TOKEN_TICKER} is the only official Voicebox token. The mint address on this page is the single source of truth — always verify it.`} />
|
||||
<ProofRow text="My other projects (including Spacedrive) will never have an official token. This is the only one I'll ever make." />
|
||||
<ProofRow text="I deployed it myself so liquidity can be locked and the trajectory controlled — and I no longer claim fees on any other community tokens." />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Good to know ─────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Good to know
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Optional, just for fun.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{FAQ.map((item) => (
|
||||
<div
|
||||
key={item.q}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{item.q}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{item.a}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground/70 mt-10 max-w-2xl mx-auto">
|
||||
{TOKEN_TICKER} is a community token with no affiliation to any
|
||||
exchange or financial product. Nothing on this page is financial
|
||||
advice. Verify the contract address before buying.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProofRow({text}: {text: string}) {
|
||||
return (
|
||||
<li className="flex items-start gap-3 text-sm text-foreground/90">
|
||||
<Check className="h-5 w-5 shrink-0 text-accent mt-px" />
|
||||
<span>{text}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Eye, Sliders, Waypoints } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// ─── Scenarios (the agent console cycles through these) ────────────────────
|
||||
|
||||
type Scenario = {
|
||||
agent: string;
|
||||
voice: string;
|
||||
voiceGradient: [string, string];
|
||||
log: { prefix: string; text: string; tone: 'accent' | 'success' | 'dim' }[];
|
||||
utterance: string;
|
||||
};
|
||||
|
||||
const SCENARIOS: Scenario[] = [
|
||||
{
|
||||
agent: 'Claude Code',
|
||||
voice: 'Morgan',
|
||||
voiceGradient: ['#60a5fa', '#6366f1'],
|
||||
log: [
|
||||
{ prefix: '$', text: 'claude run', tone: 'accent' },
|
||||
{ prefix: '✓', text: 'Tests passing (42 files)', tone: 'success' },
|
||||
{ prefix: '✓', text: 'Build succeeded in 12.4s', tone: 'success' },
|
||||
{ prefix: '→', text: 'voicebox.speak({ profile: "Morgan" })', tone: 'dim' },
|
||||
],
|
||||
utterance: 'Tests passing. Ready to merge.',
|
||||
},
|
||||
{
|
||||
agent: 'Cursor',
|
||||
voice: 'Scarlett',
|
||||
voiceGradient: ['#34d399', '#14b8a6'],
|
||||
log: [
|
||||
{ prefix: '$', text: 'cursor agent:deploy', tone: 'accent' },
|
||||
{ prefix: '✓', text: 'Migration applied (4 tables)', tone: 'success' },
|
||||
{ prefix: '✓', text: 'Deploy complete', tone: 'success' },
|
||||
{ prefix: '→', text: 'voicebox.speak({ profile: "Scarlett" })', tone: 'dim' },
|
||||
],
|
||||
utterance: 'Deploy shipped. Prod is green.',
|
||||
},
|
||||
{
|
||||
agent: 'Cline',
|
||||
voice: 'Jarvis',
|
||||
voiceGradient: ['#a855f7', '#ec4899'],
|
||||
log: [
|
||||
{ prefix: '$', text: 'cline task:review', tone: 'accent' },
|
||||
{ prefix: '!', text: '3 files need attention', tone: 'dim' },
|
||||
{ prefix: '→', text: 'voicebox.speak({ profile: "Jarvis" })', tone: 'dim' },
|
||||
],
|
||||
utterance: 'Review ready. Three files to look at.',
|
||||
},
|
||||
];
|
||||
|
||||
const TONE_CLASSES: Record<Scenario['log'][number]['tone'], string> = {
|
||||
accent: 'text-accent',
|
||||
success: 'text-emerald-400/80',
|
||||
dim: 'text-ink-faint/70',
|
||||
};
|
||||
|
||||
// ─── Console mockup ─────────────────────────────────────────────────────────
|
||||
|
||||
function AgentConsole({ scenario, cycleKey }: { scenario: Scenario; cycleKey: number }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-app-line bg-app-darkerBox overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
{/* Titlebar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-app-line bg-app-darkBox/60">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-red-500/50" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/50" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500/50" />
|
||||
</div>
|
||||
<div className="flex-1 text-center">
|
||||
<span className="text-[10px] font-mono text-ink-faint/60">{scenario.agent}</span>
|
||||
</div>
|
||||
<div className="w-12" />
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5 font-mono text-[12px] leading-relaxed min-h-[220px] flex flex-col">
|
||||
<div className="space-y-1.5">
|
||||
{scenario.log.map((line, i) => (
|
||||
<motion.div
|
||||
key={`${cycleKey}-line-${i}`}
|
||||
className="flex items-start gap-2"
|
||||
initial={{ opacity: 0, y: 2 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: i * 0.15 }}
|
||||
>
|
||||
<span className={`shrink-0 ${TONE_CLASSES[line.tone]}`}>{line.prefix}</span>
|
||||
<span
|
||||
className={
|
||||
line.tone === 'dim' ? 'text-ink-faint/70' : 'text-ink-dull'
|
||||
}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Idle cursor so the terminal doesn't feel empty */}
|
||||
<div className="mt-auto flex items-center gap-2 pt-4">
|
||||
<span className="text-ink-faint/50">$</span>
|
||||
<span className="inline-block h-3.5 w-[7px] bg-ink-faint/40 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Desktop-floating pill stage ────────────────────────────────────────────
|
||||
|
||||
function AgentSpeakStage({ scenario, cycleKey }: { scenario: Scenario; cycleKey: number }) {
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-xl border border-app-line bg-app-darkerBox/60 overflow-hidden min-h-[180px] flex-1"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, hsl(30 10% 94% / 0.04) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, hsl(30 10% 94% / 0.04) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '28px 28px',
|
||||
}}
|
||||
>
|
||||
{/* Caption in the corner — "this is on the desktop, not in a terminal" */}
|
||||
<div className="absolute top-3 left-4 text-[9px] font-mono uppercase tracking-[0.22em] text-ink-faint/50">
|
||||
On your desktop
|
||||
</div>
|
||||
|
||||
{/* Voice-tinted glow behind the pill */}
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<motion.div
|
||||
key={`glow-${cycleKey}`}
|
||||
className="w-[320px] h-[140px] rounded-full blur-[70px]"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${scenario.voiceGradient[0]}, ${scenario.voiceGradient[1]})`,
|
||||
}}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.3 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pill + utterance caption */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-6">
|
||||
<motion.div
|
||||
key={`pill-${cycleKey}`}
|
||||
className="inline-flex items-center gap-3 px-4 h-11 rounded-full bg-black/55 backdrop-blur-md shadow-[0_12px_40px_rgba(0,0,0,0.45)]"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.6 }}
|
||||
>
|
||||
<div
|
||||
className="h-4 w-4 rounded-full shrink-0 ring-1 ring-white/10"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${scenario.voiceGradient[0]}, ${scenario.voiceGradient[1]})`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[12px] font-medium text-foreground/90 shrink-0">
|
||||
Speaking · <span className="text-accent">{scenario.voice}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-[2.5px] h-5 shrink-0">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<motion.div
|
||||
key={`bar-${scenario.voice}-${i}`}
|
||||
className="w-[2.5px] rounded-full bg-accent"
|
||||
animate={{ height: ['5px', '14px', '7px', '12px', '5px'] }}
|
||||
transition={{
|
||||
duration: 1.0,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.09,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
key={`utter-${cycleKey}`}
|
||||
className="text-[12px] text-ink-dull/80 italic text-center max-w-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.9 }}
|
||||
>
|
||||
“{scenario.utterance}”
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Code panel ─────────────────────────────────────────────────────────────
|
||||
|
||||
const MCP_CONFIG = `{
|
||||
"mcpServers": {
|
||||
"voicebox": {
|
||||
"url": "http://127.0.0.1:17493/mcp"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const SPEAK_EXAMPLE = `// In any MCP-aware agent:
|
||||
await voicebox.speak({
|
||||
text: "Deploy complete.",
|
||||
profile: "Morgan",
|
||||
})`;
|
||||
|
||||
function CodePanel() {
|
||||
return (
|
||||
<div className="rounded-xl border border-app-line bg-app-darkBox overflow-hidden flex flex-col">
|
||||
{/* MCP config */}
|
||||
<div className="p-5 border-b border-app-line">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[9px] font-mono text-accent font-semibold tabular-nums">
|
||||
01
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-ink-faint/70 uppercase tracking-wider">
|
||||
Add Voicebox to your MCP config
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono text-ink-dull leading-relaxed overflow-x-auto">
|
||||
{MCP_CONFIG}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Tool call */}
|
||||
<div className="p-5 flex-1">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[9px] font-mono text-accent font-semibold tabular-nums">
|
||||
02
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-ink-faint/70 uppercase tracking-wider">
|
||||
The tool is now available
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono text-ink-dull leading-relaxed overflow-x-auto">
|
||||
{SPEAK_EXAMPLE}
|
||||
</pre>
|
||||
|
||||
{/* Hint line */}
|
||||
<div className="mt-4 text-[10px] text-ink-faint/60 leading-relaxed">
|
||||
Also exposed as{' '}
|
||||
<code className="text-accent/80">POST /speak</code> for anything that
|
||||
doesn’t speak MCP — ACP, A2A, shell scripts, or custom harnesses.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Support bullets ────────────────────────────────────────────────────────
|
||||
|
||||
const BULLETS = [
|
||||
{
|
||||
icon: Sliders,
|
||||
title: 'Per-agent voice',
|
||||
description:
|
||||
'Bind each MCP client to a voice profile. Claude Code in Morgan, Cursor in Scarlett — you know which agent is talking without looking.',
|
||||
},
|
||||
{
|
||||
icon: Eye,
|
||||
title: 'Always visible',
|
||||
description:
|
||||
'Every agent-initiated speech surfaces the pill. No silent background TTS — you always see what’s coming out of your machine.',
|
||||
},
|
||||
{
|
||||
icon: Waypoints,
|
||||
title: 'Open protocols',
|
||||
description:
|
||||
'MCP ships day one. ACP, A2A, and anything else built on a tool-call primitive slots into the same endpoint.',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Section ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AgentIntegration() {
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setIdx((i) => (i + 1) % SCENARIOS.length);
|
||||
}, 4200);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const scenario = SCENARIOS[idx];
|
||||
|
||||
return (
|
||||
<section id="mcp" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
{/* Header */}
|
||||
<div className="max-w-3xl mx-auto text-center mb-14">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
MCP
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-semibold tracking-tight text-foreground mb-5">
|
||||
Every agent gets a voice.
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base md:text-lg leading-relaxed">
|
||||
One tool call —{' '}
|
||||
<code className="text-accent font-mono text-[0.9em]">voicebox.speak</code> —
|
||||
and any MCP-aware agent can talk to you in a voice you’ve cloned. Claude Code,
|
||||
Cursor, Cline, or anything that speaks MCP.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Code (left) + console with pill stage stacked underneath (right) */}
|
||||
<div className="grid md:grid-cols-2 gap-6 mb-12 items-stretch">
|
||||
<CodePanel />
|
||||
<div className="flex flex-col gap-4">
|
||||
<AgentConsole scenario={scenario} cycleKey={idx} />
|
||||
<AgentSpeakStage scenario={scenario} cycleKey={idx} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bullets */}
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{BULLETS.map((bullet) => {
|
||||
const Icon = bullet.icon;
|
||||
return (
|
||||
<div
|
||||
key={bullet.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-5"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-[14px] font-semibold text-foreground">
|
||||
{bullet.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[13px] leading-relaxed text-muted-foreground">
|
||||
{bullet.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {AppWindow, Code2, Gamepad2, Terminal, Wrench} from "lucide-react";
|
||||
|
||||
type Endpoint = {
|
||||
method: "POST" | "GET" | "DELETE" | "PATCH";
|
||||
path: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const ENDPOINTS: Endpoint[] = [
|
||||
{method: "POST", path: "/generate", label: "Generate speech"},
|
||||
{method: "POST", path: "/generate/{id}/cancel", label: "Cancel a generation"},
|
||||
{method: "GET", path: "/profiles", label: "List voice profiles"},
|
||||
{method: "POST", path: "/profiles", label: "Create a new profile"},
|
||||
{method: "GET", path: "/models/status", label: "Model catalog & state"},
|
||||
{method: "GET", path: "/history", label: "Past generations"},
|
||||
{method: "GET", path: "/health", label: "Server health"},
|
||||
];
|
||||
|
||||
const METHOD_STYLES: Record<Endpoint["method"], string> = {
|
||||
POST: "bg-accent/10 text-accent border-accent/20",
|
||||
GET: "bg-muted text-muted-foreground border-border",
|
||||
DELETE: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
PATCH: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||
};
|
||||
|
||||
const CURL_SNIPPET = `curl -X POST http://127.0.0.1:17493/generate \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"text": "Welcome to the game, player one.",
|
||||
"profile_id": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
"engine": "qwen_custom_voice",
|
||||
"instruct": "warm, slow, cinematic"
|
||||
}' \\
|
||||
--output line.wav`;
|
||||
|
||||
const USE_CASES = [
|
||||
{
|
||||
icon: Gamepad2,
|
||||
title: "Games",
|
||||
description:
|
||||
"Generate NPC dialogue on the fly, localize characters into new languages, or ship expressive voice lines without a studio.",
|
||||
},
|
||||
{
|
||||
icon: AppWindow,
|
||||
title: "Apps & agents",
|
||||
description:
|
||||
"Give your app or AI agent a voice. Real-time narration, accessibility readouts, voice replies — all running on the user's machine.",
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
title: "Scripts & tools",
|
||||
description:
|
||||
"Batch-generate audiobook chapters, automate podcast intros, or wire Voicebox into your Stream Deck. It's just a localhost URL.",
|
||||
},
|
||||
];
|
||||
|
||||
export function ApiSection() {
|
||||
return (
|
||||
<section id="api" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-14">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-3 py-1 mb-4">
|
||||
<Code2 className="h-3 w-3 text-accent" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Built-in REST API
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Your local voice API
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Every engine you download becomes a REST endpoint on your machine.
|
||||
Build apps, games, and voice tools with full programmatic control —
|
||||
no API keys, no rate limits, no per-character fees.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main panel: endpoints + code snippet */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-5 mb-14">
|
||||
{/* Endpoint reference */}
|
||||
<div className="lg:col-span-3 rounded-xl border border-border bg-card/60 backdrop-blur-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-border/60 bg-card/40">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground ml-2">
|
||||
API Reference
|
||||
</span>
|
||||
</div>
|
||||
<code className="text-[10px] bg-background border border-border px-1.5 py-0.5 rounded font-mono text-muted-foreground">
|
||||
http://127.0.0.1:17493
|
||||
</code>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-1">
|
||||
{ENDPOINTS.map((ep) => (
|
||||
<div
|
||||
key={`${ep.method}-${ep.path}`}
|
||||
className="flex items-center gap-3 py-1.5 group"
|
||||
>
|
||||
<span
|
||||
className={`text-[10px] font-mono font-semibold w-12 text-center rounded px-1 py-0.5 border ${METHOD_STYLES[ep.method]}`}
|
||||
>
|
||||
{ep.method}
|
||||
</span>
|
||||
<code className="text-xs font-mono text-foreground/90">
|
||||
{ep.path}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground/60 ml-auto">
|
||||
{ep.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border/60 px-5 py-3 bg-card/40">
|
||||
<a
|
||||
href="http://127.0.0.1:17493/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-accent hover:underline"
|
||||
>
|
||||
See the full OpenAPI reference at{" "}
|
||||
<code className="font-mono">/docs</code> when Voicebox is running
|
||||
→
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code snippet */}
|
||||
<div className="lg:col-span-2 rounded-xl border border-border bg-card/60 backdrop-blur-sm overflow-hidden flex flex-col">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border/60 bg-card/40">
|
||||
<Terminal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
Generate a line
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground/50 font-mono">
|
||||
curl
|
||||
</span>
|
||||
</div>
|
||||
<pre className="flex-1 p-4 text-[11px] font-mono text-muted-foreground/90 leading-relaxed overflow-x-auto whitespace-pre">
|
||||
<code>{CURL_SNIPPET}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use cases */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{USE_CASES.map((uc) => {
|
||||
const Icon = uc.icon;
|
||||
return (
|
||||
<div
|
||||
key={uc.title}
|
||||
className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 transition-colors hover:border-accent/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-[15px] font-medium text-foreground">
|
||||
{uc.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{uc.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom bar: key selling points */}
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-x-8 gap-y-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
No API keys
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
No rate limits
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
No per-character fees
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
Works offline
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
Your audio, your machine
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
export function Banner() {
|
||||
return (
|
||||
<div className="bg-primary/[0.06] border-b border-border backdrop-blur-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-center h-10 text-sm">
|
||||
<a
|
||||
href="https://spacebot.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors group"
|
||||
>
|
||||
<span>
|
||||
Also by the creator of Voicebox:{' '}
|
||||
<strong className="text-foreground/90">Spacebot</strong>, an AI agent OS for teams.
|
||||
Connect Discord, Slack, or Telegram in one click.
|
||||
</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Github } from 'lucide-react';
|
||||
import { GITHUB_REPO } from '@/lib/constants';
|
||||
import { DictationHero } from './CaptureSection';
|
||||
|
||||
export function CaptureHero({
|
||||
version,
|
||||
totalDownloads,
|
||||
}: {
|
||||
version: string | null;
|
||||
totalDownloads: number | null;
|
||||
}) {
|
||||
return (
|
||||
<section className="relative pt-32 pb-16">
|
||||
{/* Background glow */}
|
||||
<div className="hero-glow hero-glow-fade pointer-events-none absolute inset-0 -top-32">
|
||||
<div className="absolute left-1/2 top-0 -translate-x-1/2 w-[900px] h-[500px] rounded-full bg-accent/12 blur-[140px]" />
|
||||
<div className="absolute left-1/2 top-16 -translate-x-1/2 w-[520px] h-[360px] rounded-full bg-accent/8 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-5xl px-6 text-center">
|
||||
{/* Kicker */}
|
||||
<div
|
||||
className="fade-in mb-6 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent"
|
||||
style={{ animationDelay: '50ms' }}
|
||||
>
|
||||
Voice dictation · for humans and AI agents
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<div className="fade-in relative" style={{ animationDelay: '100ms' }}>
|
||||
<h1 className="text-5xl font-bold tracking-tighter leading-[0.9] text-foreground md:text-7xl lg:text-[96px]">
|
||||
Just talk to your computer.
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p
|
||||
className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"
|
||||
style={{ animationDelay: '200ms' }}
|
||||
>
|
||||
Hold a key anywhere on your machine, speak, release — your words land in the focused
|
||||
text field. A free, open-source, entirely-local alternative to{' '}
|
||||
<b className="text-white">WisprFlow</b>. And because Voicebox clones voices too, any
|
||||
AI agent can speak back in a voice you own.
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div
|
||||
className="fade-in mt-10 flex flex-row items-center justify-center gap-3 sm:gap-4"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
>
|
||||
<a
|
||||
href="/download"
|
||||
className="rounded-full bg-accent px-8 py-3.5 text-sm font-semibold uppercase tracking-wider text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3),inset_0_2px_0_rgba(255,255,255,0.2),inset_0_-2px_0_rgba(0,0,0,0.1)] transition-all hover:bg-accent-faint active:shadow-[0_2px_10px_hsl(43_60%_50%/0.3),inset_0_4px_8px_rgba(0,0,0,0.3)]"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_REPO}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-6 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-border"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Version + downloads */}
|
||||
<p
|
||||
className="fade-in mt-4 text-xs text-muted-foreground/50"
|
||||
style={{ animationDelay: '400ms' }}
|
||||
>
|
||||
{version ?? ''}
|
||||
{version && totalDownloads != null ? ' · ' : ''}
|
||||
{totalDownloads != null ? `${totalDownloads.toLocaleString()} downloads` : ''}
|
||||
{version || totalDownloads != null ? ' · ' : ''}
|
||||
macOS, Windows, Linux
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hero visual — the pill itself */}
|
||||
<div className="mt-20 px-6">
|
||||
<DictationHero />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Bot, Mic2, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// ─── Hero: Hotkey Pill ──────────────────────────────────────────────────────
|
||||
// Ported from app/src/components/ServerTab/CapturesPage.tsx HotkeyPillPreview.
|
||||
// Scaled up and retuned for the landing page — larger grid field, stretched
|
||||
// aspect, longer rest phase so the loop reads as intentional.
|
||||
|
||||
type PillState = 'recording' | 'transcribing' | 'refining' | 'rest';
|
||||
|
||||
const PILL_SEQUENCE: PillState[] = ['recording', 'transcribing', 'refining', 'rest'];
|
||||
const PILL_DURATIONS: Record<PillState, number> = {
|
||||
recording: 2800,
|
||||
transcribing: 1600,
|
||||
refining: 1600,
|
||||
rest: 1400,
|
||||
};
|
||||
const PILL_LABELS: Record<Exclude<PillState, 'rest'>, string> = {
|
||||
recording: 'Recording',
|
||||
transcribing: 'Transcribing',
|
||||
refining: 'Refining',
|
||||
};
|
||||
|
||||
function PillAudioBars({ mode }: { mode: 'live' | 'thinking' }) {
|
||||
return (
|
||||
<div className="flex items-center gap-[3px] h-6 shrink-0">
|
||||
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<motion.div
|
||||
key={`${mode}-${i}`}
|
||||
className="w-[3.5px] rounded-full bg-accent"
|
||||
animate={
|
||||
mode === 'live'
|
||||
? { height: ['10px', '18px', '6px', '16px', '10px'] }
|
||||
: { height: ['8px', '20px', '8px'] }
|
||||
}
|
||||
transition={
|
||||
mode === 'live'
|
||||
? { duration: 1.1, repeat: Infinity, delay: i * 0.12, ease: 'easeInOut' }
|
||||
: { duration: 0.7, repeat: Infinity, delay: i * 0.09, ease: 'easeInOut' }
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KbdKey({ children }: { children: string }) {
|
||||
return (
|
||||
<kbd className="inline-flex items-center justify-center h-7 min-w-[1.75rem] px-2 rounded-md border border-app-line bg-app-darkBox/80 font-mono text-[12px] font-medium text-foreground shadow-[inset_0_-2px_0_rgba(0,0,0,0.2)]">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
export function DictationHero() {
|
||||
const [state, setState] = useState<PillState>('recording');
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
const next = PILL_SEQUENCE[(PILL_SEQUENCE.indexOf(state) + 1) % PILL_SEQUENCE.length];
|
||||
setState(next);
|
||||
}, PILL_DURATIONS[state]);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'recording') return;
|
||||
setTick(0);
|
||||
const iv = window.setInterval(() => setTick((n) => n + 1), 90);
|
||||
return () => window.clearInterval(iv);
|
||||
}, [state]);
|
||||
|
||||
const elapsedSec = Math.floor((tick * 90) / 1000);
|
||||
const elapsedLabel = `0:${String(elapsedSec).padStart(2, '0')}`;
|
||||
const pillVisible = state !== 'rest';
|
||||
const barMode: 'live' | 'thinking' = state === 'recording' ? 'live' : 'thinking';
|
||||
const labelText = state === 'rest' ? PILL_LABELS.recording : PILL_LABELS[state];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl">
|
||||
{/* Shortcut hint above the field */}
|
||||
<div className="mt-10 mb-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-1.5 text-[13px] text-muted-foreground">
|
||||
<span>Hold</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<KbdKey>⌘</KbdKey>
|
||||
<KbdKey>⌥</KbdKey>
|
||||
</div>
|
||||
<span>on macOS,</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<KbdKey>Ctrl</KbdKey>
|
||||
<KbdKey>Alt</KbdKey>
|
||||
</div>
|
||||
<span>on Windows — from anywhere on your machine.</span>
|
||||
</div>
|
||||
|
||||
{/* The stage — gridded field with the pill floating in the middle */}
|
||||
<div
|
||||
className="relative rounded-2xl border border-app-line bg-app-darkerBox/60 overflow-hidden aspect-[5/1]"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, hsl(30 10% 94% / 0.04) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, hsl(30 10% 94% / 0.04) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
>
|
||||
{/* Soft accent glow behind the pill */}
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-[420px] h-[160px] rounded-full bg-accent/10 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
{/* Floating pill */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className={`inline-flex items-center gap-4 px-6 h-14 rounded-full bg-black/55 backdrop-blur-md text-accent shadow-[0_12px_40px_rgba(0,0,0,0.45)] transition-opacity duration-500 ease-out ${
|
||||
pillVisible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
{/* Gold dot — pings during recording */}
|
||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||
{state === 'recording' && (
|
||||
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-70" />
|
||||
)}
|
||||
<span className="relative rounded-full h-2.5 w-2.5 bg-accent" />
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="text-[15px] font-medium shrink-0"
|
||||
style={{ minWidth: '120px' }}
|
||||
>
|
||||
{labelText}
|
||||
</span>
|
||||
|
||||
<PillAudioBars mode={barMode} />
|
||||
|
||||
<span className="text-[13px] tabular-nums text-accent/70 font-medium shrink-0 -ml-1">
|
||||
{elapsedLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card: Whisper, sized for every machine ────────────────────────────────
|
||||
|
||||
type EngineRow = { name: string; size: string; langs: string };
|
||||
|
||||
const STT_ENGINES: EngineRow[] = [
|
||||
{ name: 'Whisper Base', size: '74M', langs: '99 langs' },
|
||||
{ name: 'Whisper Small', size: '244M', langs: '99 langs' },
|
||||
{ name: 'Whisper Medium', size: '769M', langs: '99 langs' },
|
||||
{ name: 'Whisper Large', size: '1.5B', langs: '99 langs' },
|
||||
{ name: 'Whisper Turbo', size: '809M', langs: '99 langs' },
|
||||
];
|
||||
|
||||
function MultiEngineSTTAnimation() {
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setActiveIdx((i) => (i + 1) % STT_ENGINES.length);
|
||||
}, 1600);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-40 w-full flex items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4">
|
||||
<div className="w-full max-w-[240px] space-y-1.5">
|
||||
{STT_ENGINES.map((engine, i) => {
|
||||
const active = i === activeIdx;
|
||||
return (
|
||||
<motion.div
|
||||
key={engine.name}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-md border"
|
||||
animate={{
|
||||
borderColor: active ? 'hsl(43 50% 45% / 0.5)' : 'rgba(255,255,255,0.06)',
|
||||
backgroundColor: active ? 'hsl(43 50% 45% / 0.08)' : 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<motion.div
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
animate={{
|
||||
backgroundColor: active ? 'hsl(43 50% 50%)' : 'rgba(255,255,255,0.15)',
|
||||
boxShadow: active ? '0 0 8px hsl(43 50% 50%)' : '0 0 0 transparent',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
<span
|
||||
className="text-[10px] font-medium flex-1 truncate"
|
||||
style={{ color: active ? 'hsl(43 50% 55%)' : 'rgba(255,255,255,0.55)' }}
|
||||
>
|
||||
{engine.name}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-ink-faint/70 tabular-nums">
|
||||
{engine.size}
|
||||
</span>
|
||||
<span className="text-[9px] text-ink-faint/60">{engine.langs}</span>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card: LLM Refinement ───────────────────────────────────────────────────
|
||||
|
||||
const REFINEMENT_PAIRS = [
|
||||
{
|
||||
raw: 'um so like i think we should ship it on friday, actually no wait, tuesday',
|
||||
clean: 'I think we should ship it on Tuesday.',
|
||||
},
|
||||
{
|
||||
raw: 'could you uh run the migration real quick, and then, yeah, check the logs',
|
||||
clean: 'Could you run the migration, then check the logs?',
|
||||
},
|
||||
];
|
||||
|
||||
function RefinementAnimation() {
|
||||
const [pairIdx, setPairIdx] = useState(0);
|
||||
const [showClean, setShowClean] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const step = () => {
|
||||
if (!mounted) return;
|
||||
setShowClean(false);
|
||||
window.setTimeout(() => mounted && setShowClean(true), 1400);
|
||||
window.setTimeout(() => {
|
||||
if (!mounted) return;
|
||||
setPairIdx((i) => (i + 1) % REFINEMENT_PAIRS.length);
|
||||
}, 4000);
|
||||
};
|
||||
step();
|
||||
const iv = window.setInterval(step, 4000);
|
||||
return () => {
|
||||
mounted = false;
|
||||
window.clearInterval(iv);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const pair = REFINEMENT_PAIRS[pairIdx];
|
||||
|
||||
return (
|
||||
<div className="h-40 w-full flex flex-col items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4 gap-2.5">
|
||||
<div className="w-full max-w-[260px] space-y-2">
|
||||
{/* Raw line — always visible, dims when refined */}
|
||||
<motion.div
|
||||
key={`raw-${pairIdx}`}
|
||||
className="text-[10px] font-mono leading-relaxed"
|
||||
initial={{ opacity: 0, y: 2 }}
|
||||
animate={{
|
||||
opacity: showClean ? 0.35 : 1,
|
||||
y: 0,
|
||||
color: showClean ? 'rgba(255,255,255,0.35)' : 'rgba(255,255,255,0.6)',
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<span className="text-ink-faint/50 mr-1.5">raw</span>
|
||||
{pair.raw}
|
||||
</motion.div>
|
||||
|
||||
{/* Refined line — fades in */}
|
||||
<motion.div
|
||||
key={`clean-${pairIdx}`}
|
||||
className="text-[10px] leading-relaxed"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{
|
||||
opacity: showClean ? 1 : 0,
|
||||
y: showClean ? 0 : 4,
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<span className="text-accent/70 mr-1.5 font-mono">clean</span>
|
||||
<span className="text-foreground">{pair.clean}</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Activity indicator */}
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-mono text-ink-faint mt-1">
|
||||
<Sparkles className="h-2.5 w-2.5 text-accent" />
|
||||
<span>{showClean ? 'refined' : 'Qwen3 · refining...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card: Agent voice output ───────────────────────────────────────────────
|
||||
|
||||
type AgentSpeaker = {
|
||||
agent: string;
|
||||
voice: string;
|
||||
gradient: [string, string];
|
||||
message: string;
|
||||
};
|
||||
|
||||
const AGENT_SPEAKERS: AgentSpeaker[] = [
|
||||
{
|
||||
agent: 'Claude Code',
|
||||
voice: 'Morgan',
|
||||
gradient: ['#60a5fa', '#6366f1'],
|
||||
message: 'Tests passing. Ready to merge.',
|
||||
},
|
||||
{
|
||||
agent: 'Cursor',
|
||||
voice: 'Scarlett',
|
||||
gradient: ['#34d399', '#14b8a6'],
|
||||
message: 'Build finished in 42s.',
|
||||
},
|
||||
{
|
||||
agent: 'Cline',
|
||||
voice: 'Jarvis',
|
||||
gradient: ['#a855f7', '#ec4899'],
|
||||
message: 'Deploy complete.',
|
||||
},
|
||||
];
|
||||
|
||||
function AgentVoiceAnimation() {
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setIdx((i) => (i + 1) % AGENT_SPEAKERS.length);
|
||||
}, 2600);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const current = AGENT_SPEAKERS[idx];
|
||||
|
||||
return (
|
||||
<div className="h-40 w-full flex flex-col items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4 gap-2.5">
|
||||
{/* Which agent called speak() */}
|
||||
<motion.div
|
||||
key={`agent-${idx}`}
|
||||
className="text-[9px] font-mono"
|
||||
initial={{ opacity: 0, y: 2 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<span className="text-ink-faint/50">via MCP</span>
|
||||
<span className="mx-1.5 text-ink-faint/30">·</span>
|
||||
<span className="text-ink-dull">{current.agent}</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Pill in speaking state */}
|
||||
<motion.div
|
||||
key={`pill-${idx}`}
|
||||
className="inline-flex items-center gap-2.5 px-3 h-8 rounded-full bg-black/55 backdrop-blur-sm shadow-[0_6px_20px_rgba(0,0,0,0.35)]"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div
|
||||
className="h-4 w-4 rounded-full shrink-0 ring-1 ring-white/10"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${current.gradient[0]}, ${current.gradient[1]})`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] font-medium text-foreground/90">
|
||||
Speaking · <span className="text-accent">{current.voice}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-[2px] h-3.5">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<motion.div
|
||||
key={`${current.voice}-${i}`}
|
||||
className="w-[2px] rounded-full bg-accent"
|
||||
animate={{ height: ['4px', '11px', '5px', '9px', '4px'] }}
|
||||
transition={{
|
||||
duration: 0.9,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.08,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* The line the agent is saying */}
|
||||
<motion.div
|
||||
key={`msg-${idx}`}
|
||||
className="text-[10px] font-mono text-ink-dull max-w-[220px] text-center leading-relaxed"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.15 }}
|
||||
>
|
||||
“{current.message}”
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Feature data + card ────────────────────────────────────────────────────
|
||||
|
||||
const CAPTURE_FEATURES = [
|
||||
{
|
||||
title: 'Whisper, sized for every machine',
|
||||
description:
|
||||
'Base, Small, Medium, Large, and Turbo. Pick the size that fits your hardware and quality bar — 99 languages across every tier, all running locally.',
|
||||
icon: Mic2,
|
||||
animation: MultiEngineSTTAnimation,
|
||||
},
|
||||
{
|
||||
title: 'Refined transcripts',
|
||||
description:
|
||||
'A local LLM cleans ums, self-corrections, and punctuation without rephrasing. Optional, toggleable, and never leaves your machine.',
|
||||
icon: Sparkles,
|
||||
animation: RefinementAnimation,
|
||||
},
|
||||
{
|
||||
title: 'Agents speak in voices you own',
|
||||
description:
|
||||
'Any MCP-aware agent — Claude Code, Cursor, Cline — gets a voice with one tool call. The pill surfaces when an agent is speaking, so you always see what’s coming out of your machine.',
|
||||
icon: Bot,
|
||||
animation: AgentVoiceAnimation,
|
||||
},
|
||||
];
|
||||
|
||||
function CaptureCard({ feature }: { feature: (typeof CAPTURE_FEATURES)[number] }) {
|
||||
const Icon = feature.icon;
|
||||
const Animation = feature.animation;
|
||||
return (
|
||||
<div className="rounded-lg border border-app-line bg-app-darkBox overflow-hidden">
|
||||
<div className="pointer-events-none select-none">
|
||||
<Animation />
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-[15px] font-medium text-foreground">{feature.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CaptureSection() {
|
||||
return (
|
||||
<section id="capture" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
{/* Kicker + headline */}
|
||||
<div className="text-center mb-14">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Capture
|
||||
</div>
|
||||
<h2 className="text-4xl font-semibold tracking-tight text-foreground md:text-5xl mb-5">
|
||||
Dictate anywhere. Paste into any app.
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto text-base md:text-lg leading-relaxed">
|
||||
Hold a shortcut anywhere on your machine, speak, release.
|
||||
The transcript lands in a focused text field in any app, or your clipboard. Agents speak
|
||||
back through the same pill in any cloned voice.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hero pill animation */}
|
||||
<div className="mb-16">
|
||||
<DictationHero />
|
||||
</div>
|
||||
|
||||
{/* Feature cards */}
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{CAPTURE_FEATURES.map((f) => (
|
||||
<CaptureCard key={f.title} feature={f} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,481 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
AudioLines,
|
||||
Box,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Copy,
|
||||
FileAudio,
|
||||
Mic,
|
||||
Download,
|
||||
Play,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Subtitles,
|
||||
Users,
|
||||
Volume2,
|
||||
Wand2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
// ─── Sidebar (matches ControlUI exactly) ───────────────────────────────────
|
||||
|
||||
const SIDEBAR_ITEMS = [
|
||||
{ icon: Volume2, label: 'Generate' },
|
||||
{ icon: AudioLines, label: 'Stories' },
|
||||
{ icon: Mic, label: 'Captures', active: true },
|
||||
{ icon: Users, label: 'Voices' },
|
||||
{ icon: Wand2, label: 'Effects' },
|
||||
{ icon: Box, label: 'Models' },
|
||||
{ icon: Settings, label: 'Settings' },
|
||||
];
|
||||
|
||||
function Sidebar() {
|
||||
return (
|
||||
<div className="hidden md:flex w-16 shrink-0 border-r border-app-line bg-sidebar flex-col items-center py-4 gap-4">
|
||||
{/* Logo */}
|
||||
<div className="mb-1">
|
||||
<div
|
||||
className="w-9 h-9 rounded-lg overflow-hidden"
|
||||
style={{
|
||||
filter:
|
||||
'drop-shadow(0 0 6px hsl(43 50% 45% / 0.5)) drop-shadow(0 0 14px hsl(43 50% 45% / 0.35))',
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/voicebox-logo-app.webp"
|
||||
alt=""
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{SIDEBAR_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center transition-all duration-200 ${
|
||||
item.active
|
||||
? 'bg-white/[0.07] text-foreground shadow-lg backdrop-blur-sm border border-white/[0.08]'
|
||||
: 'text-muted-foreground/60'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<div className="mt-auto text-[8px] text-muted-foreground/40">v0.5.0</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── FakeWaveform (ported from CapturesTab.tsx) ────────────────────────────
|
||||
|
||||
function FakeWaveform({
|
||||
seed,
|
||||
active,
|
||||
className,
|
||||
}: {
|
||||
seed: number;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const bars = useMemo(() => {
|
||||
return Array.from({ length: 72 }).map((_, i) => {
|
||||
const h =
|
||||
28 +
|
||||
Math.sin(i * 0.35 + seed) * 22 +
|
||||
Math.cos(i * 0.81 + seed * 2) * 14 +
|
||||
Math.sin(i * 1.7 + seed * 3) * 8;
|
||||
return Math.max(6, Math.min(96, h));
|
||||
});
|
||||
}, [seed]);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-[2px] h-10 ${className ?? ''}`}>
|
||||
{bars.map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[3px] rounded-full"
|
||||
style={{
|
||||
height: `${h}%`,
|
||||
backgroundColor: active ? 'hsl(43 50% 50% / 0.85)' : 'hsl(var(--foreground) / 0.25)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Data ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Capture = {
|
||||
id: string;
|
||||
seed: number;
|
||||
transcriptRaw: string;
|
||||
transcriptRefined: string;
|
||||
durationMs: number;
|
||||
ago: string;
|
||||
createdAtLabel: string;
|
||||
source: 'dictation' | 'recording' | 'file';
|
||||
sttModel: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
const CAPTURES: Capture[] = [
|
||||
{
|
||||
id: 'c1',
|
||||
seed: 11,
|
||||
transcriptRaw:
|
||||
"okay so the pitch for voicebox is basically this it's a local first voice studio everything runs on your machine you clone voices from a few seconds of audio generate speech across seven TTS engines and now with the captures tab you can dictate into any app no cloud no API keys no per character fees your voice data never leaves your device privacy isn't a feature here it's the architecture",
|
||||
transcriptRefined:
|
||||
"Okay, so the pitch for Voicebox is basically this: it's a local-first voice studio. Everything runs on your machine. You clone voices from a few seconds of audio, generate speech across seven TTS engines, and now with the Captures tab, you can dictate into any app. No cloud, no API keys, no per-character fees. Your voice data never leaves your device. Privacy isn't a feature here — it's the architecture.",
|
||||
durationMs: 38000,
|
||||
ago: '4 min ago',
|
||||
createdAtLabel: 'Apr 22, 3:47 PM',
|
||||
source: 'dictation',
|
||||
sttModel: 'turbo',
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
seed: 23,
|
||||
transcriptRaw:
|
||||
"draft an update for the blog about the agent voice feature the key point is one MCP tool call and any agent on your machine gets a voice claude code finishes a long task calls voicebox dot speak and you hear it in a voice you've cloned morgan scarlett whatever you set up same pill that shows when you're dictating also shows when an agent is speaking so you always know what's coming out of your machine closes the whole voice IO loop for agents",
|
||||
transcriptRefined:
|
||||
"Draft an update for the blog about the agent voice feature. The key point: one MCP tool call, and any agent on your machine gets a voice. Claude Code finishes a long task, calls voicebox.speak, and you hear it in a voice you've cloned — Morgan, Scarlett, whatever you've set up. The same pill that shows when you're dictating also shows when an agent is speaking, so you always know what's coming out of your machine. It closes the full voice I/O loop for agents.",
|
||||
durationMs: 41000,
|
||||
ago: '22 min ago',
|
||||
createdAtLabel: 'Apr 22, 3:29 PM',
|
||||
source: 'dictation',
|
||||
sttModel: 'turbo',
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
id: 'c3',
|
||||
seed: 37,
|
||||
transcriptRaw:
|
||||
"tech overview for the readme seven TTS engines qwen3 kokoro chatterbox luxtts customvoice tada and chatterbox turbo whisper for STT in five sizes from base up to large and a turbo variant one local LLM qwen 3.5 shared runtime across all of them one model directory one GPU story no fragmented caches pick the right model per job speed on CPU laptops quality on an M series mac all switchable per generation",
|
||||
transcriptRefined:
|
||||
"Tech overview for the README: seven TTS engines — Qwen3, Kokoro, Chatterbox, LuxTTS, CustomVoice, TADA, and Chatterbox Turbo. Whisper for STT, in five sizes from Base up to Large, plus a Turbo variant. One local LLM, Qwen 3.5, with a shared runtime across all of them. One model directory, one GPU story, no fragmented caches. Pick the right model per job — speed on CPU laptops, quality on an M-series Mac, switchable per-generation.",
|
||||
durationMs: 34000,
|
||||
ago: '1 hr ago',
|
||||
createdAtLabel: 'Apr 22, 2:51 PM',
|
||||
source: 'dictation',
|
||||
sttModel: 'turbo',
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
id: 'c4',
|
||||
seed: 53,
|
||||
transcriptRaw:
|
||||
"okay the real magic is this you speak to voicebox your transcript gets cleaned up by a local LLM it pastes into whatever you're focused on then the agent you're talking to responds and it replies with voice in a voice you cloned through the same pill that's the loop elevenlabs has TTS wisprflow has dictation but neither runs locally and neither does both halves voicebox is full voice IO for humans and AI agents entirely on your machine",
|
||||
transcriptRefined:
|
||||
"Okay, the real magic: you speak to Voicebox, your transcript gets cleaned up by a local LLM, and it pastes into whatever you're focused on. Then the agent you're talking to responds — and it replies with voice, in a voice you've cloned, through the same pill. That's the loop. ElevenLabs has TTS, WisprFlow has dictation, but neither runs locally and neither does both halves. Voicebox is full voice I/O for humans and AI agents, entirely on your machine.",
|
||||
durationMs: 42000,
|
||||
ago: 'Yesterday',
|
||||
createdAtLabel: 'Apr 21, 11:14 PM',
|
||||
source: 'dictation',
|
||||
sttModel: 'large',
|
||||
language: 'en',
|
||||
},
|
||||
];
|
||||
|
||||
const PROFILES = [
|
||||
{ id: 'p1', name: 'Morgan', description: 'Warm, measured', gradient: 'from-blue-400 to-indigo-500' },
|
||||
{ id: 'p2', name: 'Scarlett', description: 'Bright, conversational', gradient: 'from-emerald-400 to-teal-500' },
|
||||
{ id: 'p3', name: 'Jarvis', description: 'Dry, composed', gradient: 'from-purple-500 to-fuchsia-500' },
|
||||
];
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const total = Math.round(ms / 1000);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source: Capture['source'] }) {
|
||||
const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio;
|
||||
const label =
|
||||
source === 'dictation' ? 'Dictation' : source === 'recording' ? 'Recording' : 'File';
|
||||
return (
|
||||
<span className="inline-flex items-center h-5 px-1.5 gap-1 rounded-md text-[10px] font-medium bg-muted/60 text-muted-foreground border border-transparent">
|
||||
<Icon className="h-2.5 w-2.5" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RefinedBadge() {
|
||||
return (
|
||||
<span className="inline-flex items-center h-5 px-1.5 gap-1 rounded-md text-[10px] font-medium bg-accent/10 text-accent border border-accent/20">
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
Refined
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BetaBadge() {
|
||||
return (
|
||||
<span className="inline-flex items-center h-5 px-1.5 rounded-md text-[10px] font-medium text-accent bg-accent/10 border border-accent/20">
|
||||
Beta
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Capture list row ───────────────────────────────────────────────────────
|
||||
|
||||
function CaptureRow({
|
||||
capture,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
capture: Capture;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`w-full text-left p-3 rounded-lg transition-colors block ${
|
||||
selected
|
||||
? 'bg-muted/70 border border-border'
|
||||
: 'border border-transparent hover:bg-muted/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[11px] text-muted-foreground font-medium">{capture.ago}</span>
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
|
||||
{formatDuration(capture.durationMs)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
|
||||
{capture.transcriptRefined}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<SourceBadge source={capture.source} />
|
||||
<RefinedBadge />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Detail view ────────────────────────────────────────────────────────────
|
||||
|
||||
function DetailView({ capture }: { capture: Capture }) {
|
||||
const [showRefined, setShowRefined] = useState(true);
|
||||
const [profileIdx, setProfileIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setShowRefined(true);
|
||||
}, [capture.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setProfileIdx((i) => (i + 1) % PROFILES.length);
|
||||
}, 2600);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const playAs = PROFILES[profileIdx];
|
||||
const transcript = showRefined ? capture.transcriptRefined : capture.transcriptRaw;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col px-8 pt-4 pb-5 overflow-hidden">
|
||||
{/* Compact top row — date + language + source, inline */}
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground/80 mb-4 shrink-0">
|
||||
<span>{capture.createdAtLabel}</span>
|
||||
{capture.language && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span>{capture.language.toUpperCase()}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<SourceBadge source={capture.source} />
|
||||
</div>
|
||||
|
||||
{/* Audio player card */}
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-4 mb-5 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-full border border-border bg-background flex items-center justify-center shrink-0">
|
||||
<Play className="h-4 w-4 ml-0.5 fill-current text-foreground" />
|
||||
</div>
|
||||
<FakeWaveform seed={capture.seed} active className="flex-1" />
|
||||
<span className="text-xs tabular-nums text-muted-foreground font-medium">
|
||||
{formatDuration(capture.durationMs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript header */}
|
||||
<div className="flex items-center gap-3 mb-3 shrink-0">
|
||||
<div className="inline-flex rounded-md bg-muted/40 p-0.5 border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRefined(true)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||
showRefined
|
||||
? 'bg-background shadow-sm text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-3 w-3 inline-block mr-1 -translate-y-px" />
|
||||
Refined
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRefined(false)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||
!showRefined
|
||||
? 'bg-background shadow-sm text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Subtitles className="h-3 w-3 inline-block mr-1 -translate-y-px" />
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{showRefined
|
||||
? 'Refined with Qwen3 · 1.7B'
|
||||
: `Whisper ${capture.sttModel}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Transcript body — focal point, fills remaining height */}
|
||||
<div className="flex-1 min-h-0 rounded-xl border border-border bg-muted/10 p-6 overflow-y-auto mb-4">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${capture.id}-${showRefined}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="text-[15px] leading-relaxed text-foreground/90"
|
||||
>
|
||||
{transcript}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Action row — matches CapturesTab bottom row */}
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<div className="inline-flex">
|
||||
<div className="inline-flex items-center justify-center gap-2 whitespace-nowrap h-9 rounded-full rounded-tr-none rounded-br-none border border-r-0 border-input bg-background pl-2 pr-3 text-sm font-medium transition-colors">
|
||||
<div
|
||||
className={`h-5 w-5 rounded-full bg-gradient-to-br shrink-0 ring-1 ring-white/10 ${playAs.gradient}`}
|
||||
/>
|
||||
<Volume2 className="h-4 w-4 shrink-0" />
|
||||
Play as {playAs.name}
|
||||
</div>
|
||||
<div className="inline-flex items-center justify-center gap-2 whitespace-nowrap h-9 px-2 rounded-full rounded-tl-none rounded-bl-none border border-input bg-background text-sm font-medium transition-colors">
|
||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-70" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 h-9 px-3 rounded-full border border-input bg-background text-sm font-medium text-foreground whitespace-nowrap">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 h-9 px-3 rounded-full border border-input bg-background text-sm font-medium text-foreground whitespace-nowrap">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Re-refine
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 h-9 px-3 rounded-full border border-input bg-background text-sm font-medium text-foreground whitespace-nowrap">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main mockup ────────────────────────────────────────────────────────────
|
||||
|
||||
export function CapturesMockup() {
|
||||
const [selectedId, setSelectedId] = useState<string>(CAPTURES[0].id);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setSelectedId((current) => {
|
||||
const idx = CAPTURES.findIndex((c) => c.id === current);
|
||||
return CAPTURES[(idx + 1) % CAPTURES.length].id;
|
||||
});
|
||||
}, 4200);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const selected = CAPTURES.find((c) => c.id === selectedId) ?? CAPTURES[0];
|
||||
|
||||
return (
|
||||
<div className="relative z-20 mx-auto w-full max-w-5xl px-6">
|
||||
<div className="overflow-hidden rounded-2xl border border-app-line bg-app-box shadow-[0_25px_60px_rgba(0,0,0,0.5),0_8px_20px_rgba(0,0,0,0.3)] md:h-[640px] pointer-events-none select-none">
|
||||
<div className="flex flex-col md:flex-row h-full">
|
||||
<Sidebar />
|
||||
|
||||
{/* ── Main area: two-panel Captures tab ─────────────────── */}
|
||||
<div className="flex-1 flex flex-col md:flex-row min-w-0 relative">
|
||||
{/* ── Left: capture list (w-[340px]) ──────────────────── */}
|
||||
<div
|
||||
style={{ width: 300, flex: '0 0 300px' }}
|
||||
className="flex flex-col overflow-hidden border-r border-app-line"
|
||||
>
|
||||
{/* Header — normal flow */}
|
||||
<div className="shrink-0 pl-4 pr-4 pt-4 pb-2">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<h1 className="text-2xl px-4 font-bold">Captures</h1>
|
||||
<BetaBadge />
|
||||
</div>
|
||||
<div className="h-9 flex items-center rounded-full border border-input bg-background px-4 text-sm text-muted-foreground">
|
||||
Search transcripts…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="px-4 pt-2 pb-6 space-y-1">
|
||||
{CAPTURES.map((capture) => (
|
||||
<CaptureRow
|
||||
key={capture.id}
|
||||
capture={capture}
|
||||
selected={selectedId === capture.id}
|
||||
onSelect={() => setSelectedId(capture.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: capture detail (flex-1) ───────────────────── */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={selectedId}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex-1 overflow-hidden"
|
||||
>
|
||||
<DetailView capture={selected} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,889 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
AudioLines,
|
||||
Box,
|
||||
Download,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Server,
|
||||
Sparkles,
|
||||
Speaker,
|
||||
Star,
|
||||
Trash2,
|
||||
Volume2,
|
||||
Wand2,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { LandingAudioPlayer, unlockAudioContext } from './LandingAudioPlayer';
|
||||
|
||||
// ─── Data ───────────────────────────────────────────────────────────────────
|
||||
// Edit this section to customise all the content shown in the ControlUI demo.
|
||||
|
||||
interface VoiceProfile {
|
||||
name: string;
|
||||
description: string;
|
||||
language: string;
|
||||
hasEffects: boolean;
|
||||
}
|
||||
|
||||
/** Voice profiles shown in the grid / scroll strip. Index matters — DemoScript references profiles by index. */
|
||||
const PROFILES: VoiceProfile[] = [
|
||||
{
|
||||
name: 'Jarvis',
|
||||
description: 'Dry wit, composed British AI assistant',
|
||||
language: 'en',
|
||||
hasEffects: true,
|
||||
},
|
||||
{
|
||||
name: 'Samuel L. Jackson',
|
||||
description: 'Commanding intensity with sharp, punchy delivery',
|
||||
language: 'en',
|
||||
hasEffects: true,
|
||||
},
|
||||
{
|
||||
name: 'Bob Ross',
|
||||
description: 'Gentle, soothing voice full of quiet encouragement',
|
||||
language: 'en',
|
||||
hasEffects: false,
|
||||
},
|
||||
{
|
||||
name: 'Sam Altman',
|
||||
description: 'Measured, thoughtful Silicon Valley cadence',
|
||||
language: 'en',
|
||||
hasEffects: false,
|
||||
},
|
||||
{
|
||||
name: 'Morgan Freeman',
|
||||
description: 'Rich, warm baritone with gravitas and calm authority',
|
||||
language: 'en',
|
||||
hasEffects: false,
|
||||
},
|
||||
{
|
||||
name: 'Linus Tech Tips',
|
||||
description: 'Enthusiastic, fast-paced tech explainer energy',
|
||||
language: 'en',
|
||||
hasEffects: false,
|
||||
},
|
||||
{
|
||||
name: 'Fireship',
|
||||
description: 'Rapid-fire, deadpan tech humor with zero filler',
|
||||
language: 'en',
|
||||
hasEffects: false,
|
||||
},
|
||||
{
|
||||
name: 'Scarlett Johansson',
|
||||
description: 'Smooth, low alto with understated warmth',
|
||||
language: 'en',
|
||||
hasEffects: false,
|
||||
},
|
||||
{
|
||||
name: 'Dario Amodei',
|
||||
description: 'Calm, precise articulation with academic depth',
|
||||
language: 'en',
|
||||
hasEffects: false,
|
||||
},
|
||||
{
|
||||
name: 'David Attenborough',
|
||||
description: 'Warm, reverent narration with wonder and precision',
|
||||
language: 'en',
|
||||
hasEffects: false,
|
||||
},
|
||||
{
|
||||
name: 'Zendaya',
|
||||
description: 'Relaxed, modern delivery with effortless cool',
|
||||
language: 'en',
|
||||
hasEffects: false,
|
||||
},
|
||||
{
|
||||
name: 'Barack Obama',
|
||||
description: 'Measured cadence with rhythmic pauses and gravitas',
|
||||
language: 'en',
|
||||
hasEffects: false,
|
||||
},
|
||||
];
|
||||
|
||||
/** Each entry is one cycle of the demo animation: select a profile → type text → generate → play audio. */
|
||||
interface DemoStep {
|
||||
profileIndex: number;
|
||||
text: string;
|
||||
audioUrl: string;
|
||||
engine: string;
|
||||
duration: string;
|
||||
effect?: string;
|
||||
}
|
||||
|
||||
const DEMO_SCRIPT: DemoStep[] = [
|
||||
{
|
||||
profileIndex: 0,
|
||||
text: 'Sir, I have completed the analysis. Your code has twelve critical vulnerabilities, your coffee is cold, and frankly your commit messages could use some work.',
|
||||
audioUrl: '/audio/jarvis.webm',
|
||||
engine: 'Qwen 1.7B',
|
||||
duration: '0:10',
|
||||
effect: 'Robot',
|
||||
},
|
||||
{
|
||||
profileIndex: 4,
|
||||
text: "I've narrated penguins, galaxies, and the entire history of mankind. But nothing prepared me for the moment a computer learned to do my job from a five second audio clip.",
|
||||
audioUrl: '/audio/morganfreeman.webm',
|
||||
engine: 'Qwen 1.7B',
|
||||
duration: '0:11',
|
||||
effect: 'Radio',
|
||||
},
|
||||
{
|
||||
profileIndex: 3,
|
||||
text: "Open source? [laugh] What's that?",
|
||||
audioUrl: '/audio/samaltman.webm',
|
||||
engine: 'Chatterbox',
|
||||
duration: '0:03',
|
||||
},
|
||||
{
|
||||
profileIndex: 1,
|
||||
text: "So let me get this straight. You downloaded an app, pressed a button, and now there's two of me? The world was not ready for one",
|
||||
audioUrl: '/audio/samjackson.webm',
|
||||
engine: 'Qwen 1.7B',
|
||||
duration: '0:10',
|
||||
},
|
||||
{
|
||||
profileIndex: 5,
|
||||
text: "So we got this voice cloning software and honestly it's kind of terrifying. Like, my wife could not tell the difference. Voicebox dot s h, link in the description!",
|
||||
audioUrl: '/audio/linus.webm',
|
||||
engine: 'Qwen 1.7B',
|
||||
duration: '0:11',
|
||||
},
|
||||
{
|
||||
profileIndex: 6,
|
||||
text: 'This is Voicebox in one hundred seconds. It clones voices locally, it runs on your GPU, and no, OpenAI cannot hear you. Lets go.',
|
||||
audioUrl: '/audio/fireship.webm',
|
||||
engine: 'Qwen 0.6B',
|
||||
duration: '0:09',
|
||||
},
|
||||
];
|
||||
|
||||
/** History rows pre-populated on first load. Oldest first visually (array index 0 = top row). */
|
||||
interface Generation {
|
||||
id: number;
|
||||
profileName: string;
|
||||
text: string;
|
||||
language: string;
|
||||
engine: string;
|
||||
duration: string;
|
||||
timeAgo: string;
|
||||
favorited: boolean;
|
||||
versions: number;
|
||||
}
|
||||
|
||||
const INITIAL_GENERATIONS: Generation[] = [
|
||||
{
|
||||
id: 1,
|
||||
profileName: 'Morgan Freeman',
|
||||
text: 'The neural pathways of human speech contain more complexity than any language model can fully capture, yet we keep pushing the boundaries of what is possible.',
|
||||
language: 'en',
|
||||
engine: 'Qwen 1.7B',
|
||||
duration: '0:08',
|
||||
timeAgo: '2 minutes ago',
|
||||
favorited: true,
|
||||
versions: 3,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
profileName: 'Samuel L. Jackson',
|
||||
text: 'In a world increasingly shaped by artificial intelligence, the human voice remains our most powerful tool for connection and storytelling.',
|
||||
language: 'en',
|
||||
engine: 'Qwen 1.7B',
|
||||
duration: '0:07',
|
||||
timeAgo: '15 minutes ago',
|
||||
favorited: false,
|
||||
versions: 1,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
profileName: 'Jarvis',
|
||||
text: 'The architecture of modern text-to-speech systems reveals an elegant interplay between transformer models and acoustic feature prediction.',
|
||||
language: 'en',
|
||||
engine: 'Qwen 0.6B',
|
||||
duration: '0:09',
|
||||
timeAgo: '1 hour ago',
|
||||
favorited: false,
|
||||
versions: 2,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
profileName: 'Bob Ross',
|
||||
text: 'Welcome to the next chapter. Every great story begins with a single voice, and today that voice can be yours.',
|
||||
language: 'en',
|
||||
engine: 'Chatterbox',
|
||||
duration: '0:06',
|
||||
timeAgo: '3 hours ago',
|
||||
favorited: true,
|
||||
versions: 1,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
profileName: 'Linus Tech Tips',
|
||||
text: 'Local inference gives you complete control over your voice data. No cloud, no subscriptions, no compromises.',
|
||||
language: 'en',
|
||||
engine: 'Qwen 1.7B',
|
||||
duration: '0:05',
|
||||
timeAgo: '5 hours ago',
|
||||
favorited: false,
|
||||
versions: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const SIDEBAR_ITEMS = [
|
||||
{ icon: Volume2, label: 'Generate' },
|
||||
{ icon: AudioLines, label: 'Stories' },
|
||||
{ icon: Mic, label: 'Voices' },
|
||||
{ icon: Wand2, label: 'Effects' },
|
||||
{ icon: Speaker, label: 'Audio' },
|
||||
{ icon: Box, label: 'Models' },
|
||||
{ icon: Server, label: 'Server' },
|
||||
];
|
||||
|
||||
// ─── Phase system ───────────────────────────────────────────────────────────
|
||||
|
||||
type Phase = 'idle' | 'selecting' | 'typing' | 'generating' | 'complete' | 'playing';
|
||||
|
||||
const PHASE_DURATIONS: Record<Phase, number> = {
|
||||
idle: 2500,
|
||||
selecting: 800,
|
||||
typing: 6000,
|
||||
generating: 2800,
|
||||
complete: 1200,
|
||||
playing: 4000,
|
||||
};
|
||||
|
||||
// ─── Typewriter ─────────────────────────────────────────────────────────────
|
||||
|
||||
function TypewriterText({ text, speed }: { text: string; speed?: number }) {
|
||||
// Default: fill the typing phase duration, leaving 500ms buffer at the end
|
||||
const resolvedSpeed =
|
||||
speed ?? Math.max(20, Math.floor((PHASE_DURATIONS.typing - 500) / text.length));
|
||||
const [displayed, setDisplayed] = useState('');
|
||||
const indexRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
indexRef.current = 0;
|
||||
setDisplayed('');
|
||||
const interval = setInterval(() => {
|
||||
indexRef.current += 1;
|
||||
if (indexRef.current <= text.length) {
|
||||
setDisplayed(text.slice(0, indexRef.current));
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, resolvedSpeed);
|
||||
return () => clearInterval(interval);
|
||||
}, [text, resolvedSpeed]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{displayed}
|
||||
<span className="inline-block h-3.5 w-[2px] animate-pulse bg-foreground/70 ml-[1px] align-middle" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Loading bars (simplified react-loaders replacement) ────────────────────
|
||||
|
||||
function LoadingBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
|
||||
const barColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40';
|
||||
return (
|
||||
<div className="flex items-center gap-[2px] h-5">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<motion.div
|
||||
key={`${i}-${mode}`}
|
||||
className={`w-[3px] rounded-full ${barColor}`}
|
||||
animate={
|
||||
mode === 'generating'
|
||||
? { height: ['6px', '16px', '6px'] }
|
||||
: mode === 'playing'
|
||||
? { height: ['8px', '14px', '4px', '12px', '8px'] }
|
||||
: { height: '8px' }
|
||||
}
|
||||
transition={
|
||||
mode === 'generating'
|
||||
? { duration: 0.6, repeat: Infinity, delay: i * 0.08, ease: 'easeInOut' }
|
||||
: mode === 'playing'
|
||||
? { duration: 1.2, repeat: Infinity, delay: i * 0.15, ease: 'easeInOut' }
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Profile Card ───────────────────────────────────────────────────────────
|
||||
|
||||
const ProfileCard = ({
|
||||
profile,
|
||||
selected,
|
||||
selecting,
|
||||
cardRef,
|
||||
}: {
|
||||
profile: VoiceProfile;
|
||||
selected: boolean;
|
||||
selecting: boolean;
|
||||
cardRef?: React.Ref<HTMLDivElement>;
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
ref={cardRef}
|
||||
className={`rounded-xl border-2 bg-card p-3.5 flex flex-col h-[143px] transition-all duration-200 ${
|
||||
selected ? 'border-accent shadow-md' : 'border-border/50 hover:shadow-sm'
|
||||
} ${selecting && !selected ? 'opacity-60' : ''}`}
|
||||
animate={selecting && selected ? { scale: [1, 1.02, 1] } : {}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="text-[15px] font-bold leading-tight line-clamp-2">{profile.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground line-clamp-2 leading-relaxed mt-1">
|
||||
{profile.description}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-md border border-border text-muted-foreground">
|
||||
{profile.language}
|
||||
</span>
|
||||
{profile.hasEffects && <Sparkles className="h-3 w-3 text-accent fill-accent" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-auto justify-end">
|
||||
<Download className="h-3.5 w-3.5 text-muted-foreground/40" />
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground/40" />
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground/40" />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── History Row ────────────────────────────────────────────────────────────
|
||||
|
||||
function HistoryRow({
|
||||
gen,
|
||||
mode,
|
||||
isNew,
|
||||
}: {
|
||||
gen: Generation;
|
||||
mode: 'idle' | 'generating' | 'playing';
|
||||
isNew: boolean;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
className={`border rounded-md transition-colors text-left w-full ${
|
||||
mode === 'playing' ? 'bg-muted/70' : 'bg-card'
|
||||
}`}
|
||||
initial={isNew ? { opacity: 0, y: -8 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
>
|
||||
<div className="flex items-stretch gap-3 h-[80px] p-2.5">
|
||||
{/* Status icon */}
|
||||
<div className="w-8 flex items-center justify-center shrink-0">
|
||||
<LoadingBars mode={mode} />
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex flex-col gap-1 w-36 shrink-0 justify-center">
|
||||
<div className="text-[12px] font-medium truncate">{gen.profileName}</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<span>{gen.language}</span>
|
||||
<span>{gen.engine}</span>
|
||||
{mode !== 'generating' && <span>{gen.duration}</span>}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{mode === 'generating' ? (
|
||||
<span className="text-accent">Generating...</span>
|
||||
) : (
|
||||
gen.timeAgo
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript */}
|
||||
<div className="flex-1 min-w-0 flex items-center">
|
||||
<div className="text-[11px] text-muted-foreground line-clamp-3 leading-relaxed">
|
||||
{gen.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col justify-center items-center gap-0.5 shrink-0">
|
||||
<button className="h-5 w-5 flex items-center justify-center rounded-sm hover:bg-muted">
|
||||
<Star
|
||||
className={`h-2.5 w-2.5 ${
|
||||
gen.favorited ? 'text-accent fill-accent' : 'text-muted-foreground/50'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{gen.versions > 1 && (
|
||||
<button className="h-5 w-5 flex items-center justify-center rounded-sm hover:bg-muted">
|
||||
<AudioLines className="h-2.5 w-2.5 text-muted-foreground/50" />
|
||||
</button>
|
||||
)}
|
||||
<button className="h-5 w-5 flex items-center justify-center rounded-sm hover:bg-muted">
|
||||
<MoreHorizontal className="h-2.5 w-2.5 text-muted-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Floating Generate Box ──────────────────────────────────────────────────
|
||||
|
||||
function FloatingGenerateBox({
|
||||
phase,
|
||||
typingText,
|
||||
selectedProfile,
|
||||
engine,
|
||||
effect,
|
||||
}: {
|
||||
phase: Phase;
|
||||
typingText: string;
|
||||
selectedProfile: VoiceProfile | null;
|
||||
engine: string;
|
||||
effect?: string;
|
||||
}) {
|
||||
const isFocused = phase === 'typing' || phase === 'generating';
|
||||
const isGenerating = phase === 'generating';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[1.5rem] shadow-2xl p-2.5"
|
||||
animate={{
|
||||
borderColor: isGenerating
|
||||
? 'hsl(43 50% 45% / 0.35)'
|
||||
: isFocused
|
||||
? 'hsl(43 50% 45% / 0.25)'
|
||||
: 'hsl(43 50% 45% / 0.15)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Text area + generate button */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<motion.div
|
||||
className="overflow-hidden"
|
||||
animate={{ height: isFocused ? 100 : 32 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
>
|
||||
<div
|
||||
className="text-[12.5px] text-muted-foreground/60 px-2 py-1 leading-relaxed"
|
||||
style={{ minHeight: isFocused ? 100 : 32 }}
|
||||
>
|
||||
{phase === 'typing' ? (
|
||||
<span className="text-foreground">
|
||||
<TypewriterText text={typingText} />
|
||||
</span>
|
||||
) : phase === 'generating' ? (
|
||||
<span className="text-muted-foreground/40">{typingText}</span>
|
||||
) : (
|
||||
<span>
|
||||
{selectedProfile
|
||||
? `Generate speech using ${selectedProfile.name}...`
|
||||
: 'Select a voice profile above...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Generate button */}
|
||||
<button className="h-8 w-8 rounded-full bg-accent flex items-center justify-center shrink-0 shadow-lg">
|
||||
<Sparkles className="h-3.5 w-3.5 text-white fill-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom selectors */}
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground">
|
||||
English
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground">
|
||||
{engine}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] px-2 py-1 rounded-full border flex items-center gap-1 ${
|
||||
effect
|
||||
? 'border-accent/30 bg-accent/10 text-accent'
|
||||
: 'border-border bg-card text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className={`h-2.5 w-2.5 ${effect ? 'fill-accent' : ''}`} />
|
||||
{effect || 'Effect'}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main ControlUI ─────────────────────────────────────────────────────────
|
||||
|
||||
export function ControlUI() {
|
||||
const [phase, setPhase] = useState<Phase>('idle');
|
||||
const [selectedIndex, setSelectedIndex] = useState(DEMO_SCRIPT[0].profileIndex);
|
||||
const [cycle, setCycle] = useState(0);
|
||||
const [newGenId, setNewGenId] = useState<number | null>(null);
|
||||
const [generations, setGenerations] = useState<Generation[]>([...INITIAL_GENERATIONS]);
|
||||
const [isMuted, setIsMuted] = useState(true);
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [pageHidden, setPageHidden] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const phaseRef = useRef(phase);
|
||||
const mobileCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const desktopCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const profileGridRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
phaseRef.current = phase;
|
||||
|
||||
const step = DEMO_SCRIPT[cycle % DEMO_SCRIPT.length];
|
||||
const selectedProfile = PROFILES[selectedIndex];
|
||||
|
||||
// Scroll to selected profile card — accounts for generate box overlay on desktop
|
||||
useEffect(() => {
|
||||
const isMobile = window.innerWidth < 768;
|
||||
|
||||
if (isMobile) {
|
||||
const el = mobileCardRefs.current.get(selectedIndex);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop
|
||||
const el = desktopCardRefs.current.get(selectedIndex);
|
||||
const scrollContainer = profileGridRef.current;
|
||||
if (!el || !scrollContainer) return;
|
||||
|
||||
const containerTop = scrollContainer.getBoundingClientRect().top;
|
||||
const elTop = el.getBoundingClientRect().top;
|
||||
const elRelTop = elTop - containerTop + scrollContainer.scrollTop;
|
||||
|
||||
const rowHeight = 145;
|
||||
const generateBoxHeight = 200;
|
||||
const visibleTop = scrollContainer.scrollTop;
|
||||
const visibleBottom = visibleTop + scrollContainer.clientHeight - generateBoxHeight;
|
||||
const elRelBottom = elRelTop + el.offsetHeight;
|
||||
|
||||
if (elRelTop >= visibleTop && elRelBottom <= visibleBottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = elRelTop - rowHeight;
|
||||
scrollContainer.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Visibility detection
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting), {
|
||||
threshold: 0,
|
||||
});
|
||||
if (containerRef.current) observer.observe(containerRef.current);
|
||||
|
||||
const handleVisibility = () => setPageHidden(document.visibilityState !== 'visible');
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const paused = !isVisible || pageHidden;
|
||||
|
||||
// Phase cycling — `playing` phase is driven by audio finish, not a timeout
|
||||
useEffect(() => {
|
||||
if (paused || phase === 'playing') return;
|
||||
|
||||
const duration = PHASE_DURATIONS[phase];
|
||||
const timer = setTimeout(() => {
|
||||
console.log(
|
||||
'[ControlUI] phase transition',
|
||||
phase,
|
||||
'→ next, cycle:',
|
||||
cycle,
|
||||
'step profile:',
|
||||
PROFILES[step.profileIndex].name,
|
||||
);
|
||||
switch (phase) {
|
||||
case 'idle': {
|
||||
setSelectedIndex(step.profileIndex);
|
||||
setPhase('selecting');
|
||||
break;
|
||||
}
|
||||
case 'selecting':
|
||||
setPhase('typing');
|
||||
break;
|
||||
case 'typing': {
|
||||
const profile = PROFILES[step.profileIndex];
|
||||
const newGen: Generation = {
|
||||
id: Date.now(),
|
||||
profileName: profile.name,
|
||||
text: step.text,
|
||||
language: profile.language,
|
||||
engine: step.engine,
|
||||
duration: step.duration,
|
||||
timeAgo: 'just now',
|
||||
favorited: false,
|
||||
versions: 1,
|
||||
};
|
||||
setGenerations((prev) => [newGen, ...prev.slice(0, 5)]);
|
||||
setNewGenId(newGen.id);
|
||||
setPhase('generating');
|
||||
break;
|
||||
}
|
||||
case 'generating':
|
||||
setPhase('playing');
|
||||
break;
|
||||
}
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [phase, paused, step, cycle]);
|
||||
|
||||
const handleAudioFinish = useCallback(() => {
|
||||
if (phaseRef.current !== 'playing') return;
|
||||
setPhase('idle');
|
||||
setCycle((c) => c + 1);
|
||||
setNewGenId(null);
|
||||
}, []);
|
||||
|
||||
const isGenerating = phase === 'generating';
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative z-20 mx-auto w-full max-w-6xl px-6">
|
||||
{/* Unmute button with handwritten hint */}
|
||||
<div className="flex justify-end mb-3">
|
||||
<div className="relative">
|
||||
{/* Handwritten hint — absolutely positioned above the button */}
|
||||
{isMuted && (
|
||||
<motion.div
|
||||
className="absolute select-none pointer-events-none"
|
||||
style={{ top: -30, right: 100 }}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 2, duration: 0.6, ease: 'easeOut' }}
|
||||
>
|
||||
<span
|
||||
className="text-xl text-accent/80 whitespace-nowrap"
|
||||
style={{
|
||||
fontFamily: "'Caveat', 'Segoe Script', 'Comic Sans MS', cursive",
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
try me!
|
||||
</span>
|
||||
{/* Curved arrow from text down-right toward the button */}
|
||||
<svg
|
||||
width="22"
|
||||
height="11"
|
||||
viewBox="0 0 80 40"
|
||||
fill="none"
|
||||
className="text-accent/70 absolute"
|
||||
style={{ top: 14, left: 60 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<title>Arrow</title>
|
||||
<path
|
||||
d="M4 4 C20 4, 40 8, 55 20 C62 26, 66 32, 70 36"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M58 42 L70 36 L64 22"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
transform="rotate(35, 70, 36)"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
unlockAudioContext();
|
||||
setIsMuted(!isMuted);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-border bg-card/50 backdrop-blur text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isMuted ? (
|
||||
<>
|
||||
<Volume2 className="h-3.5 w-3.5" />
|
||||
<span>Unmute</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Volume2 className="h-3.5 w-3.5 text-accent" />
|
||||
<span>Mute</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-app-line bg-app-box shadow-[0_25px_60px_rgba(0,0,0,0.5),0_8px_20px_rgba(0,0,0,0.3)] md:h-[640px] pointer-events-none select-none">
|
||||
<div className="flex flex-col md:flex-row h-full">
|
||||
{/* ── Sidebar (hidden on mobile) ─────────────────────────── */}
|
||||
<div className="hidden md:flex w-16 shrink-0 border-r border-app-line bg-sidebar flex-col items-center py-4 gap-4">
|
||||
{/* Logo */}
|
||||
<div className="mb-1">
|
||||
<div
|
||||
className="w-9 h-9 rounded-lg overflow-hidden"
|
||||
style={{
|
||||
filter:
|
||||
'drop-shadow(0 0 6px hsl(43 50% 45% / 0.5)) drop-shadow(0 0 14px hsl(43 50% 45% / 0.35))',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/voicebox-logo-app.webp"
|
||||
alt=""
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{SIDEBAR_ITEMS.map((item, i) => {
|
||||
const Icon = item.icon;
|
||||
const active = i === 0;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-white/[0.07] text-foreground shadow-lg backdrop-blur-sm border border-white/[0.08]'
|
||||
: 'text-muted-foreground/60'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<div className="mt-auto text-[8px] text-muted-foreground/40">v0.2.0</div>
|
||||
</div>
|
||||
|
||||
{/* ── Main content ──────────────────────────────────────── */}
|
||||
<div className="flex-1 flex flex-col md:flex-row min-w-0 relative">
|
||||
{/* Left: Profiles + Generate box */}
|
||||
<div className="flex flex-col min-w-0 relative md:flex-1 md:overflow-hidden">
|
||||
{/* Gradient fade overlay — sits between header and scroll content */}
|
||||
<div className="hidden md:block absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-app-box to-transparent z-[1] pointer-events-none" />
|
||||
|
||||
{/* Header — floats above everything */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10 px-4 pt-4 md:pt-6 pb-2 flex items-center justify-between">
|
||||
<h2 className="text-base font-bold">Voicebox</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button className="h-6 text-[10px] px-2.5 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1">
|
||||
Import Voice
|
||||
</button>
|
||||
<button className="h-6 text-[10px] px-2.5 rounded-full bg-accent text-accent-foreground flex items-center">
|
||||
Create Voice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable profile cards — scrolls behind header + gradient */}
|
||||
<div
|
||||
ref={profileGridRef}
|
||||
className="flex-1 min-h-0 md:overflow-y-auto md:pt-14 pt-12"
|
||||
>
|
||||
<div className="px-4">
|
||||
{/* Mobile: horizontal scroll strip with edge fade */}
|
||||
<div className="relative md:hidden">
|
||||
{scrollLeft > 0 && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-6 bg-gradient-to-r from-app-box to-transparent z-10" />
|
||||
)}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-6 bg-gradient-to-l from-app-box to-transparent z-10" />
|
||||
<div
|
||||
className="flex gap-2 overflow-x-auto pb-2"
|
||||
onScroll={(e) => setScrollLeft(e.currentTarget.scrollLeft)}
|
||||
>
|
||||
{PROFILES.map((profile, i) => (
|
||||
<div
|
||||
key={profile.name}
|
||||
className="shrink-0 w-[140px]"
|
||||
ref={(el) => {
|
||||
if (el) mobileCardRefs.current.set(i, el);
|
||||
}}
|
||||
>
|
||||
<ProfileCard
|
||||
profile={profile}
|
||||
selected={i === selectedIndex}
|
||||
selecting={phase === 'selecting'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: 3-col grid */}
|
||||
<div className="hidden md:grid grid-cols-3 gap-2 mt-1 pb-44">
|
||||
{PROFILES.map((profile, i) => (
|
||||
<ProfileCard
|
||||
key={profile.name}
|
||||
profile={profile}
|
||||
selected={i === selectedIndex}
|
||||
selecting={phase === 'selecting'}
|
||||
cardRef={(el: HTMLDivElement | null) => {
|
||||
if (el) desktopCardRefs.current.set(i, el);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating generate box — desktop: absolute overlay, mobile: inline */}
|
||||
<div className="px-3 pt-2 pb-3 md:pt-0 md:absolute md:left-4 md:right-4 md:bottom-[117px] md:z-20 md:pb-0 md:px-0">
|
||||
<FloatingGenerateBox
|
||||
phase={phase}
|
||||
typingText={step.text}
|
||||
selectedProfile={selectedProfile}
|
||||
engine={step.engine}
|
||||
effect={step.effect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right/Below: History */}
|
||||
<div className="md:w-[48%] shrink-0 flex flex-col min-w-0 border-t md:border-t-0 border-app-line">
|
||||
<div className="max-h-[360px] md:max-h-none flex-1 overflow-hidden px-3 pt-3 md:pt-6 pb-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{generations.map((gen) => {
|
||||
const isThisNew = gen.id === newGenId;
|
||||
const rowMode: 'idle' | 'generating' | 'playing' =
|
||||
isThisNew && isGenerating
|
||||
? 'generating'
|
||||
: isThisNew && phase === 'playing'
|
||||
? 'playing'
|
||||
: 'idle';
|
||||
return <HistoryRow key={gen.id} gen={gen} mode={rowMode} isNew={isThisNew} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audio player */}
|
||||
<LandingAudioPlayer
|
||||
audioUrl={step.audioUrl}
|
||||
title={selectedProfile.name}
|
||||
playing={phase === 'playing'}
|
||||
muted={isMuted}
|
||||
onFinish={handleAudioFinish}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||