Compare commits

...
Author SHA1 Message Date
Aftaab SiddiquiandJamie Pine 901ffcc93b Fix voice sample validation on Python 3.13 (fixes #852) (#853)
* Fix voice sample validation on Python 3.13

Python 3.13 removed audioop from the standard library, which broke reference
audio validation when adding voice samples. Add the audioop-lts backport for
3.13+ installs and bundle audioop in PyInstaller builds on the same versions.

* style(tests): satisfy Ruff import ordering

---------

Co-authored-by: Jamie Pine <[email protected]>
2026-07-20 22:35:40 -07:00
a3fa9a2784 fix(backend): return 404 instead of 500 for audio of failed generations (#893)
* fix(backend): return 404 instead of 500 for audio of failed generations

A failed generation stores an empty audio_path. 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"), surfacing as a 500.

- resolve_storage_path now returns None for empty paths
- audio routes check is_file() instead of exists() so directories never
  reach FileResponse
- GET /audio/{generation_id} reports "Generation failed; no audio
  available" when the generation status is failed

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(backend): reject empty Path objects in resolve_storage_path

Path("") is truthy, so the previous `if not path` guard only caught
None and empty strings. Callers such as database/migrations.py pass
Path objects, so an empty Path could still resolve to the data dir.
Check None separately and reject paths with no parts.

Also add regression tests asserting the version and sample audio
endpoints 404 when a stored path resolves to an existing directory
(guards the is_file() checks against regressing to exists()).

Addresses CodeRabbit review on PR #893.

Co-Authored-By: Claude Fable 5 <[email protected]>

* style(tests): drop parentheses on pytest.fixture decorator (ruff PT001)

Co-Authored-By: Claude Fable 5 <[email protected]>

* style(tests): satisfy Ruff naming rule

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Jamie Pine <[email protected]>
2026-07-20 22:35:40 -07:00
cc9c905b55 fix(setup): install mlx-lm and mlx-audio in setup-python on Apple Silicon (#892)
* fix(setup): install mlx-lm and mlx-audio in setup-python on Apple Silicon

The dev setup installed requirements-mlx.txt but not mlx-audio/mlx-lm
themselves, so POST /transcribe failed on a fresh Apple Silicon setup
with "No module named 'mlx_audio'" (then "No module named 'mlx_lm'").
The release workflow already installs both with --no-deps (they declare
transformers>=5.x, conflicting with our <=4.57.x cap); mirror that in
the setup-python recipe with the same pins.

Co-Authored-By: Claude Fable 5 <[email protected]>

* test: add MLX smoke test for the --no-deps mlx-audio/mlx-lm install

mlx-audio and mlx-lm are installed --no-deps, so a missing transitive
dependency only surfaces at import time. Add a pytest-discoverable
smoke test (skipped off Apple Silicon) covering the exact entry points
the backend uses: mlx_audio.tts.load, mlx_audio.stt.load (which also
exercises the miniaudio dep from issue #505), mlx_lm.load/generate,
and a basic mlx.core op.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 22:27:01 -07:00
XariannandJamie Pine 7d4844fb10 fix(rocm): unset empty HSA_OVERRIDE_GFX_VERSION before torch loads (#864)
Docker compose sets HSA_OVERRIDE_GFX_VERSION=${HSA_OVERRIDE_GFX_VERSION:-}
which results in an empty string when not provided. An empty string is
not the same as unset - ROCm treats it as 'force-empty' and no GPU is
detected, even natively supported ones (e.g. gfx1201 / RX 9070 on ROCm 7.2).

Pop the env var when it is empty, before torch loads, so ROCm auto-detects
the GPU correctly.

Tested on RX 9070 (gfx1201) with ROCm 7.2 and PyTorch 2.12.1+rocm7.2.
2026-07-20 22:27:01 -07:00
309120ec89 fix(build): build voicebox-mcp shim sidecar on Windows (#794)
The Windows `build-server` just recipe only built and copied the
voicebox-server sidecar, omitting the voicebox-mcp stdio shim that the
Unix scripts/build-server.sh builds via `build_binary.py --shim`.

As a result `just build` on Windows produced only one sidecar and the
Tauri bundle step failed with:

    resource path `binaries\voicebox-mcp-<triple>.exe` doesn't exist

Build and copy the shim sidecar after the server, mirroring
build-server.sh. Hoist the triple/binaries-dir setup ahead of both
builds so the shim step reuses them.

Co-authored-by: namu.shin <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-20 22:27:01 -07:00
29dadb5543 fix: the justfile syntax errror, GPU information cannot be read (#669)
Co-authored-by: xor_s <[email protected]>
2026-07-20 22:27:01 -07:00
Shabeer VPKandJamie Pine 2a206cd09f Update .dockerignore (#861)
whitelist ROCm entrypoint
2026-07-20 22:27:01 -07:00
jitendra kumar sainiandJamie Pine dbd57dfb60 docs: fix incorrect app identifier in CONTRIBUTING.md (#863)
The CUDA backend path used com.voicebox.app, but the actual Tauri identifier is sh.voicebox.app (as in tauri.conf.json and all other docs).
2026-07-20 21:19:19 -07:00
neuron-tech-aiandJamie Pine 0f2032f058 Batch story item counts in list_stories to eliminate N+1 (#663)
list_stories() previously executed one COUNT(story_items) query per
story in a Python loop. With N stories that is N+1 round-trips to
SQLite regardless of list length. Replace with a single aggregated
GROUP BY query that fetches all counts at once, then populate each
StoryResponse from a dict lookup.
2026-07-20 21:15:00 -07:00
youtsuhoandJamie Pine 46fe1c3608 fix: gate macOS-only keyboard_layout symbols behind cfg (#831)
* fix: gate macOS-only keyboard_layout symbols behind cfg to suppress dead_code warnings

* chore: sync bun.lock with package.json
2026-07-20 21:07:46 -07:00
Jamie Pine 7346c9e652 chore(release): reconcile approved main promotions 2026-07-20 21:03:34 -07:00
cbca21ac77 fix(kokoro): add missing male Mandarin voices (#788)
Co-authored-by: Siddharth Chintawar <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-07-20 20:51:17 -07:00
Andrew BarnesandJamie Pine 12ce7a4c35 Fix CUDA downloads on unsupported platforms (#770)
* Fix CUDA downloads on unsupported platforms

* fix: align CUDA status nullability

* fix: require CUDA download support flag
2026-07-20 19:22:57 -07:00
3f4631c865 fix(offline): remove process-global offline guard from Qwen3 LLM loads (#924)
force_offline_if_cached flips HF_HUB_OFFLINE (env + huggingface_hub
constant + transformers._is_offline_mode) process-wide for the duration
of a cached LLM load, silently switching every concurrent model
download/load on other threads to offline mode. With default capture
settings (whisper-turbo STT + Qwen3 refinement + auto_refine) a first
run downloads several models concurrently, and a poisoned fetch
surfaces as "Can't load feature extractor..." (whisper) or
"Unrecognized model ... model_type" (Qwen3) rather than anything
mentioning offline mode.

These are the last two call sites of the guard — the same pattern was
deliberately removed app-wide in #524/#530 after identical failures,
and the 0.5.0 LLM backend reintroduced it. LLM loads now run with the
process's default HF_HUB_OFFLINE state, matching every other backend
(issue #462 precedent).

Fixes #841

Claude-Session: https://claude.ai/code/session_011iwL9AyeAWgz2jpgcHxJpC

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 18:53:32 -07:00
Jamie Pine e001439c06 chore: remove public landing application 2026-07-20 15:29:48 -07:00
TedChang-LimandJamie Pine 47d9ce908f feat(i18n): add Korean (ko) locale with 559 translation keys (#814)
* feat(i18n): add Korean (ko) locale with 559 translation keys

* fix(i18n): complete Korean translations for current UI

---------

Co-authored-by: Jamie Pine <[email protected]>
2026-07-20 15:19:56 -07:00
b57cfed3ef feat(i18n): add Spanish (es) locale (#798)
Adds Spanish as a UI display language, matching the existing
4-locale pattern (en, ja, zh-CN, zh-TW) with full key parity.

- app/src/i18n/locales/es/translation.json: 832 strings across 18
  namespaces, translated from the en master. Keys, {{interpolation}}
  placeholders, <code>/<path>/<link>/<strong> tags and _one/_other
  plurals preserved. Brand/model names (Whisper, Qwen3, CUDA, MCP…)
  left untranslated by design.
- app/src/i18n/index.ts: register `es` in SUPPORTED_LANGUAGES and
  resources; the language switcher and LanguageCode derive automatically.
- app/src/lib/utils/format.ts: wire the date-fns `es` locale for
  relative-date formatting.

Verified: key parity 832/832 (no missing/extra, placeholders & tags
intact), biome check clean, app+web typecheck pass, build:web succeeds.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Jamie Pine <[email protected]>
2026-07-20 13:16:13 -07:00
albanobattistellaandJamie Pine cf173ae837 Add Italian translation (#904)
* Add Italian translation

* Add Italian language support to i18n
2026-07-20 13:16:13 -07:00
89d489f711 fix(linux): skip click-through toggle on dictate pill to prevent startup crash (#906)
The dictate pill window is built hidden at setup and the frontend emits
dictate:hide as soon as it mounts. The handler calls
set_ignore_cursor_events(true) on a window GTK has never realized, and
tao's CursorIgnoreEvents path unwraps the missing GdkWindow
(tao-0.34.5 event_loop.rs:449), panicking inside a glib dispatch that
cannot unwind — the process aborts within seconds of launch on Linux.

The click-through toggle exists as a macOS workaround for transparent
always-on-top NSWindows lingering as invisible click targets; it was
never needed on Linux. Gate all three call sites so Linux never toggles
it: the true/false pair stays balanced (never set, never unset), and
macOS/Windows builds are unchanged.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 12:41:24 -07:00
f7c08477a0 fix(models): stop reporting errored downloads as still downloading (#926)
TaskManager.error_download() intentionally keeps a failed task in the
active list (status="error") so /tasks/active can surface the error
and retry UI — but /models/status derived its "downloading" flag from
the same unfiltered list. One failed download therefore showed the
model as downloading:true / downloaded:false for the life of the
process, masking the model's real cache state (even a fully valid
on-disk cache) until an app restart. Likely behind endless-spinner
reports like #181 and the restart-fixes-it pattern in #883.

Add TaskManager.get_pending_downloads() (downloading/extracting only)
and use it in /models/status; /tasks/active behavior is unchanged.

Fixes #925


Claude-Session: https://claude.ai/code/session_011iwL9AyeAWgz2jpgcHxJpC

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 12:41:19 -07:00
Elem OghenekaroandJamie Pine 7d3f7b96d7 Fix: keep the uploaded file extension when transcribing (#903)
/transcribe wrote every upload to a temp file named .wav regardless of its
real format. librosa picks its decoder from the extension, so any non-wav
upload failed with "could not open/decode file" even though the format is
one the app handles elsewhere.

profiles.py already solves this for voice samples by keeping the uploaded
extension when it is one of the audio types it accepts, and falling back to
.wav otherwise. Same approach here, same set. The fallback means an unknown
or missing extension behaves exactly as it does today.
2026-07-20 12:41:19 -07:00
Jamie Pine 7e424a20a3 fix(dictation): preserve native focus and fullscreen injection
Carry focus and auto-paste permission per capture, support dictation over macOS fullscreen Spaces, preserve native window behavior flags, and abort paste when the pill cannot be hidden safely.\n\nVerified: frontend CI; cargo check; diff and security scans. Packaged multi-Space validation remains a release gate.
2026-07-19 17:45:52 -07:00
Jamie Pine 9b1beba3e1 fix(dictation): make microphone lifecycle race-safe
Coalesce and invalidate microphone acquisition, preserve immediate stop/cancel events, block overlapping/finalising takes, and add the opt-in keep_mic_warm setting with privacy-preserving default off.\n\nVerified: frontend CI; keep_mic_warm migration default and idempotency; diff checks.
2026-07-19 17:45:52 -07:00
Jamie Pine 0070c04bcf fix(mlx): serialize accelerator lifecycle and inference
Route MLX load, inference, unload, reset, cache cleanup, and shutdown through a single worker. Add affinity and concurrent-unload regression coverage.\n\nVerified: 17 related backend tests; frontend CI; cargo check.
2026-07-19 17:45:52 -07:00
130 changed files with 1467 additions and 10371 deletions
+2 -1
View File
@@ -8,7 +8,8 @@ tauri/
landing/
docs/
mlx-test/
scripts/
scripts/*
!scripts/rocm-entrypoint.sh
# Dependencies & build artifacts (rebuilt in Docker)
node_modules/
+2 -2
View File
@@ -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`:
+1 -1
View File
@@ -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)
+3 -4
View File
@@ -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')}
+4
View File
@@ -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.",
+4
View File
@@ -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. */
+369 -137
View File
@@ -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,
};
}
+60 -23
View File
@@ -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,
};
}
+26 -1
View File
@@ -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.
+10 -3
View File
@@ -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")
+20 -6
View File
@@ -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
+55 -29
View File
@@ -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)
+22 -6
View File
@@ -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,
+3
View File
@@ -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"
+5
View File
@@ -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:
+7
View File
@@ -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:
+4
View File
@@ -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(
+2
View File
@@ -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)
+2 -1
View File
@@ -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.
+1
View File
@@ -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
+9 -4
View File
@@ -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(
+3 -3
View File
@@ -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("/", "--"))
+4 -4
View File
@@ -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())
+39
View File
@@ -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()
+15 -3
View File
@@ -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
+5 -7
View File
@@ -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())
+7 -8
View File
@@ -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"
+123
View File
@@ -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)
+55
View File
@@ -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)
+128
View File
@@ -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
+3 -246
View File
@@ -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

+17 -7
View File
@@ -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]
-35
View File
@@ -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
-100
View File
@@ -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
-18
View File
@@ -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"
}
}
-12
View File
@@ -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;
-11
View File
@@ -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"
-38
View File
@@ -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"
}
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

-14
View File
@@ -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 });
}
}
-15
View File
@@ -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,
);
}
-92
View File
@@ -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 />
</>
);
}
-86
View File
@@ -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 />
</>
);
}
-176
View File
@@ -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 />
</>
);
}
-188
View File
@@ -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);
}
-312
View File
@@ -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>
);
}
-245
View File
@@ -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;
}
-56
View File
@@ -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>
);
}
-169
View File
@@ -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&apos;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 />
</>
);
}
-86
View File
@@ -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 &times; 630 &mdash; Right-click the card or screenshot at 1:1 zoom
</div>
</div>
);
}
-252
View File
@@ -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 />
</>
);
}
-131
View File
@@ -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 />
</>
);
}
-223
View File
@@ -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>
);
}
-343
View File
@@ -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 }}
>
&ldquo;{scenario.utterance}&rdquo;
</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&rsquo;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&rsquo;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>
);
}
-200
View File
@@ -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>
);
}
-25
View File
@@ -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>
);
}
-90
View File
@@ -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>
);
}
-480
View File
@@ -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 }}
>
&ldquo;{current.message}&rdquo;
</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>
);
}
-481
View File
@@ -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>
);
}
-889
View File
@@ -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>
);
}
-38
View File
@@ -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>
);
}
-855
View File
@@ -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 }}
>
&rarr;
</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>
);
}
-204
View File
@@ -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">
&copy; {new Date().getFullYear()} Voicebox. Open source under MIT license.
</p>
</div>
</div>
</footer>
);
}
-39
View File
@@ -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>
);
}
-139
View File
@@ -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>
);
}
-229
View File
@@ -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">&ldquo;{PERSONA_DESCRIPTION}&rdquo;</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">
&ldquo;{mode.output}&rdquo;
</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>
);
}
-29
View File
@@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More