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 |
@@ -8,7 +8,8 @@ tauri/
|
||||
landing/
|
||||
docs/
|
||||
mlx-test/
|
||||
scripts/
|
||||
scripts/*
|
||||
!scripts/rocm-entrypoint.sh
|
||||
|
||||
# Dependencies & build artifacts (rebuilt in Docker)
|
||||
node_modules/
|
||||
|
||||
@@ -91,7 +91,7 @@ On Windows, to build with CUDA support for local testing:
|
||||
just build-local # Build CPU + CUDA server binaries + Tauri installer
|
||||
```
|
||||
|
||||
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/com.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
|
||||
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/sh.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
|
||||
|
||||
Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src-tauri/target/release/bundle/`.
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -21,7 +21,7 @@ COPY app/ ./app/
|
||||
COPY web/ ./web/
|
||||
|
||||
# Strip workspaces not needed for web build, and fix trailing comma
|
||||
RUN sed -i '/"tauri"/d; /"landing"/d' package.json && \
|
||||
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)
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -38,6 +38,13 @@ logging.basicConfig(
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# An empty HSA_OVERRIDE_GFX_VERSION poisons the ROCm HSA runtime. It is
|
||||
# treated as "force-empty" and no GPU is detected, even natively supported
|
||||
# ones (e.g. gfx1201 / RX 9070 on ROCm 7.2). docker-compose can't
|
||||
# conditionally omit an env var, so we clean it up here before torch loads.
|
||||
if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
|
||||
os.environ.pop("HSA_OVERRIDE_GFX_VERSION", None)
|
||||
|
||||
# AMD GPU environment variables must be set before torch import
|
||||
# Only set HSA_OVERRIDE_GFX_VERSION for older GPUs that need it.
|
||||
# RDNA 3+ (gfx1100+) and RDNA 4 (gfx1200+) are natively supported by ROCm
|
||||
@@ -353,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")
|
||||
|
||||
|
||||
@@ -547,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
|
||||
@@ -555,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
|
||||
|
||||
@@ -563,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
|
||||
|
||||
@@ -571,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
|
||||
|
||||
@@ -579,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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -330,6 +330,9 @@ def build_server(cuda=False, rocm=False):
|
||||
]
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
args.extend(["--hidden-import", "audioop"])
|
||||
|
||||
# Add CUDA/ROCm-specific hidden imports
|
||||
if cuda or rocm:
|
||||
variant = "ROCm" if rocm else "CUDA"
|
||||
|
||||
@@ -80,6 +80,11 @@ def resolve_storage_path(path: str | Path | None) -> Path | None:
|
||||
return None
|
||||
|
||||
stored_path = Path(path)
|
||||
# Empty paths (e.g. failed generations) must not resolve to the data
|
||||
# dir itself, which exists and would defeat the callers' 404 guards.
|
||||
# Path("") is truthy, so check parts rather than the raw value.
|
||||
if not stored_path.parts:
|
||||
return None
|
||||
if stored_path.is_absolute():
|
||||
rebased_path = _path_relative_to_any_data_dir(stored_path)
|
||||
if rebased_path is not None:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ miniaudio>=1.59
|
||||
# mlx_audio.stt.load) works fine on transformers 4.57.x in practice.
|
||||
#
|
||||
# Install it via `pip install --no-deps mlx-audio==0.4.1` after this file
|
||||
# (see .github/workflows/release.yml). Most other mlx-audio runtime deps
|
||||
# (see .github/workflows/release.yml and the setup-python recipe in the
|
||||
# justfile). Most other mlx-audio runtime deps
|
||||
# (huggingface_hub, librosa, mlx-lm, numba, numpy, protobuf, pyloudnorm,
|
||||
# sounddevice, tqdm) are already in requirements.txt or pulled in by
|
||||
# other engines.
|
||||
|
||||
@@ -53,6 +53,7 @@ en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_
|
||||
unidic-lite>=1.0.8
|
||||
|
||||
# Audio processing
|
||||
audioop-lts>=0.2.1; python_version >= "3.13"
|
||||
librosa>=0.10.0
|
||||
soundfile>=0.12.0
|
||||
numpy>=1.24.0,<2.0
|
||||
|
||||
@@ -34,7 +34,7 @@ async def get_version_audio(version_id: str, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=404, detail="Version not found")
|
||||
|
||||
audio_path = config.resolve_storage_path(version.audio_path)
|
||||
if audio_path is None or not audio_path.exists():
|
||||
if audio_path is None or not audio_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
|
||||
return FileResponse(
|
||||
@@ -52,8 +52,13 @@ async def get_audio(generation_id: str, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=404, detail="Generation not found")
|
||||
|
||||
audio_path = config.resolve_storage_path(generation.audio_path)
|
||||
if audio_path is None or not audio_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
if audio_path is None or not audio_path.is_file():
|
||||
detail = (
|
||||
"Generation failed; no audio available"
|
||||
if generation.status == "failed"
|
||||
else "Audio file not found"
|
||||
)
|
||||
raise HTTPException(status_code=404, detail=detail)
|
||||
|
||||
return FileResponse(
|
||||
audio_path,
|
||||
@@ -72,7 +77,7 @@ async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=404, detail="Sample not found")
|
||||
|
||||
audio_path = config.resolve_storage_path(sample.audio_path)
|
||||
if audio_path is None or not audio_path.exists():
|
||||
if audio_path is None or not audio_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
|
||||
return FileResponse(
|
||||
|
||||
@@ -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("/", "--"))
|
||||
|
||||
@@ -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()
|
||||
@@ -125,12 +125,24 @@ async def list_stories(
|
||||
"""
|
||||
stories = db.query(DBStory).order_by(DBStory.updated_at.desc()).all()
|
||||
|
||||
if not stories:
|
||||
return []
|
||||
|
||||
# Batch-fetch all story item counts in one query to avoid an N+1 pattern
|
||||
# (previously there was one COUNT query per story in the loop below).
|
||||
story_ids = [s.id for s in stories]
|
||||
count_rows = (
|
||||
db.query(DBStoryItem.story_id, func.count(DBStoryItem.id).label("cnt"))
|
||||
.filter(DBStoryItem.story_id.in_(story_ids))
|
||||
.group_by(DBStoryItem.story_id)
|
||||
.all()
|
||||
)
|
||||
item_counts = {row.story_id: row.cnt for row in count_rows}
|
||||
|
||||
result = []
|
||||
for story in stories:
|
||||
item_count = db.query(func.count(DBStoryItem.id)).filter(DBStoryItem.story_id == story.id).scalar()
|
||||
|
||||
response = StoryResponse.model_validate(story)
|
||||
response.item_count = item_count
|
||||
response.item_count = item_counts.get(story.id, 0)
|
||||
result.append(response)
|
||||
|
||||
return result
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Regression tests for GET /audio/{generation_id} on failed generations.
|
||||
|
||||
A failed generation stores an empty ``audio_path``. Previously,
|
||||
``config.resolve_storage_path("")`` resolved to the data directory itself,
|
||||
which exists, so the route's 404 guard passed and ``FileResponse`` raised
|
||||
``RuntimeError: File at path .../data is not a file`` — a 500 instead of
|
||||
a clean 404.
|
||||
|
||||
Usage:
|
||||
python -m pytest backend/tests/test_audio_failed_generation.py -v
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
# Repo root on sys.path so ``backend`` imports as a package (the audio
|
||||
# routes use package-relative imports).
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from backend import config
|
||||
from backend.database import (
|
||||
Base,
|
||||
Generation,
|
||||
GenerationVersion,
|
||||
ProfileSample,
|
||||
VoiceProfile,
|
||||
get_db,
|
||||
)
|
||||
from backend.routes.audio import router as audio_router
|
||||
|
||||
|
||||
def test_resolve_storage_path_empty_returns_none():
|
||||
"""An empty stored path must not resolve to the data dir itself."""
|
||||
assert config.resolve_storage_path("") is None
|
||||
assert config.resolve_storage_path(None) is None
|
||||
# Path("") is truthy, so it must be rejected via its (empty) parts.
|
||||
assert config.resolve_storage_path(Path("")) is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
"""Minimal app with only the audio routes and a temp sqlite DB."""
|
||||
monkeypatch.setattr(config, "_data_dir", tmp_path)
|
||||
# An existing directory that a stored audio_path may wrongly point to.
|
||||
(tmp_path / "somedir").mkdir()
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'test.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
testing_session_local = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
session = testing_session_local()
|
||||
profile = VoiceProfile(id="profile-1", name="Test Profile")
|
||||
session.add(profile)
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
Generation(
|
||||
id="gen-failed-empty",
|
||||
profile_id="profile-1",
|
||||
text="failed generation",
|
||||
audio_path="",
|
||||
status="failed",
|
||||
error="engine exploded",
|
||||
),
|
||||
Generation(
|
||||
id="gen-failed-null",
|
||||
profile_id="profile-1",
|
||||
text="failed generation",
|
||||
audio_path=None,
|
||||
status="failed",
|
||||
),
|
||||
Generation(
|
||||
id="gen-missing-file",
|
||||
profile_id="profile-1",
|
||||
text="completed but file deleted",
|
||||
audio_path="generations/does-not-exist.wav",
|
||||
status="completed",
|
||||
),
|
||||
Generation(
|
||||
id="gen-with-version",
|
||||
profile_id="profile-1",
|
||||
text="generation with a broken version",
|
||||
audio_path="somedir",
|
||||
status="completed",
|
||||
),
|
||||
GenerationVersion(
|
||||
id="version-dir",
|
||||
generation_id="gen-with-version",
|
||||
label="original",
|
||||
audio_path="somedir",
|
||||
),
|
||||
ProfileSample(
|
||||
id="sample-dir",
|
||||
profile_id="profile-1",
|
||||
audio_path="somedir",
|
||||
reference_text="sample pointing at a directory",
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(audio_router)
|
||||
|
||||
def override_get_db():
|
||||
db = testing_session_local()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("generation_id", ["gen-failed-empty", "gen-failed-null"])
|
||||
def test_failed_generation_returns_404(client, generation_id):
|
||||
"""Failed generations (empty/null audio_path) get a clean 404, not a 500."""
|
||||
response = client.get(f"/audio/{generation_id}")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Generation failed; no audio available"
|
||||
|
||||
|
||||
def test_missing_audio_file_returns_404(client):
|
||||
"""A completed generation whose file vanished still 404s."""
|
||||
response = client.get("/audio/gen-missing-file")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Audio file not found"
|
||||
|
||||
|
||||
def test_unknown_generation_returns_404(client):
|
||||
response = client.get("/audio/no-such-generation")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Generation not found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"/audio/gen-with-version",
|
||||
"/audio/version/version-dir",
|
||||
"/samples/sample-dir",
|
||||
],
|
||||
)
|
||||
def test_audio_path_pointing_at_directory_returns_404(client, url):
|
||||
"""A stored path resolving to an existing directory must 404, not 500.
|
||||
|
||||
Guards the is_file() checks: a directory passes exists() and would
|
||||
crash FileResponse.
|
||||
"""
|
||||
response = client.get(url)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Audio file not found"
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Regression tests for issue #852: audioop removed from Python 3.13 stdlib.
|
||||
|
||||
Voice sample validation imports audioop transitively (librosa → audioread).
|
||||
The audioop-lts backport must be declared in requirements and bundled in
|
||||
PyInstaller builds on 3.13+.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from build_binary import build_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend_dir():
|
||||
return Path(__file__).parent.parent
|
||||
|
||||
|
||||
class TestAudioopRequirements:
|
||||
def test_requirements_declare_audioop_lts_for_python_313(self, backend_dir):
|
||||
content = (backend_dir / "requirements.txt").read_text()
|
||||
assert re.search(
|
||||
r"^audioop-lts.*python_version\s*>=\s*['\"]3\.13['\"]",
|
||||
content,
|
||||
re.MULTILINE,
|
||||
), "requirements.txt must pin audioop-lts for Python 3.13+"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 13), reason="Python 3.13+ only")
|
||||
class TestAudioopRuntime:
|
||||
def test_audioop_importable(self):
|
||||
import audioop # noqa: F401
|
||||
|
||||
def test_validate_reference_wav_does_not_fail_on_missing_audioop(self, tmp_path):
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from utils.audio import validate_and_load_reference_audio
|
||||
|
||||
sr = 24000
|
||||
t = np.arange(int(sr * 3), dtype=np.float32) / sr
|
||||
audio = (0.3 * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
|
||||
path = tmp_path / "reference.wav"
|
||||
sf.write(str(path), audio, sr)
|
||||
|
||||
ok, err, out_audio, out_sr = validate_and_load_reference_audio(str(path))
|
||||
|
||||
assert ok, err
|
||||
assert out_audio is not None
|
||||
assert out_sr == sr
|
||||
assert "audioop" not in (err or "").lower()
|
||||
|
||||
|
||||
class TestAudioopBuildArgs:
|
||||
@staticmethod
|
||||
def _hidden_imports(args):
|
||||
imports = []
|
||||
for i, arg in enumerate(args):
|
||||
if arg == "--hidden-import" and i + 1 < len(args):
|
||||
imports.append(args[i + 1])
|
||||
return imports
|
||||
|
||||
def test_pyinstaller_includes_audioop_on_python_313(self):
|
||||
class FakeVersionInfo(tuple):
|
||||
@property
|
||||
def major(self):
|
||||
return self[0]
|
||||
|
||||
@property
|
||||
def minor(self):
|
||||
return self[1]
|
||||
|
||||
@property
|
||||
def micro(self):
|
||||
return self[2]
|
||||
|
||||
fake_313 = FakeVersionInfo((3, 13, 0, "final", 0))
|
||||
|
||||
with (
|
||||
patch("build_binary.PyInstaller.__main__.run") as mock_run,
|
||||
patch("build_binary.platform.system", return_value="Linux"),
|
||||
patch("build_binary.is_apple_silicon", return_value=False),
|
||||
patch("build_binary.os.chdir"),
|
||||
patch("build_binary.sys.version_info", fake_313),
|
||||
):
|
||||
build_server()
|
||||
args = mock_run.call_args[0][0]
|
||||
|
||||
assert "audioop" in self._hidden_imports(args)
|
||||
|
||||
def test_pyinstaller_omits_audioop_on_python_312(self):
|
||||
class FakeVersionInfo(tuple):
|
||||
@property
|
||||
def major(self):
|
||||
return self[0]
|
||||
|
||||
@property
|
||||
def minor(self):
|
||||
return self[1]
|
||||
|
||||
@property
|
||||
def micro(self):
|
||||
return self[2]
|
||||
|
||||
fake_312 = FakeVersionInfo((3, 12, 0, "final", 0))
|
||||
|
||||
with (
|
||||
patch("build_binary.PyInstaller.__main__.run") as mock_run,
|
||||
patch("build_binary.platform.system", return_value="Linux"),
|
||||
patch("build_binary.is_apple_silicon", return_value=False),
|
||||
patch("build_binary.os.chdir"),
|
||||
patch("build_binary.sys.version_info", fake_312),
|
||||
):
|
||||
build_server()
|
||||
args = mock_run.call_args[0][0]
|
||||
|
||||
assert "audioop" not in self._hidden_imports(args)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Smoke test for the MLX backend dependencies on Apple Silicon.
|
||||
|
||||
Guards the `--no-deps` install of mlx-audio/mlx-lm done by `just setup-python`
|
||||
and release.yml: those packages skip their declared dependencies (transformers
|
||||
>=5.x conflict), so a missing transitive dep only surfaces at import time.
|
||||
This test fails fast if the MLX STT/TTS entry points the backend uses stop
|
||||
importing (e.g. the `miniaudio` regression from issue #505).
|
||||
|
||||
Usage:
|
||||
python -m pytest backend/tests/test_mlx_smoke.py -v
|
||||
"""
|
||||
|
||||
import platform
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (sys.platform == "darwin" and platform.machine() == "arm64"),
|
||||
reason="MLX packages are only installed on Apple Silicon macOS",
|
||||
)
|
||||
|
||||
|
||||
def test_mlx_core_runs():
|
||||
"""The MLX runtime itself works (Metal array op)."""
|
||||
import mlx.core as mx
|
||||
|
||||
assert mx.array([1, 2]).sum().item() == 3
|
||||
|
||||
|
||||
def test_mlx_audio_tts_entry_point():
|
||||
"""`from mlx_audio.tts import load` — used by MLXBackend.load_model_async."""
|
||||
from mlx_audio.tts import load
|
||||
|
||||
assert callable(load)
|
||||
|
||||
|
||||
def test_mlx_audio_stt_entry_point():
|
||||
"""`from mlx_audio.stt import load` — used by the Whisper MLX STT path.
|
||||
|
||||
Importing mlx_audio.stt also pulls in miniaudio, so this catches the
|
||||
ModuleNotFoundError from issue #505 on fresh installs.
|
||||
"""
|
||||
from mlx_audio.stt import load
|
||||
|
||||
assert callable(load)
|
||||
|
||||
|
||||
def test_mlx_lm_entry_points():
|
||||
"""`mlx_lm.load` / `mlx_lm.generate` — used by qwen_llm_backend."""
|
||||
from mlx_lm import generate, load
|
||||
|
||||
assert callable(load)
|
||||
assert callable(generate)
|
||||
@@ -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
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
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 |
@@ -72,6 +72,12 @@ setup-python:
|
||||
if [ "$(uname -m)" = "arm64" ] && [ "$(uname)" = "Darwin" ]; then
|
||||
echo "Detected Apple Silicon — installing MLX dependencies..."
|
||||
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
|
||||
# mlx-lm and mlx-audio declare transformers>=5.x, which conflicts with
|
||||
# our transformers<=4.57.x cap, so install them --no-deps (their other
|
||||
# runtime deps are covered by requirements.txt / requirements-mlx.txt —
|
||||
# see the note in requirements-mlx.txt and .github/workflows/release.yml)
|
||||
{{ pip }} install --no-deps mlx-lm==0.31.1
|
||||
{{ pip }} install --no-deps mlx-audio==0.4.1
|
||||
fi
|
||||
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
{{ pip }} install pyinstaller ruff pytest pytest-asyncio -q
|
||||
@@ -89,10 +95,10 @@ setup-python:
|
||||
}
|
||||
Write-Host "Installing Python dependencies..."
|
||||
& "{{ python }}" -m pip install --upgrade pip -q
|
||||
$gpus = Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name
|
||||
Write-Host "Detected GPUs: $($gpus -join ', ')"
|
||||
$hasNvidia = ($gpus | Where-Object { $_ -match 'NVIDIA' }).Count -gt 0
|
||||
$hasIntelArc = ($gpus | Where-Object { $_ -match 'Arc' }).Count -gt 0
|
||||
$gpus = Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name; \
|
||||
Write-Host "Detected GPUs: $($gpus -join ', ')"; \
|
||||
$hasNvidia = ($gpus | Where-Object { $_ -match 'NVIDIA' }).Count -gt 0; \
|
||||
$hasIntelArc = ($gpus | Where-Object { $_ -match 'Arc' }).Count -gt 0; \
|
||||
if ($hasNvidia) { \
|
||||
Write-Host "NVIDIA GPU detected — installing PyTorch with CUDA support..."; \
|
||||
& "{{ pip }}" install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128; \
|
||||
@@ -226,12 +232,16 @@ build-server: _ensure-venv
|
||||
build-server: _ensure-venv
|
||||
$ErrorActionPreference = "Stop"; \
|
||||
$env:PATH = "{{ venv_bin }};$env:PATH"; \
|
||||
& "{{ python }}" backend/build_binary.py; \
|
||||
if ($LASTEXITCODE -ne 0) { throw "build_binary.py failed with exit code $LASTEXITCODE" }; \
|
||||
$triple = (rustc --print host-tuple); \
|
||||
New-Item -ItemType Directory -Path "{{ tauri_dir }}/src-tauri/binaries" -Force | Out-Null; \
|
||||
& "{{ python }}" backend/build_binary.py; \
|
||||
if ($LASTEXITCODE -ne 0) { throw "build_binary.py failed with exit code $LASTEXITCODE" }; \
|
||||
Copy-Item "backend/dist/voicebox-server.exe" "{{ tauri_dir }}/src-tauri/binaries/voicebox-server-$triple.exe" -Force; \
|
||||
Write-Host "Copied sidecar: voicebox-server-$triple.exe"
|
||||
Write-Host "Copied sidecar: voicebox-server-$triple.exe"; \
|
||||
& "{{ python }}" backend/build_binary.py --shim; \
|
||||
if ($LASTEXITCODE -ne 0) { throw "build_binary.py --shim failed with exit code $LASTEXITCODE" }; \
|
||||
Copy-Item "backend/dist/voicebox-mcp.exe" "{{ tauri_dir }}/src-tauri/binaries/voicebox-mcp-$triple.exe" -Force; \
|
||||
Write-Host "Copied sidecar: voicebox-mcp-$triple.exe"
|
||||
|
||||
# Build CUDA server binary and place in app data dir for local testing
|
||||
[windows]
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
/** Compact, copyable contract address — short display, full value to clipboard. */
|
||||
export function CopyAddress({ address }: { address: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard unavailable (e.g. insecure context) — silently no-op.
|
||||
}
|
||||
};
|
||||
|
||||
const short = `${address.slice(0, 4)}…${address.slice(-4)}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title={address}
|
||||
aria-label={copied ? 'Contract address copied' : 'Copy contract address'}
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-2.5 py-1.5 font-mono text-xs text-muted-foreground transition-colors hover:border-border hover:text-foreground"
|
||||
>
|
||||
<span>{short}</span>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-accent" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 opacity-60 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Download, Laptop, Monitor, Terminal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { DOWNLOAD_LINKS, LATEST_VERSION } from '@/lib/constants';
|
||||
|
||||
export function DownloadSection() {
|
||||
const downloads = [
|
||||
{
|
||||
platform: 'Mac',
|
||||
icon: Laptop,
|
||||
link: DOWNLOAD_LINKS.macArm,
|
||||
description: 'macOS (Intel + Apple Silicon)',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
platform: 'Windows',
|
||||
icon: Monitor,
|
||||
link: DOWNLOAD_LINKS.windows,
|
||||
description: 'Windows x64',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
platform: 'Linux',
|
||||
icon: Terminal,
|
||||
link: DOWNLOAD_LINKS.linux,
|
||||
description: 'Linux AppImage',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground mb-2">Latest Version</p>
|
||||
<p className="text-2xl font-bold">{LATEST_VERSION}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
|
||||
{downloads.map(({ platform, icon: Icon, link, description, disabled }) => (
|
||||
<Card
|
||||
key={platform}
|
||||
className={`transition-all duration-200 ${
|
||||
disabled
|
||||
? 'opacity-50'
|
||||
: 'hover:border-primary/20 hover:shadow-lg hover:shadow-primary/3 hover:-translate-y-0.5'
|
||||
}`}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center text-center space-y-4">
|
||||
<div className="p-3 rounded-xl bg-muted/50 backdrop-blur-sm border border-border">
|
||||
<Icon className="h-8 w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-1">{platform}</h3>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Button asChild size="lg" className="w-full" disabled={disabled}>
|
||||
<a href={link} download className={disabled ? 'pointer-events-none' : ''}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,855 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AudioLines, Cloud, MessageSquareText, Mic, Sparkles, TextCursorInput } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// ─── Lazy load wrapper ──────────────────────────────────────────────────────
|
||||
|
||||
function LazyLoad({
|
||||
children,
|
||||
className,
|
||||
rootMargin = '200px',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
rootMargin?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [rootMargin]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
{visible ? children : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Animation: Voice Cloning ───────────────────────────────────────────────
|
||||
|
||||
function VoiceCloningAnimation() {
|
||||
const [phase, setPhase] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setPhase((p) => (p + 1) % 3);
|
||||
}, 2400);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const samples = ['Sample 1', 'Sample 2', 'Sample 3'];
|
||||
const bars = [0.4, 0.7, 0.5, 0.9, 0.3, 0.6, 0.8, 0.4, 0.7, 0.5, 0.3, 0.6];
|
||||
|
||||
return (
|
||||
<div className="h-40 w-full flex items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4">
|
||||
<div className="flex flex-col items-center gap-3 w-full max-w-[200px]">
|
||||
{/* Sample pills */}
|
||||
<div className="flex gap-1.5">
|
||||
{samples.map((s, i) => (
|
||||
<motion.div
|
||||
key={s}
|
||||
className="text-[9px] px-2 py-1 rounded-full border font-medium"
|
||||
animate={{
|
||||
borderColor: i === phase ? 'hsl(43 50% 45% / 0.5)' : 'rgba(255,255,255,0.06)',
|
||||
backgroundColor: i === phase ? 'hsl(43 50% 45% / 0.08)' : 'rgba(255,255,255,0.02)',
|
||||
color: i === phase ? 'hsl(43 50% 45%)' : 'rgba(255,255,255,0.4)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{s}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Waveform visualization */}
|
||||
<div className="flex items-center gap-[2px] h-10 w-full justify-center">
|
||||
{bars.map((h, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="w-[4px] rounded-full"
|
||||
animate={{
|
||||
height: `${h * 100}%`,
|
||||
backgroundColor: phase === 2 ? 'hsl(43 50% 45%)' : 'rgba(255,255,255,0.15)',
|
||||
}}
|
||||
transition={{
|
||||
height: { duration: 0.6, delay: i * 0.04, ease: 'easeInOut' },
|
||||
backgroundColor: { duration: 0.3 },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Result label */}
|
||||
<motion.div
|
||||
className="text-[9px] font-mono"
|
||||
animate={{
|
||||
opacity: phase === 2 ? 1 : 0.3,
|
||||
color: phase === 2 ? 'hsl(43 50% 45%)' : 'rgba(255,255,255,0.3)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
voice profile ready
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Mini waveform for clips ────────────────────────────────────────────────
|
||||
// Fixed-width dense waveform that overflows — the clip container clips it.
|
||||
// This way resizing a clip just reveals/hides bars instead of re-rendering.
|
||||
|
||||
const WAVEFORM_BAR_COUNT = 60;
|
||||
|
||||
function MiniWaveform({ seed, color }: { seed: number; color: string }) {
|
||||
// Deterministic pseudo-random waveform that looks like real speech audio.
|
||||
// Uses layered noise at different frequencies for natural envelope + detail.
|
||||
const bars = useMemo(() => {
|
||||
// Seeded pseudo-random number generator (deterministic per seed)
|
||||
let s = seed * 9301 + 49297;
|
||||
const rand = () => {
|
||||
s = (s * 16807 + 0) % 2147483647;
|
||||
return s / 2147483647;
|
||||
};
|
||||
|
||||
// Pre-generate random values
|
||||
const r = Array.from({ length: WAVEFORM_BAR_COUNT }, () => rand());
|
||||
|
||||
return Array.from({ length: WAVEFORM_BAR_COUNT }, (_, i) => {
|
||||
const t = i / WAVEFORM_BAR_COUNT;
|
||||
|
||||
// Slow envelope — broad amplitude shape (words / phrases)
|
||||
const envelope =
|
||||
0.3 +
|
||||
0.35 *
|
||||
Math.sin(t * Math.PI * (2 + (seed % 3))) *
|
||||
Math.sin(t * Math.PI * (1.3 + seed * 0.7)) +
|
||||
0.2 * Math.sin(t * Math.PI * (4.7 + seed * 1.3));
|
||||
|
||||
// Medium variation — syllable-level bumps
|
||||
const mid = 0.15 * Math.sin(i * 0.8 + seed * 3.1) * Math.cos(i * 1.3 + seed);
|
||||
|
||||
// High-frequency noise — individual sample jitter
|
||||
const noise = (r[i] - 0.5) * 0.25;
|
||||
|
||||
// Combine and clamp
|
||||
const raw = envelope + mid + noise;
|
||||
return Math.max(0.06, Math.min(1, raw));
|
||||
});
|
||||
}, [seed]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-full overflow-hidden">
|
||||
{bars.map((h, i) => (
|
||||
<div
|
||||
key={`w-${seed}-${i}`}
|
||||
className="shrink-0 rounded-full opacity-50"
|
||||
style={{
|
||||
width: 2,
|
||||
marginRight: 1,
|
||||
height: `${h * 100}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Animation: Stories Editor ───────────────────────────────────────────────
|
||||
|
||||
// Clip shape: id, profile, track, left (px out of 220), width (px), waveform seed
|
||||
type DemoClip = { id: string; profile: string; track: number; x: number; w: number; seed: number };
|
||||
|
||||
const INITIAL_CLIPS: DemoClip[] = [
|
||||
{ id: 'n1', profile: 'Morgan', track: 0, x: 4, w: 70, seed: 1 },
|
||||
{ id: 'n2', profile: 'Morgan', track: 0, x: 135, w: 35, seed: 2 },
|
||||
{ id: 'a1', profile: 'Scarlett', track: 1, x: 25, w: 40, seed: 3 },
|
||||
{ id: 'a2', profile: 'Scarlett', track: 1, x: 120, w: 35, seed: 4 },
|
||||
{ id: 'b1', profile: 'Jarvis', track: 2, x: 70, w: 45, seed: 5 },
|
||||
];
|
||||
|
||||
// Timeline width the clips live inside
|
||||
const TL_W = 220;
|
||||
// Each action returns a new clips array (or modifies in place)
|
||||
type Action = { label: string; apply: (clips: DemoClip[]) => DemoClip[] };
|
||||
|
||||
const ACTIONS: Action[] = [
|
||||
// 0 — move Jarvis clip earlier
|
||||
{ label: 'Move clip', apply: (c) => c.map((cl) => (cl.id === 'b1' ? { ...cl, x: 55 } : cl)) },
|
||||
// 1 — split Morgan's first clip into two with visible gap
|
||||
{
|
||||
label: 'Split clip',
|
||||
apply: (c) => {
|
||||
// Idempotent: if n1b already exists, the split already happened
|
||||
if (c.some((cl) => cl.id === 'n1b')) return c;
|
||||
const clip = c.find((cl) => cl.id === 'n1');
|
||||
if (!clip) return c;
|
||||
const leftW = 25;
|
||||
const gap = 8;
|
||||
const rightW = clip.w - leftW - gap;
|
||||
return [
|
||||
...c.filter((cl) => cl.id !== 'n1'),
|
||||
{ ...clip, w: leftW, id: 'n1' },
|
||||
{
|
||||
id: 'n1b',
|
||||
profile: clip.profile,
|
||||
track: clip.track,
|
||||
x: clip.x + leftW + gap,
|
||||
w: rightW,
|
||||
seed: 6,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
// 2 — trim Scarlett's second clip shorter
|
||||
{ label: 'Trim clip', apply: (c) => c.map((cl) => (cl.id === 'a2' ? { ...cl, w: 25 } : cl)) },
|
||||
// 3 — duplicate Jarvis to track 0
|
||||
{
|
||||
label: 'Duplicate',
|
||||
apply: (c) => {
|
||||
// Idempotent: if b1d already exists, the duplicate already happened
|
||||
if (c.some((cl) => cl.id === 'b1d')) return c;
|
||||
const clip = c.find((cl) => cl.id === 'b1');
|
||||
if (!clip) return c;
|
||||
return [...c, { ...clip, id: 'b1d', track: 0, x: 180, w: 35, seed: 7 }];
|
||||
},
|
||||
},
|
||||
// 4 — reset
|
||||
{ label: '', apply: () => INITIAL_CLIPS },
|
||||
];
|
||||
|
||||
function StoriesAnimation() {
|
||||
const [clips, setClips] = useState<DemoClip[]>(INITIAL_CLIPS);
|
||||
const [actionIndex, setActionIndex] = useState(-1);
|
||||
const [playheadX, setPlayheadX] = useState(0);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const playheadRef = useRef<ReturnType<typeof requestAnimationFrame>>(0);
|
||||
|
||||
// Animate the playhead continuously
|
||||
useEffect(() => {
|
||||
let start: number | null = null;
|
||||
const speed = 12; // px per second
|
||||
const animate = (ts: number) => {
|
||||
if (start === null) start = ts;
|
||||
const elapsed = (ts - start) / 1000;
|
||||
setPlayheadX((elapsed * speed) % TL_W);
|
||||
playheadRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
playheadRef.current = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(playheadRef.current);
|
||||
}, []);
|
||||
|
||||
// Step through actions
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setActionIndex((prev) => {
|
||||
const next = (prev + 1) % ACTIONS.length;
|
||||
setClips((current) => ACTIONS[next].apply(current));
|
||||
// Highlight the clip being acted on
|
||||
if (next === 0) setSelectedId('b1');
|
||||
else if (next === 1) setSelectedId('n1');
|
||||
else if (next === 2) setSelectedId('a2');
|
||||
else if (next === 3) setSelectedId('b1');
|
||||
else setSelectedId(null);
|
||||
return next;
|
||||
});
|
||||
}, 2600);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const trackLabels = ['1', '0', '-1'];
|
||||
const timeMarkers = [0, 2, 4, 6, 8];
|
||||
const accentColor = 'hsl(43 50% 45%)';
|
||||
const accentFg = 'hsl(30 10% 94%)';
|
||||
|
||||
return (
|
||||
<div className="h-40 w-full flex flex-col overflow-hidden rounded-md bg-app-darkerBox/50">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-app-line bg-app-darkBox/60 shrink-0">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-ink-faint/40" />
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-4 rounded flex items-center justify-center bg-app-button">
|
||||
<div className="border-l-[4px] border-l-ink-faint border-t-[3px] border-t-transparent border-b-[3px] border-b-transparent ml-0.5" />
|
||||
</div>
|
||||
<div className="w-4 h-4 rounded flex items-center justify-center bg-app-button">
|
||||
<div className="w-2 h-2 rounded-sm bg-ink-faint/60" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[8px] text-ink-faint font-mono ml-1 tabular-nums">0:03 / 0:10</span>
|
||||
<div className="flex-1" />
|
||||
{actionIndex >= 0 && actionIndex < ACTIONS.length - 1 && (
|
||||
<motion.span
|
||||
key={actionIndex}
|
||||
className="text-[7px] font-medium px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${accentColor.replace(')', ' / 0.15)')}`,
|
||||
color: accentColor,
|
||||
}}
|
||||
initial={{ opacity: 0, y: 3 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
{ACTIONS[actionIndex].label}
|
||||
</motion.span>
|
||||
)}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-[7px] text-ink-faint">Zoom</span>
|
||||
<div className="w-3 h-3 rounded flex items-center justify-center bg-app-button text-[8px] text-ink-faint">
|
||||
-
|
||||
</div>
|
||||
<div className="w-3 h-3 rounded flex items-center justify-center bg-app-button text-[8px] text-ink-faint">
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Track labels sidebar */}
|
||||
<div className="w-7 shrink-0 border-r border-app-line bg-app-darkBox/30 flex flex-col">
|
||||
<div className="h-5 border-b border-app-line" />
|
||||
{trackLabels.map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex-1 flex items-center justify-center border-b border-app-line"
|
||||
>
|
||||
<span className="text-[7px] text-ink-faint select-none">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tracks area */}
|
||||
<div className="flex-1 relative overflow-hidden flex flex-col">
|
||||
{/* Time ruler */}
|
||||
<div className="h-5 shrink-0 border-b border-app-line bg-app-darkBox/20 relative">
|
||||
{timeMarkers.map((t) => (
|
||||
<div
|
||||
key={`tm-${t}`}
|
||||
className="absolute top-0 h-full flex flex-col justify-end pb-0.5"
|
||||
style={{ left: `${(t / 10) * 100}%` }}
|
||||
>
|
||||
<div className="h-1.5 w-px bg-app-line" />
|
||||
<span className="text-[7px] text-ink-faint ml-0.5 select-none">{`0:0${t}`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Track rows + clips — same parent so percentages match */}
|
||||
<div className="flex-1 relative min-h-0">
|
||||
{/* Track rows background */}
|
||||
{trackLabels.map((label, i) => (
|
||||
<div
|
||||
key={`bg-${label}`}
|
||||
className="border-b border-app-line absolute left-0 right-0"
|
||||
style={{
|
||||
height: `${100 / 3}%`,
|
||||
top: `${(i * 100) / 3}%`,
|
||||
backgroundColor: i % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.01)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Clips */}
|
||||
{clips.map((clip) => {
|
||||
const trackIdx = clip.track;
|
||||
const isSelected = clip.id === selectedId;
|
||||
const clipTop = `calc(${(trackIdx * 100) / 3}% + 2px)`;
|
||||
const clipHeight = `calc(${100 / 3}% - 4px)`;
|
||||
return (
|
||||
<motion.div
|
||||
key={clip.id}
|
||||
className="absolute rounded overflow-hidden"
|
||||
initial={false}
|
||||
style={{
|
||||
height: clipHeight,
|
||||
left: `${(clip.x / TL_W) * 100}%`,
|
||||
width: `${(clip.w / TL_W) * 100}%`,
|
||||
top: clipTop,
|
||||
}}
|
||||
animate={{
|
||||
left: `${(clip.x / TL_W) * 100}%`,
|
||||
width: `${(clip.w / TL_W) * 100}%`,
|
||||
top: clipTop,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 25 }}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full rounded overflow-hidden flex flex-col"
|
||||
style={{
|
||||
backgroundColor: isSelected ? 'hsl(43 50% 45%)' : 'hsl(43 45% 40%)',
|
||||
boxShadow: isSelected
|
||||
? 'inset 0 0 0 1px hsl(43 50% 55%), 0 0 0 1px hsl(30 10% 94% / 0.4)'
|
||||
: 'inset 0 0 0 1px hsl(30 10% 94% / 0.1)',
|
||||
}}
|
||||
>
|
||||
{/* Profile label — scaled to bypass browser min font size */}
|
||||
<div className="shrink-0 relative" style={{ height: 9 }}>
|
||||
<span
|
||||
className="text-[10px] font-medium leading-none absolute top-0 left-0.5 origin-top-left opacity-80 whitespace-nowrap"
|
||||
style={{ color: accentFg, transform: 'scale(0.75)' }}
|
||||
>
|
||||
{clip.profile}
|
||||
</span>
|
||||
</div>
|
||||
{/* Waveform — absolutely positioned so it never affects clip width */}
|
||||
<div className="absolute left-0 right-0 bottom-0" style={{ top: 9 }}>
|
||||
<MiniWaveform seed={clip.seed} color={accentFg} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Trim handles on selected */}
|
||||
{isSelected && (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-1 rounded-l"
|
||||
style={{ backgroundColor: 'hsl(30 10% 94% / 0.25)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 rounded-r"
|
||||
style={{ backgroundColor: 'hsl(30 10% 94% / 0.25)' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Playhead */}
|
||||
<motion.div
|
||||
className="absolute top-0 bottom-0 w-[2px] rounded-full z-20 pointer-events-none"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
animate={{ left: `${(playheadX / TL_W) * 100}%` }}
|
||||
transition={{ duration: 0.05, ease: 'linear' }}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-0.5 left-1/2 -translate-x-1/2 w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Animation: Effects Pipeline ────────────────────────────────────────────
|
||||
|
||||
function EffectsAnimation() {
|
||||
const [activeEffect, setActiveEffect] = useState(0);
|
||||
const effects = [
|
||||
{ name: 'Pitch Shift', param: '-3 semitones', color: '#3b82f6' },
|
||||
{ name: 'Reverb', param: 'Room 0.7', color: '#8b5cf6' },
|
||||
{ name: 'Compressor', param: '-15 dB', color: '#ec4899' },
|
||||
{ name: 'Low-Pass', param: '6000 Hz', color: '#14b8a6' },
|
||||
];
|
||||
|
||||
// Waveform bars — original shape
|
||||
const rawBars = [0.3, 0.6, 0.8, 0.5, 0.9, 0.4, 0.7, 0.3, 0.6, 0.5, 0.8, 0.4, 0.7, 0.9, 0.3];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setActiveEffect((p) => (p + 1) % effects.length);
|
||||
}, 2200);
|
||||
return () => clearInterval(interval);
|
||||
}, [effects.length]);
|
||||
|
||||
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-3">
|
||||
{/* Effects chain */}
|
||||
<div className="flex items-center gap-1">
|
||||
{effects.map((fx, i) => (
|
||||
<div key={fx.name} className="flex items-center gap-1">
|
||||
<motion.div
|
||||
className="text-[8px] px-2 py-0.5 rounded-full border font-medium"
|
||||
animate={{
|
||||
borderColor: i <= activeEffect ? `${fx.color}60` : 'rgba(255,255,255,0.06)',
|
||||
backgroundColor: i <= activeEffect ? `${fx.color}15` : 'rgba(255,255,255,0.02)',
|
||||
color: i <= activeEffect ? fx.color : 'rgba(255,255,255,0.3)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{fx.name}
|
||||
</motion.div>
|
||||
{i < effects.length - 1 && (
|
||||
<motion.span
|
||||
className="text-[8px]"
|
||||
animate={{
|
||||
color: i < activeEffect ? 'rgba(255,255,255,0.3)' : 'rgba(255,255,255,0.08)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
→
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Waveform that morphs as effects are applied */}
|
||||
<div className="flex items-center gap-[2px] h-10 w-full max-w-[200px] justify-center">
|
||||
{rawBars.map((h, i) => {
|
||||
// Each effect stage progressively transforms the shape
|
||||
const shifted = activeEffect >= 0 ? h * (0.7 + 0.3 * Math.sin(i * 0.8)) : h;
|
||||
const dampened = activeEffect >= 1 ? shifted * (0.6 + 0.4 * Math.cos(i * 0.3)) : shifted;
|
||||
const compressed = activeEffect >= 2 ? 0.3 + dampened * 0.5 : dampened;
|
||||
const filtered = activeEffect >= 3 ? compressed * (1 - i * 0.03) : compressed;
|
||||
const finalH = Math.max(0.08, Math.min(1, filtered));
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`bar-${i}`}
|
||||
className="w-[3px] rounded-full"
|
||||
animate={{
|
||||
height: `${finalH * 100}%`,
|
||||
backgroundColor: effects[activeEffect].color,
|
||||
}}
|
||||
transition={{
|
||||
height: { duration: 0.5, delay: i * 0.02, ease: 'easeInOut' },
|
||||
backgroundColor: { duration: 0.4 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Active effect detail */}
|
||||
<motion.div
|
||||
className="text-[9px] font-mono text-ink-faint"
|
||||
key={activeEffect}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{effects[activeEffect].name}: {effects[activeEffect].param}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Animation: Local or Remote ─────────────────────────────────────────────
|
||||
|
||||
function LocalRemoteAnimation() {
|
||||
const [mode, setMode] = useState(0);
|
||||
const modes = ['Local GPU', 'Remote Server'];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setMode((p) => (p + 1) % 2);
|
||||
}, 2800);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-40 w-full flex items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4">
|
||||
<div className="flex flex-col items-center gap-4 w-full max-w-[180px]">
|
||||
{/* Toggle */}
|
||||
<div className="flex gap-1 p-0.5 rounded-full border border-app-line bg-app-darkerBox">
|
||||
{modes.map((m, i) => (
|
||||
<motion.div
|
||||
key={m}
|
||||
className="text-[9px] px-3 py-1 rounded-full font-medium"
|
||||
animate={{
|
||||
backgroundColor: i === mode ? 'hsl(43 50% 45%)' : 'transparent',
|
||||
color: i === mode ? 'hsl(30 10% 94%)' : 'rgba(255,255,255,0.35)',
|
||||
}}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
{m}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<motion.div
|
||||
className="w-2 h-2 rounded-full"
|
||||
animate={{
|
||||
backgroundColor: mode === 0 ? '#4ade80' : '#3b82f6',
|
||||
boxShadow: mode === 0 ? '0 0 8px #4ade80' : '0 0 8px #3b82f6',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
<span className="text-[9px] text-ink-faint font-mono">
|
||||
{mode === 0 ? 'Metal acceleration active' : 'Connected to 192.168.1.50'}
|
||||
</span>
|
||||
<span className="text-[8px] text-ink-faint/60 font-mono">
|
||||
{mode === 0 ? 'VRAM: 8.2 / 16.0 GB' : 'Latency: 12ms | CUDA'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Animation: Transcription ───────────────────────────────────────────────
|
||||
|
||||
function TranscriptionAnimation() {
|
||||
const [charIndex, setCharIndex] = useState(0);
|
||||
const text = 'The quick brown fox jumps over the lazy dog near the riverbank.';
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCharIndex((p) => {
|
||||
if (p >= text.length) return 0;
|
||||
return p + 1;
|
||||
});
|
||||
}, 80);
|
||||
return () => clearInterval(interval);
|
||||
}, [text.length]);
|
||||
|
||||
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-3">
|
||||
{/* Fake waveform */}
|
||||
<div className="flex items-center gap-[1px] h-6 w-full max-w-[180px] justify-center">
|
||||
{Array.from({ length: 30 }, (_, i) => {
|
||||
const h = 0.2 + 0.8 * Math.abs(Math.sin(i * 0.5 + charIndex * 0.1));
|
||||
const active = i < (charIndex / text.length) * 30;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-[3px] rounded-full transition-colors duration-100 ${
|
||||
active ? 'bg-accent' : 'bg-app-line'
|
||||
}`}
|
||||
style={{ height: `${h * 100}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Transcribed text */}
|
||||
<div className="text-[10px] text-ink-dull font-mono max-w-[200px] text-center leading-relaxed min-h-[32px]">
|
||||
{text.slice(0, charIndex)}
|
||||
{charIndex < text.length && (
|
||||
<span className="inline-block w-[2px] h-3 bg-accent animate-pulse ml-[1px] align-middle" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Animation: Unlimited Length ─────────────────────────────────────────────
|
||||
|
||||
function UnlimitedLengthAnimation() {
|
||||
const [phase, setPhase] = useState(0);
|
||||
|
||||
const chunks = [
|
||||
'The morning sun crept over the mountains, casting long shadows across the valley below.',
|
||||
'Birds stirred in the canopy, their songs weaving through the cool air like threads of gold.',
|
||||
'Far below, a river wound its way through ancient stones, carrying whispers of the night.',
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setPhase((p) => (p + 1) % 4); // 0-2 = processing chunks, 3 = crossfade/done
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
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">
|
||||
{/* Chunk pills */}
|
||||
<div className="flex flex-col gap-1 w-full max-w-[220px]">
|
||||
{chunks.map((chunk, i) => (
|
||||
<motion.div
|
||||
key={`chunk-${i}`}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded border text-[8px]"
|
||||
animate={{
|
||||
borderColor:
|
||||
phase === 3
|
||||
? 'hsl(43 50% 45% / 0.3)'
|
||||
: i === phase
|
||||
? 'hsl(43 50% 45% / 0.5)'
|
||||
: i < phase
|
||||
? 'rgba(255,255,255,0.12)'
|
||||
: 'rgba(255,255,255,0.06)',
|
||||
backgroundColor:
|
||||
phase === 3
|
||||
? 'hsl(43 50% 45% / 0.04)'
|
||||
: i === phase
|
||||
? 'hsl(43 50% 45% / 0.08)'
|
||||
: i < phase
|
||||
? 'rgba(255,255,255,0.04)'
|
||||
: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<motion.div
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
animate={{
|
||||
backgroundColor:
|
||||
phase === 3
|
||||
? 'hsl(43 50% 50%)'
|
||||
: i === phase
|
||||
? 'hsl(43 50% 50%)'
|
||||
: i < phase
|
||||
? 'rgba(255,255,255,0.3)'
|
||||
: 'rgba(255,255,255,0.1)',
|
||||
boxShadow:
|
||||
i === phase && phase < 3 ? '0 0 6px hsl(43 50% 50%)' : '0 0 0px transparent',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
<span
|
||||
className={`truncate font-mono ${
|
||||
phase === 3 || i <= phase ? 'text-ink-dull' : 'text-ink-faint/50'
|
||||
}`}
|
||||
>
|
||||
{chunk}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Crossfade / result bar */}
|
||||
<div className="flex items-center gap-1 w-full max-w-[220px]">
|
||||
{chunks.map((_, i) => (
|
||||
<motion.div
|
||||
key={`seg-${i}`}
|
||||
className="h-1.5 flex-1 rounded-full"
|
||||
animate={{
|
||||
backgroundColor:
|
||||
phase === 3
|
||||
? 'hsl(43 50% 45%)'
|
||||
: i < phase
|
||||
? 'rgba(255,255,255,0.2)'
|
||||
: i === phase
|
||||
? 'hsl(43 50% 45% / 0.5)'
|
||||
: 'rgba(255,255,255,0.06)',
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status text */}
|
||||
<motion.div
|
||||
className="text-[9px] font-mono"
|
||||
key={phase}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<span className={phase === 3 ? 'text-accent' : 'text-ink-faint'}>
|
||||
{phase < 3
|
||||
? `generating chunk ${phase + 1} of ${chunks.length}...`
|
||||
: 'crossfaded & ready'}
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Feature data ───────────────────────────────────────────────────────────
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
title: 'Near-Perfect Voice Cloning',
|
||||
description:
|
||||
'Multiple TTS engines for exceptional voice quality. Clone any voice from a few seconds of audio with natural intonation and emotion.',
|
||||
icon: Mic,
|
||||
animation: VoiceCloningAnimation,
|
||||
},
|
||||
{
|
||||
title: 'Stories Editor',
|
||||
description:
|
||||
'Create multi-voice narratives with a timeline-based editor. Arrange tracks, trim clips, and mix conversations between characters.',
|
||||
icon: AudioLines,
|
||||
animation: StoriesAnimation,
|
||||
},
|
||||
{
|
||||
title: 'Audio Effects Pipeline',
|
||||
description:
|
||||
'Apply pitch shift, reverb, delay, compression, and more — then save as presets. Preview effects live and set defaults per voice profile.',
|
||||
icon: Sparkles,
|
||||
animation: EffectsAnimation,
|
||||
},
|
||||
{
|
||||
title: 'Local or Remote',
|
||||
description:
|
||||
'Run GPU inference locally with Metal, CUDA, ROCm, Intel Arc, or DirectML — or connect to a remote machine. One-click server setup with automatic discovery.',
|
||||
icon: Cloud,
|
||||
animation: LocalRemoteAnimation,
|
||||
},
|
||||
{
|
||||
title: 'Audio Transcription',
|
||||
description:
|
||||
'Powered by Whisper for accurate speech-to-text. Automatically extract reference text from voice samples.',
|
||||
icon: MessageSquareText,
|
||||
animation: TranscriptionAnimation,
|
||||
},
|
||||
{
|
||||
title: 'Unlimited Generation Length',
|
||||
description:
|
||||
'Generate up to 50,000 characters in one go. Text is auto-split at sentence boundaries, generated per-chunk, and crossfaded seamlessly.',
|
||||
icon: TextCursorInput,
|
||||
animation: UnlimitedLengthAnimation,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Feature Card ───────────────────────────────────────────────────────────
|
||||
|
||||
function FeatureCard({ feature }: { feature: (typeof FEATURES)[number] }) {
|
||||
const Icon = feature.icon;
|
||||
const Animation = feature.animation;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-app-line bg-app-darkBox overflow-hidden">
|
||||
<LazyLoad>
|
||||
<div className="pointer-events-none select-none">
|
||||
<Animation />
|
||||
</div>
|
||||
</LazyLoad>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Features Section ───────────────────────────────────────────────────────
|
||||
|
||||
export function Features() {
|
||||
return (
|
||||
<section id="features" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<div className="mb-16 text-center">
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Professional voice tools, zero compromise
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Everything you need to clone voices, generate speech, and produce multi-voice content —
|
||||
running entirely on your machine.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{FEATURES.map((feature) => (
|
||||
<FeatureCard key={feature.title} feature={feature} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import { ArrowUpRight, Coffee, Coins } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { CopyAddress } from '@/components/CopyAddress';
|
||||
import {
|
||||
DONATE_URL,
|
||||
GITHUB_REPO,
|
||||
TOKEN_CONTRACT_ADDRESS,
|
||||
TOKEN_TICKER,
|
||||
} from '@/lib/constants';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-border py-12">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-8 mb-10">
|
||||
{/* Brand */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<Image
|
||||
src="/voicebox-logo-app.webp"
|
||||
alt="Voicebox"
|
||||
width={24}
|
||||
height={24}
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
<span className="text-sm font-semibold">Voicebox</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Open source voice cloning studio. Local-first, free forever.
|
||||
</p>
|
||||
<a
|
||||
href={DONATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground hover:border-[#FFDD00]/40"
|
||||
aria-label="Donate via Buy Me a Coffee"
|
||||
>
|
||||
<Coffee className="h-4 w-4 text-[#FFDD00]" />
|
||||
<span className="text-[13px] font-medium">Donate</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Product */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">Product</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<a href="/#features" className="hover:text-foreground transition-colors">
|
||||
Clone
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/capture" className="hover:text-foreground transition-colors">
|
||||
Capture
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/#mcp" className="hover:text-foreground transition-colors">
|
||||
MCP
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/#about" className="hover:text-foreground transition-colors">
|
||||
Models
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/#api" className="hover:text-foreground transition-colors">
|
||||
API
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/cloud" className="hover:text-foreground transition-colors">
|
||||
Cloud
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/pricing" className="hover:text-foreground transition-colors">
|
||||
Pricing
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/download" className="hover:text-foreground transition-colors">
|
||||
Download
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">Resources</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<a href="/blog" className="hover:text-foreground transition-colors">
|
||||
Blog
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="https://docs.voicebox.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Documentation
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={GITHUB_REPO}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Source Code
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={`${GITHUB_REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Releases
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={`${GITHUB_REPO}/issues`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Issues
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/sponsors" className="hover:text-foreground transition-colors">
|
||||
VIP Sponsor
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Also by */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">Also By</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<a
|
||||
href="https://spacebot.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Spacebot
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://spacedrive.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Spacedrive
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Token */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">Token</h4>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className="h-4 w-4 text-accent" />
|
||||
<span className="font-semibold text-foreground">{TOKEN_TICKER}</span>
|
||||
<span className="text-xs text-muted-foreground/60">Solana</span>
|
||||
</div>
|
||||
<CopyAddress address={TOKEN_CONTRACT_ADDRESS} />
|
||||
<Link
|
||||
href="/token"
|
||||
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||||
>
|
||||
Token details
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-6">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
© {new Date().getFullYear()} Voicebox. Open source under MIT license.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Github } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GITHUB_REPO } from '@/lib/constants';
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="border-b border-border bg-background/60 backdrop-blur-2xl sticky top-0 z-50 supports-[backdrop-filter]:bg-background/40">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 font-bold text-xl sm:text-2xl hover:opacity-80 transition-opacity tracking-tight"
|
||||
>
|
||||
Voicebox
|
||||
</Link>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<Button variant="outline" size="sm" asChild className="hidden sm:flex">
|
||||
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer">
|
||||
<Github className="h-4 w-4 mr-2" />
|
||||
GitHub
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" asChild className="sm:hidden">
|
||||
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer" aria-label="GitHub">
|
||||
<Github className="h-5 w-5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Shared ref so the unmute button can unlock WaveSurfer's audio on iOS Safari
|
||||
// Must call .play() on WaveSurfer's actual media element during a user gesture
|
||||
let sharedWaveSurfer: WaveSurfer | null = null;
|
||||
let audioUnlocked = false;
|
||||
|
||||
export function unlockAudioContext() {
|
||||
if (audioUnlocked) return;
|
||||
audioUnlocked = true;
|
||||
|
||||
// Unlock WaveSurfer's internal audio element
|
||||
// Skip if already playing — the context is already unlocked and the
|
||||
// play/pause/reset dance would destroy the active playback.
|
||||
if (sharedWaveSurfer && !sharedWaveSurfer.isPlaying()) {
|
||||
const media = sharedWaveSurfer.getMediaElement();
|
||||
if (media) {
|
||||
media.muted = true;
|
||||
media
|
||||
.play()
|
||||
.then(() => {
|
||||
media.pause();
|
||||
media.muted = false;
|
||||
media.currentTime = 0;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Also unlock a standalone AudioContext as fallback
|
||||
try {
|
||||
const ctx = new (
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
||||
)();
|
||||
const buffer = ctx.createBuffer(1, 1, 22050);
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
source.start(0);
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
interface LandingAudioPlayerProps {
|
||||
audioUrl: string;
|
||||
title: string;
|
||||
playing: boolean;
|
||||
muted: boolean;
|
||||
onFinish: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LandingAudioPlayer({
|
||||
audioUrl,
|
||||
title,
|
||||
playing,
|
||||
muted,
|
||||
onFinish,
|
||||
onClose,
|
||||
}: LandingAudioPlayerProps) {
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(0.75);
|
||||
const [isLooping, setIsLooping] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const onFinishRef = useRef(onFinish);
|
||||
onFinishRef.current = onFinish;
|
||||
const playingRef = useRef(playing);
|
||||
playingRef.current = playing;
|
||||
const mutedRef = useRef(muted);
|
||||
mutedRef.current = muted;
|
||||
|
||||
// Initialize WaveSurfer
|
||||
useEffect(() => {
|
||||
const initWaveSurfer = () => {
|
||||
const container = waveformRef.current;
|
||||
if (!container) {
|
||||
setTimeout(initWaveSurfer, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
setTimeout(initWaveSurfer, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up existing instance
|
||||
if (wavesurferRef.current) {
|
||||
wavesurferRef.current.destroy();
|
||||
wavesurferRef.current = null;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const getCSSVar = (varName: string) => {
|
||||
const value = getComputedStyle(root).getPropertyValue(varName).trim();
|
||||
return value ? `hsl(${value})` : '';
|
||||
};
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container,
|
||||
waveColor: getCSSVar('--muted'),
|
||||
progressColor: getCSSVar('--accent'),
|
||||
cursorColor: getCSSVar('--accent'),
|
||||
barWidth: 2,
|
||||
barRadius: 2,
|
||||
height: 80,
|
||||
normalize: true,
|
||||
interact: true,
|
||||
mediaControls: false,
|
||||
});
|
||||
|
||||
ws.on('ready', () => {
|
||||
setDuration(ws.getDuration());
|
||||
ws.setVolume(mutedRef.current ? 0 : volume);
|
||||
setIsReady(true);
|
||||
});
|
||||
|
||||
ws.on('play', () => {
|
||||
console.log('[Player] play event');
|
||||
setIsPlaying(true);
|
||||
});
|
||||
ws.on('pause', () => {
|
||||
console.log('[Player] pause event');
|
||||
setIsPlaying(false);
|
||||
});
|
||||
|
||||
ws.on('timeupdate', (time: number) => {
|
||||
setCurrentTime(Math.min(time, ws.getDuration()));
|
||||
});
|
||||
|
||||
let didFinish = false;
|
||||
ws.on('finish', () => {
|
||||
if (didFinish) return;
|
||||
didFinish = true;
|
||||
console.log(
|
||||
'[Player] finish event, currentTime:',
|
||||
ws.getCurrentTime(),
|
||||
'duration:',
|
||||
ws.getDuration(),
|
||||
);
|
||||
setIsPlaying(false);
|
||||
onFinishRef.current();
|
||||
});
|
||||
|
||||
ws.load(audioUrl);
|
||||
wavesurferRef.current = ws;
|
||||
sharedWaveSurfer = ws;
|
||||
};
|
||||
|
||||
setIsReady(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(initWaveSurfer, 10);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (wavesurferRef.current) {
|
||||
wavesurferRef.current.destroy();
|
||||
wavesurferRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [audioUrl]);
|
||||
|
||||
// Respond to external play/stop signals
|
||||
useEffect(() => {
|
||||
const ws = wavesurferRef.current;
|
||||
console.log('[Player] effect', { playing, isReady, hasWs: !!ws });
|
||||
if (!ws || !isReady) return;
|
||||
|
||||
if (playing) {
|
||||
// Resume the AudioContext first (required for iOS Safari after unlock)
|
||||
const backend = ws.getMediaElement();
|
||||
if (backend && 'context' in backend) {
|
||||
const ctx = (backend as unknown as { context: AudioContext }).context;
|
||||
if (ctx?.state === 'suspended') ctx.resume();
|
||||
}
|
||||
ws.play()
|
||||
.then(() => {
|
||||
console.log('[Player] play succeeded');
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (e.name === 'NotAllowedError') {
|
||||
console.warn('[Player] Autoplay blocked by browser — waiting for user gesture');
|
||||
} else {
|
||||
console.error('[Player] play failed', e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ws.pause();
|
||||
}
|
||||
}, [playing, isReady]);
|
||||
|
||||
// Sync volume and muted state
|
||||
useEffect(() => {
|
||||
if (wavesurferRef.current) {
|
||||
wavesurferRef.current.setVolume(muted ? 0 : volume);
|
||||
}
|
||||
}, [volume, muted]);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (!wavesurferRef.current) return;
|
||||
wavesurferRef.current.playPause();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 right-0 border-t border-border bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-30">
|
||||
<div className="px-4 py-3 flex flex-col md:flex-row md:items-center gap-2 md:gap-4">
|
||||
{/* Waveform — full width row on mobile, inline on desktop */}
|
||||
<div className="min-w-0 min-h-[60px] md:min-h-[80px] md:flex-1 md:order-2">
|
||||
<div ref={waveformRef} className="w-full h-full min-h-[60px] md:min-h-[80px]" />
|
||||
</div>
|
||||
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center gap-3 md:contents">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
disabled={!isReady}
|
||||
className="h-10 w-10 rounded-full bg-accent flex items-center justify-center shrink-0 disabled:opacity-50 md:order-1 shadow-lg"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-5 w-5 text-accent-foreground fill-accent-foreground" />
|
||||
) : (
|
||||
<Play className="h-5 w-5 ml-0.5 text-accent-foreground fill-accent-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Time */}
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 md:order-3">
|
||||
<span className="font-mono text-xs">{formatDuration(currentTime)}</span>
|
||||
<span className="text-xs">/</span>
|
||||
<span className="font-mono text-xs">{formatDuration(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block md:order-4">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loop */}
|
||||
<button
|
||||
onClick={() => setIsLooping(!isLooping)}
|
||||
className={`h-8 w-8 flex items-center justify-center rounded-sm shrink-0 hover:bg-muted md:order-5 ${
|
||||
isLooping ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Repeat className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="flex items-center gap-2 shrink-0 w-[140px] md:order-6 mr-3">
|
||||
<button
|
||||
onClick={() => setVolume(volume > 0 ? 0 : 0.75)}
|
||||
className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm"
|
||||
>
|
||||
{volume > 0 ? (
|
||||
<Volume2 className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<VolumeX className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={volume * 100}
|
||||
onChange={(e) => setVolume(Number(e.target.value) / 100)}
|
||||
className="flex-1 h-1 appearance-none bg-muted rounded-full accent-foreground cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Coffee, Coins, Github } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DONATE_URL, GITHUB_REPO, TOKEN_TICKER } from '@/lib/constants';
|
||||
|
||||
function formatStarCount(count: number): string {
|
||||
if (count >= 1000) {
|
||||
const k = count / 1000;
|
||||
return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`;
|
||||
}
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
export function Navbar() {
|
||||
const [starCount, setStarCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/stars')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch stars');
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (typeof data.count === 'number') setStarCount(data.count);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch star count:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-3 sm:grid sm:grid-cols-[1fr_auto_1fr] sm:gap-x-6">
|
||||
{/* Logo + wordmark */}
|
||||
<a href="/" className="flex items-center gap-2.5 justify-self-start">
|
||||
<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>
|
||||
</a>
|
||||
|
||||
{/* Nav links - centered */}
|
||||
<div className="hidden sm:flex items-center gap-1 justify-self-center">
|
||||
<a
|
||||
href="/#features"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Clone
|
||||
</a>
|
||||
<a
|
||||
href="/capture"
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Capture
|
||||
<span className="rounded-full bg-accent/15 px-1.5 text-[9px] font-semibold uppercase tracking-wider text-accent">
|
||||
New
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/#mcp"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
MCP
|
||||
</a>
|
||||
<a
|
||||
href="/#about"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Models
|
||||
</a>
|
||||
<a
|
||||
href="/pricing"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Pricing
|
||||
</a>
|
||||
<a
|
||||
href="/blog"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Blog
|
||||
</a>
|
||||
<a
|
||||
href="https://docs.voicebox.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Token + Donate + GitHub star buttons */}
|
||||
<div className="flex items-center gap-2 justify-self-end">
|
||||
<a
|
||||
href="/token"
|
||||
className="hidden sm:flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground hover:border-accent/40"
|
||||
aria-label={`${TOKEN_TICKER} token`}
|
||||
>
|
||||
<Coins className="h-4 w-4 text-accent" />
|
||||
<span className="text-[13px] font-semibold tracking-wide text-foreground">
|
||||
{TOKEN_TICKER}
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href={DONATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground hover:border-[#FFDD00]/40"
|
||||
aria-label="Donate via Buy Me a Coffee"
|
||||
>
|
||||
<Coffee className="h-4 w-4 text-[#FFDD00]" />
|
||||
<span className="text-[13px] font-medium">Donate</span>
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_REPO}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground hover:border-border"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
<span className="text-[13px] font-medium">Star</span>
|
||||
{starCount !== null && (
|
||||
<span className="border-l border-border/60 pl-2 text-[13px] font-semibold text-foreground">
|
||||
{formatStarCount(starCount)}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowRight, Dices, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// ─── Modes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Mode = {
|
||||
id: 'compose' | 'rewrite';
|
||||
label: string;
|
||||
icon: typeof Dices;
|
||||
outputLabel: string;
|
||||
output: string;
|
||||
} & (
|
||||
| { inputLabel: string; input: string }
|
||||
| { inputLabel?: undefined; input?: undefined }
|
||||
);
|
||||
|
||||
const MODES: Mode[] = [
|
||||
{
|
||||
id: 'rewrite',
|
||||
label: 'Rewrite',
|
||||
icon: Wand2,
|
||||
inputLabel: 'Your text',
|
||||
outputLabel: "Marlowe, in character",
|
||||
input: 'the build is done and we shipped to production',
|
||||
output:
|
||||
"Build's wrapped, ship's left the dock. Another stack of code makes its way into prod, another row of green checks lining the wall.",
|
||||
},
|
||||
{
|
||||
id: 'compose',
|
||||
label: 'Compose',
|
||||
icon: Dices,
|
||||
outputLabel: "Marlowe, in character",
|
||||
output:
|
||||
"She came through clean. Not a single test casting a shadow. In this town, that's usually when you start worrying.",
|
||||
},
|
||||
];
|
||||
|
||||
const PERSONA_DESCRIPTION =
|
||||
"1940s noir detective. World-weary, cynical, every situation a metaphor for the city's underbelly. Talks like he's seen one stack trace too many.";
|
||||
|
||||
// ─── Persona card ───────────────────────────────────────────────────────────
|
||||
|
||||
function PersonaCard() {
|
||||
return (
|
||||
<div className="rounded-xl border border-app-line bg-app-darkBox p-5 shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="h-12 w-12 rounded-full shrink-0 ring-1 ring-white/10"
|
||||
style={{ background: 'linear-gradient(135deg, #dc2626, #7f1d1d)' }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[15px] font-semibold text-foreground leading-tight">Marlowe</div>
|
||||
<div className="text-[11px] text-muted-foreground/80 leading-tight mt-0.5">
|
||||
Voice profile · cloned from a 12s sample
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1.5 text-[10px] font-mono uppercase tracking-[0.2em] text-ink-faint/70">
|
||||
Personality
|
||||
</div>
|
||||
<p className="text-[13px] leading-relaxed text-ink-dull italic">“{PERSONA_DESCRIPTION}”</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Mode demo ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ModeDemo({ mode, cycleKey }: { mode: Mode; cycleKey: number }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-app-line bg-app-darkerBox overflow-hidden flex flex-col flex-1">
|
||||
{/* Mode tabs */}
|
||||
<div className="flex items-center gap-1 p-1.5 border-b border-app-line bg-app-darkBox/40">
|
||||
{MODES.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const active = m.id === mode.id;
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`flex items-center gap-1.5 h-8 px-3 rounded-md text-[12px] font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-white/[0.07] text-foreground border border-white/[0.08]'
|
||||
: 'text-muted-foreground/60'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{m.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Input → Output */}
|
||||
<div className="p-5 flex-1 flex flex-col">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${cycleKey}-${mode.id}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="flex flex-col gap-4 flex-1"
|
||||
>
|
||||
{/* Input */}
|
||||
{mode.input ? (
|
||||
<div>
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.2em] text-ink-faint/70 mb-1.5">
|
||||
{mode.inputLabel}
|
||||
</div>
|
||||
<div className="text-[13px] leading-relaxed text-ink-dull/90 font-mono bg-black/20 rounded-md border border-app-line/60 px-3 py-2.5">
|
||||
{mode.input}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.2em] text-ink-faint/70 mb-1.5">
|
||||
No input
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-[13px] text-ink-dull/90 bg-black/20 rounded-md border border-app-line/60 px-3 py-2.5">
|
||||
<Dices className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Click Compose — the character improvises a fresh line.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="flex items-center justify-center gap-2 text-[10px] font-mono uppercase tracking-[0.2em] text-ink-faint/50">
|
||||
<span>In character</span>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
<div>
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.2em] text-accent mb-1.5">
|
||||
{mode.outputLabel}
|
||||
</div>
|
||||
<div className="text-[14px] leading-relaxed text-foreground bg-accent/[0.06] rounded-md border border-accent/20 px-3 py-2.5 italic">
|
||||
“{mode.output}”
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Bullets ────────────────────────────────────────────────────────────────
|
||||
|
||||
const BULLETS = [
|
||||
{
|
||||
icon: Wand2,
|
||||
title: 'Rewrite',
|
||||
description:
|
||||
'Restate your text in their voice while preserving every idea. Same content, their delivery — for scripts, dubs, and consistent character voice across long-form work.',
|
||||
},
|
||||
{
|
||||
icon: Dices,
|
||||
title: 'Compose',
|
||||
description:
|
||||
'No input needed — hit the button and the character improvises a fresh line of their own. Roll again for another take. Useful for game dialogue, narration cues, or character barks.',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Section ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Personalities() {
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setIdx((i) => (i + 1) % MODES.length);
|
||||
}, 4500);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const mode = MODES[idx];
|
||||
|
||||
return (
|
||||
<section id="personalities" 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">
|
||||
Personalities
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-semibold tracking-tight text-foreground mb-5">
|
||||
Voices with a personality.
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base md:text-lg leading-relaxed">
|
||||
Give any voice profile a free-form personality. Then{' '}
|
||||
<b className="text-foreground/90">Rewrite</b> your text in their voice, or let them{' '}
|
||||
<b className="text-foreground/90">Compose</b> a fresh line of their own — your cloned voice, in full character.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mockup: persona card (left) + mode demo (right) */}
|
||||
<div className="grid md:grid-cols-[340px_1fr] gap-6 mb-12 items-stretch">
|
||||
<PersonaCard />
|
||||
<ModeDemo mode={mode} cycleKey={idx} />
|
||||
</div>
|
||||
|
||||
{/* Bullets */}
|
||||
<div className="grid md:grid-cols-2 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,29 +0,0 @@
|
||||
import { SiApple, SiLinux } from '@icons-pack/react-simple-icons';
|
||||
|
||||
// Official brand icons via Simple Icons (apple/linux). Simple Icons drops
|
||||
// Microsoft's mark due to trademark policy, so the Windows 11 flag is
|
||||
// inlined from Microsoft's public brand guidance.
|
||||
|
||||
export function AppleIcon({ className }: { className?: string }) {
|
||||
return <SiApple className={className} color="currentColor" />;
|
||||
}
|
||||
|
||||
export function LinuxIcon({ className }: { className?: string }) {
|
||||
return <SiLinux className={className} color="currentColor" />;
|
||||
}
|
||||
|
||||
export function WindowsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Windows"
|
||||
>
|
||||
<title>Windows</title>
|
||||
<path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4l-13.051.149M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||