mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-28 06:35:18 -07:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac68052945 | ||
|
|
e601fd2ca4 | ||
|
|
7b25e0ba0b | ||
|
|
a6817cd082 | ||
|
|
df50b8a925 | ||
|
|
a672ac5279 | ||
|
|
d35e6f0cc5 | ||
|
|
b1069b4521 | ||
|
|
f9e1aa153d | ||
|
|
01800f196f | ||
|
|
606da1c894 | ||
|
|
664178f0cf | ||
|
|
f1541701fb | ||
|
|
a2adc3b506 | ||
|
|
15ba824472 | ||
|
|
2ad4776a76 | ||
|
|
a8469b39f1 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.2.3
|
||||
current_version = 0.3.0
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
|
||||
@@ -189,10 +189,10 @@ jobs:
|
||||
pip install -r backend/requirements.txt
|
||||
pip install --no-deps chatterbox-tts
|
||||
|
||||
- name: Install PyTorch with CUDA 12.1
|
||||
- name: Install PyTorch with CUDA 12.6
|
||||
run: |
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall --no-deps
|
||||
pip install torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cu126 --force-reinstall --no-deps
|
||||
pip install torchaudio --index-url https://download.pytorch.org/whl/cu126 --force-reinstall --no-deps
|
||||
|
||||
- name: Verify CUDA support in torch
|
||||
run: |
|
||||
|
||||
@@ -50,6 +50,8 @@ logs/
|
||||
app/openapi.json
|
||||
tauri/src-tauri/binaries/*
|
||||
tauri/src-tauri/gen/Assets.car
|
||||
tauri/src-tauri/gen/voicebox.icns
|
||||
tauri/src-tauri/gen/partial.plist
|
||||
|
||||
# Temporary
|
||||
tmp/
|
||||
|
||||
+29
-2
@@ -7,9 +7,25 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
This release rewrites the backend into a modular architecture, migrates the documentation site to Fumadocs, and ships a batch of bug fixes and UI polish across the stack.
|
||||
## [0.3.0] - 2026-03-17
|
||||
|
||||
The backend's 3,000-line monolith `main.py` has been decomposed into domain routers, a services layer, and a proper database package. A style guide and ruff configuration now enforce consistency. On the frontend, model loading status is now visible in the UI, effects presets get a dropdown, and several race conditions and accessibility gaps are closed.
|
||||
This release rewrites the backend into a modular architecture, overhauls the settings UI into routed sub-pages, fixes audio player freezing, migrates documentation to Fumadocs, and ships a batch of bug fixes targeting the most-reported issues from the tracker.
|
||||
|
||||
The backend's 3,000-line monolith `main.py` has been decomposed into domain routers, a services layer, and a proper database package. A style guide and ruff configuration now enforce consistency. On the frontend, settings have been split into dedicated routed pages with server logs, a changelog viewer, and an about page. The audio player no longer freezes mid-playback, and model loading status is now visible in the UI. Seven user-reported bugs have been fixed, including server crashes during sample uploads, generation list staleness, cryptic error messages, and CUDA support for RTX 50-series GPUs.
|
||||
|
||||
### Settings Overhaul ([#294](https://github.com/jamiepine/voicebox/pull/294))
|
||||
- Split settings into routed sub-tabs: General, Generation, GPU, Logs, Changelog, About
|
||||
- Added live server log viewer with auto-scroll
|
||||
- Added in-app changelog page that parses `CHANGELOG.md` at build time
|
||||
- Added About page with version info, license, and generation folder quick-open
|
||||
- Extracted reusable `SettingRow` component for consistent setting layouts
|
||||
|
||||
### Audio Player Fix ([#293](https://github.com/jamiepine/voicebox/pull/293))
|
||||
- Fixed audio player freezing during playback
|
||||
- Improved playback UX with better state management and listener cleanup
|
||||
- Fixed restart race condition during regeneration
|
||||
- Added stable keys for audio element re-rendering
|
||||
- Improved accessibility across player controls
|
||||
|
||||
### Backend Refactor ([#285](https://github.com/jamiepine/voicebox/pull/285))
|
||||
- Extracted all routes from `main.py` into 13 domain routers under `backend/routes/` — `main.py` dropped from ~3,100 lines to ~10
|
||||
@@ -40,6 +56,17 @@ The backend's 3,000-line monolith `main.py` has been decomposed into domain rout
|
||||
- Softened select focus indicator opacity
|
||||
- Addressed 4 critical and 12 major issues from CodeRabbit review
|
||||
|
||||
### Bug Fixes ([#295](https://github.com/jamiepine/voicebox/pull/295))
|
||||
- Fixed sample uploads crashing the server — audio decoding now runs in a thread pool instead of blocking the async event loop ([#278](https://github.com/jamiepine/voicebox/issues/278))
|
||||
- Fixed generation list not updating when a generation completes — switched to `refetchQueries` for reliable cache busting, added SSE error fallback, and page reset on completion ([#231](https://github.com/jamiepine/voicebox/issues/231))
|
||||
- Fixed error toasts showing `[object Object]` instead of the actual error message ([#290](https://github.com/jamiepine/voicebox/issues/290))
|
||||
- Added Whisper model selection (`base`, `small`, `medium`, `large`, `turbo`) and expanded language support to the `/transcribe` endpoint ([#233](https://github.com/jamiepine/voicebox/issues/233))
|
||||
- Upgraded CUDA backend build from cu121 to cu126 for RTX 50-series (Blackwell) GPU support ([#289](https://github.com/jamiepine/voicebox/issues/289))
|
||||
- Handled client disconnects in SSE and streaming endpoints to suppress `[Errno 32] Broken Pipe` errors ([#248](https://github.com/jamiepine/voicebox/issues/248))
|
||||
- Fixed Docker build failure from pip hash mismatch on Qwen3-TTS dependencies ([#286](https://github.com/jamiepine/voicebox/issues/286))
|
||||
- Added 50 MB upload size limit with chunked reads to prevent unbounded memory allocation on sample uploads
|
||||
- Eliminated redundant double audio decode in sample processing pipeline
|
||||
|
||||
### Platform Fixes
|
||||
- Replaced `netstat` with `TcpStream` + PowerShell for Windows port detection ([#277](https://github.com/jamiepine/voicebox/pull/277))
|
||||
- Fixed Docker frontend build and cleaned up Docker docs
|
||||
|
||||
@@ -31,6 +31,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
RUN pip install --no-cache-dir --prefix=/install \
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.2.3",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -153,7 +153,9 @@ export function HistoryTable() {
|
||||
}
|
||||
}, [historyData, page]);
|
||||
|
||||
// Reset to page 0 when deletions or imports occur
|
||||
// Reset to page 0 when deletions, imports, or generation completions occur
|
||||
const pendingCount = useGenerationStore((state) => state.pendingGenerationIds.size);
|
||||
const prevPendingCountRef = useRef(pendingCount);
|
||||
useEffect(() => {
|
||||
if (deleteGeneration.isSuccess || importGeneration.isSuccess) {
|
||||
setPage(0);
|
||||
@@ -161,6 +163,19 @@ export function HistoryTable() {
|
||||
}
|
||||
}, [deleteGeneration.isSuccess, importGeneration.isSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
// A generation finished (pending count decreased) — scroll back to show it
|
||||
if (
|
||||
prevPendingCountRef.current > 0 &&
|
||||
pendingCount < prevPendingCountRef.current &&
|
||||
page !== 0
|
||||
) {
|
||||
setPage(0);
|
||||
setAllHistory([]);
|
||||
}
|
||||
prevPendingCountRef.current = pendingCount;
|
||||
}, [pendingCount, page]);
|
||||
|
||||
// Intersection Observer for infinite scroll
|
||||
useEffect(() => {
|
||||
const loadMoreEl = loadMoreRef.current;
|
||||
|
||||
@@ -124,7 +124,7 @@ export function AboutPage() {
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-muted-foreground/60 transition-colors"
|
||||
>
|
||||
BSL 1.1
|
||||
MIT
|
||||
</a>
|
||||
</p>
|
||||
</FadeIn>
|
||||
|
||||
@@ -57,8 +57,8 @@ function renderMarkdown(md: string): React.ReactNode[] {
|
||||
}
|
||||
elements.push(
|
||||
<ul key={elements.length} className="space-y-1 my-2">
|
||||
{items.map((item) => (
|
||||
<li key={item} className="text-sm text-muted-foreground flex gap-2">
|
||||
{items.map((item, idx) => (
|
||||
<li key={idx} className="text-sm text-muted-foreground flex gap-2">
|
||||
<span className="text-muted-foreground/50 select-none shrink-0">•</span>
|
||||
<span>{inlineMarkdown(item)}</span>
|
||||
</li>
|
||||
@@ -96,9 +96,9 @@ function renderTable(tableLines: string[], keyBase: number): React.ReactNode {
|
||||
<table className="text-sm w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
{headers.map((h) => (
|
||||
{headers.map((h, hIdx) => (
|
||||
<th
|
||||
key={h}
|
||||
key={hIdx}
|
||||
className="text-left py-1.5 pr-4 text-muted-foreground font-medium text-xs"
|
||||
>
|
||||
{inlineMarkdown(h)}
|
||||
@@ -107,10 +107,10 @@ function renderTable(tableLines: string[], keyBase: number): React.ReactNode {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.join('|')} className="border-b border-border/50">
|
||||
{row.map((cell) => (
|
||||
<td key={cell} className="py-1.5 pr-4 text-muted-foreground">
|
||||
{rows.map((row, rowIdx) => (
|
||||
<tr key={rowIdx} className="border-b border-border/50">
|
||||
{row.map((cell, cellIdx) => (
|
||||
<td key={cellIdx} className="py-1.5 pr-4 text-muted-foreground">
|
||||
{inlineMarkdown(cell)}
|
||||
</td>
|
||||
))}
|
||||
|
||||
@@ -133,6 +133,13 @@ export function GeneralPage() {
|
||||
setKeepServerRunningOnClose(checked);
|
||||
platform.lifecycle.setKeepServerRunning(checked).catch((error) => {
|
||||
console.error('Failed to sync setting to Rust:', error);
|
||||
setKeepServerRunningOnClose(!checked);
|
||||
toast({
|
||||
title: 'Failed to update setting',
|
||||
description: 'Could not sync setting to backend.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
});
|
||||
toast({
|
||||
title: 'Setting updated',
|
||||
|
||||
@@ -171,17 +171,21 @@ export function GpuPage() {
|
||||
};
|
||||
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
|
||||
|
||||
const clearHealthPolling = useCallback(() => {
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startHealthPolling = useCallback(() => {
|
||||
if (healthPollRef.current) return;
|
||||
clearHealthPolling();
|
||||
|
||||
healthPollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const result = await apiClient.getHealth();
|
||||
if (result.status === 'healthy') {
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
clearHealthPolling();
|
||||
setRestartPhase('ready');
|
||||
queryClient.invalidateQueries();
|
||||
setTimeout(() => setRestartPhase('idle'), 2000);
|
||||
@@ -190,7 +194,23 @@ export function GpuPage() {
|
||||
// Server still down, keep polling
|
||||
}
|
||||
}, 1000);
|
||||
}, [queryClient]);
|
||||
}, [queryClient, clearHealthPolling]);
|
||||
|
||||
const restartServerWithPolling = useCallback(
|
||||
async (errorMessage: string) => {
|
||||
setRestartPhase('stopping');
|
||||
try {
|
||||
await platform.lifecycle.restartServer();
|
||||
setRestartPhase('waiting');
|
||||
startHealthPolling();
|
||||
} catch (e: unknown) {
|
||||
clearHealthPolling();
|
||||
setRestartPhase('idle');
|
||||
throw new Error(e instanceof Error ? e.message : errorMessage);
|
||||
}
|
||||
},
|
||||
[platform, startHealthPolling, clearHealthPolling],
|
||||
);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setError(null);
|
||||
@@ -209,24 +229,9 @@ export function GpuPage() {
|
||||
|
||||
const handleRestart = async () => {
|
||||
setError(null);
|
||||
setRestartPhase('stopping');
|
||||
try {
|
||||
setRestartPhase('waiting');
|
||||
startHealthPolling();
|
||||
await platform.lifecycle.restartServer();
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
setRestartPhase('ready');
|
||||
queryClient.invalidateQueries();
|
||||
setTimeout(() => setRestartPhase('idle'), 2000);
|
||||
await restartServerWithPolling('Restart failed');
|
||||
} catch (e: unknown) {
|
||||
setRestartPhase('idle');
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
setError(e instanceof Error ? e.message : 'Restart failed');
|
||||
}
|
||||
};
|
||||
@@ -236,22 +241,8 @@ export function GpuPage() {
|
||||
setRestartPhase('stopping');
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
setRestartPhase('waiting');
|
||||
startHealthPolling();
|
||||
await platform.lifecycle.restartServer();
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
setRestartPhase('ready');
|
||||
queryClient.invalidateQueries();
|
||||
setTimeout(() => setRestartPhase('idle'), 2000);
|
||||
await restartServerWithPolling('Failed to switch to CPU');
|
||||
} catch (e: unknown) {
|
||||
setRestartPhase('idle');
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
|
||||
refetchCudaStatus();
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function LogsPage() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
entries.map((entry, i) => <LogLine key={`${entry.timestamp}-${i}`} entry={entry} />)
|
||||
entries.map((entry) => <LogLine key={entry.id} entry={entry} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@ export function SettingRow({
|
||||
<div className="min-w-0">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className="text-sm font-medium leading-none cursor-pointer select-none"
|
||||
className={`text-sm font-medium leading-none select-none ${htmlFor ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
{title}
|
||||
</label>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function Toaster() {
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
))}
|
||||
<ToastViewport className={isPlayerOpen ? 'sm:bottom-32' : ''} />
|
||||
<ToastViewport className={isPlayerOpen ? 'sm:bottom-44' : ''} />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(
|
||||
}}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
checked ? 'bg-accent' : 'bg-muted-foreground/25',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
className,
|
||||
|
||||
+35
-12
@@ -32,8 +32,24 @@ import type {
|
||||
TranscriptionResponse,
|
||||
VoiceProfileCreate,
|
||||
VoiceProfileResponse,
|
||||
WhisperModelSize,
|
||||
} from './types';
|
||||
|
||||
function formatErrorDetail(detail: unknown, fallback: string): string {
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
return detail
|
||||
.map((e: Record<string, unknown>) => e.msg || e.message || JSON.stringify(e))
|
||||
.join('; ');
|
||||
}
|
||||
if (detail && typeof detail === 'object') {
|
||||
const obj = detail as Record<string, unknown>;
|
||||
if (typeof obj.message === 'string') return obj.message;
|
||||
return JSON.stringify(detail);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private getBaseUrl(): string {
|
||||
const serverUrl = useServerStore.getState().serverUrl;
|
||||
@@ -54,7 +70,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -113,7 +129,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -147,7 +163,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
@@ -167,7 +183,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -187,7 +203,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -257,7 +273,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
@@ -271,7 +287,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
@@ -297,7 +313,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -318,12 +334,19 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// Transcription
|
||||
async transcribeAudio(file: File, language?: LanguageCode): Promise<TranscriptionResponse> {
|
||||
async transcribeAudio(
|
||||
file: File,
|
||||
language?: LanguageCode,
|
||||
model?: WhisperModelSize,
|
||||
): Promise<TranscriptionResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (language) {
|
||||
formData.append('language', language);
|
||||
}
|
||||
if (model) {
|
||||
formData.append('model', model);
|
||||
}
|
||||
|
||||
const url = `${this.getBaseUrl()}/transcribe`;
|
||||
const response = await fetch(url, {
|
||||
@@ -335,7 +358,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -608,7 +631,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
@@ -705,7 +728,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
|
||||
@@ -99,8 +99,11 @@ export interface HistoryListResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type WhisperModelSize = 'base' | 'small' | 'medium' | 'large' | 'turbo';
|
||||
|
||||
export interface TranscriptionRequest {
|
||||
language?: LanguageCode;
|
||||
model?: WhisperModelSize;
|
||||
}
|
||||
|
||||
export interface TranscriptionResponse {
|
||||
|
||||
@@ -75,8 +75,8 @@ export function useGenerationProgress() {
|
||||
currentSources.delete(id);
|
||||
removePendingGeneration(id);
|
||||
|
||||
// Refresh history to pick up the completed generation
|
||||
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||
// Refetch history to pick up the completed generation
|
||||
queryClient.refetchQueries({ queryKey: ['history'] });
|
||||
|
||||
// If this generation was queued for a story, add it now
|
||||
const storyId = removePendingStoryAdd(id);
|
||||
@@ -120,7 +120,7 @@ export function useGenerationProgress() {
|
||||
removePendingGeneration(id);
|
||||
removePendingStoryAdd(id);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||
queryClient.refetchQueries({ queryKey: ['history'] });
|
||||
|
||||
toast({
|
||||
title: data.status === 'not_found' ? 'Generation not found' : 'Generation failed',
|
||||
@@ -134,11 +134,12 @@ export function useGenerationProgress() {
|
||||
};
|
||||
|
||||
source.onerror = () => {
|
||||
// EventSource auto-reconnects, but if we get repeated errors
|
||||
// just clean up
|
||||
// SSE connection dropped — clean up and refresh history so any
|
||||
// completed/failed generation still appears in the list
|
||||
source.close();
|
||||
currentSources.delete(id);
|
||||
removePendingGeneration(id);
|
||||
queryClient.refetchQueries({ queryKey: ['history'] });
|
||||
};
|
||||
|
||||
currentSources.set(id, source);
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { WhisperModelSize } from '@/lib/api/types';
|
||||
import type { LanguageCode } from '@/lib/constants/languages';
|
||||
|
||||
export function useTranscription() {
|
||||
return useMutation({
|
||||
mutationFn: ({ file, language }: { file: File; language?: LanguageCode }) =>
|
||||
apiClient.transcribeAudio(file, language),
|
||||
mutationFn: ({
|
||||
file,
|
||||
language,
|
||||
model,
|
||||
}: {
|
||||
file: File;
|
||||
language?: LanguageCode;
|
||||
model?: WhisperModelSize;
|
||||
}) => apiClient.transcribeAudio(file, language, model),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { ServerLogEntry } from '@/platform/types';
|
||||
|
||||
const MAX_LOG_ENTRIES = 2000;
|
||||
|
||||
let nextLogEntryId = 0;
|
||||
|
||||
export interface LogEntry extends ServerLogEntry {
|
||||
id: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -17,9 +20,8 @@ export const useLogStore = create<LogStore>((set) => ({
|
||||
entries: [],
|
||||
addEntry: (entry) =>
|
||||
set((state) => {
|
||||
const newEntry: LogEntry = { ...entry, timestamp: Date.now() };
|
||||
const newEntry: LogEntry = { ...entry, id: nextLogEntryId++, timestamp: Date.now() };
|
||||
const entries = [...state.entries, newEntry];
|
||||
// Cap buffer size
|
||||
if (entries.length > MAX_LOG_ENTRIES) {
|
||||
return { entries: entries.slice(entries.length - MAX_LOG_ENTRIES) };
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Backend package
|
||||
|
||||
__version__ = "0.2.3"
|
||||
__version__ = "0.3.0"
|
||||
|
||||
@@ -134,6 +134,7 @@ class STTBackend(Protocol):
|
||||
self,
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
model_size: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio to text.
|
||||
|
||||
@@ -345,18 +345,20 @@ class MLXSTTBackend:
|
||||
self,
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
model_size: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio to text.
|
||||
|
||||
Args:
|
||||
audio_path: Path to audio file
|
||||
language: Optional language hint (en or zh)
|
||||
language: Optional language hint
|
||||
model_size: Optional model size override
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
"""
|
||||
await self.load_model_async(None)
|
||||
await self.load_model_async(model_size)
|
||||
|
||||
def _transcribe_sync():
|
||||
"""Run synchronous transcription in thread pool."""
|
||||
|
||||
@@ -306,18 +306,20 @@ class PyTorchSTTBackend:
|
||||
self,
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
model_size: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio to text.
|
||||
|
||||
Args:
|
||||
audio_path: Path to audio file
|
||||
language: Optional language hint (en or zh)
|
||||
language: Optional language hint
|
||||
model_size: Optional model size override
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
"""
|
||||
await self.load_model_async(None)
|
||||
await self.load_model_async(model_size)
|
||||
|
||||
def _transcribe_sync():
|
||||
"""Run synchronous transcription in thread pool."""
|
||||
|
||||
+2
-1
@@ -149,7 +149,8 @@ class HistoryListResponse(BaseModel):
|
||||
class TranscriptionRequest(BaseModel):
|
||||
"""Request model for audio transcription."""
|
||||
|
||||
language: Optional[str] = Field(None, pattern="^(en|zh)$")
|
||||
language: Optional[str] = Field(None, pattern="^(en|zh|ja|ko|de|fr|ru|pt|es|it)$")
|
||||
model: Optional[str] = Field(None, pattern="^(base|small|medium|large|turbo)$")
|
||||
|
||||
|
||||
class TranscriptionResponse(BaseModel):
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""TTS generation endpoints."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .. import models
|
||||
from ..services import history, profiles, tts
|
||||
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
|
||||
@@ -181,25 +184,28 @@ async def get_generation_status(generation_id: str, db: Session = Depends(get_db
|
||||
import json
|
||||
|
||||
async def event_stream():
|
||||
while True:
|
||||
db.expire_all()
|
||||
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||
if not gen:
|
||||
yield f"data: {json.dumps({'status': 'not_found', 'id': generation_id})}\n\n"
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
db.expire_all()
|
||||
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||
if not gen:
|
||||
yield f"data: {json.dumps({'status': 'not_found', 'id': generation_id})}\n\n"
|
||||
return
|
||||
|
||||
payload = {
|
||||
"id": gen.id,
|
||||
"status": gen.status or "completed",
|
||||
"duration": gen.duration,
|
||||
"error": gen.error,
|
||||
}
|
||||
yield f"data: {json.dumps(payload)}\n\n"
|
||||
payload = {
|
||||
"id": gen.id,
|
||||
"status": gen.status or "completed",
|
||||
"duration": gen.duration,
|
||||
"error": gen.error,
|
||||
}
|
||||
yield f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
if (gen.status or "completed") in ("completed", "failed"):
|
||||
return
|
||||
if (gen.status or "completed") in ("completed", "failed"):
|
||||
return
|
||||
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(1)
|
||||
except (BrokenPipeError, ConnectionResetError, asyncio.CancelledError):
|
||||
logger.debug("SSE client disconnected for generation %s", generation_id)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
@@ -265,9 +271,12 @@ async def stream_speech(
|
||||
wav_bytes = tts.audio_to_wav_bytes(audio, sample_rate)
|
||||
|
||||
async def _wav_stream():
|
||||
chunk_size = 64 * 1024
|
||||
for i in range(0, len(wav_bytes), chunk_size):
|
||||
yield wav_bytes[i : i + chunk_size]
|
||||
try:
|
||||
chunk_size = 64 * 1024
|
||||
for i in range(0, len(wav_bytes), chunk_size):
|
||||
yield wav_bytes[i : i + chunk_size]
|
||||
except (BrokenPipeError, ConnectionResetError, asyncio.CancelledError):
|
||||
logger.debug("Client disconnected during audio stream")
|
||||
|
||||
return StreamingResponse(
|
||||
_wav_stream(),
|
||||
|
||||
@@ -102,6 +102,10 @@ async def delete_profile(
|
||||
return {"message": "Profile deleted successfully"}
|
||||
|
||||
|
||||
SAMPLE_MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
SAMPLE_UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1 MB
|
||||
|
||||
|
||||
@router.post("/profiles/{profile_id}/samples", response_model=models.ProfileSampleResponse)
|
||||
async def add_profile_sample(
|
||||
profile_id: str,
|
||||
@@ -115,8 +119,16 @@ async def add_profile_sample(
|
||||
file_suffix = _uploaded_ext if _uploaded_ext in _allowed_audio_exts else ".wav"
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=file_suffix, delete=False) as tmp:
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
total_size = 0
|
||||
while chunk := await file.read(SAMPLE_UPLOAD_CHUNK_SIZE):
|
||||
total_size += len(chunk)
|
||||
if total_size > SAMPLE_MAX_FILE_SIZE:
|
||||
Path(tmp.name).unlink(missing_ok=True)
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"File too large (max {SAMPLE_MAX_FILE_SIZE // (1024 * 1024)} MB)",
|
||||
)
|
||||
tmp.write(chunk)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
|
||||
@@ -20,6 +20,7 @@ UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1MB
|
||||
async def transcribe_audio(
|
||||
file: UploadFile = File(...),
|
||||
language: str | None = Form(None),
|
||||
model: str | None = Form(None),
|
||||
):
|
||||
"""Transcribe audio file to text."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
@@ -29,14 +30,23 @@ async def transcribe_audio(
|
||||
|
||||
try:
|
||||
from ..utils.audio import load_audio
|
||||
from ..backends import WHISPER_HF_REPOS
|
||||
|
||||
audio, sr = await asyncio.to_thread(load_audio, tmp_path)
|
||||
duration = len(audio) / sr
|
||||
|
||||
whisper_model = transcribe.get_whisper_model()
|
||||
model_size = whisper_model.model_size
|
||||
model_size = model if model else whisper_model.model_size
|
||||
|
||||
if not whisper_model.is_loaded() and not whisper_model._is_model_cached(model_size):
|
||||
valid_sizes = list(WHISPER_HF_REPOS.keys())
|
||||
if model_size not in valid_sizes:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid model size '{model_size}'. Must be one of: {', '.join(valid_sizes)}",
|
||||
)
|
||||
|
||||
already_loaded = whisper_model.is_loaded() and whisper_model.model_size == model_size
|
||||
if not already_loaded and not whisper_model._is_model_cached(model_size):
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
task_manager = get_task_manager()
|
||||
|
||||
@@ -59,7 +69,7 @@ async def transcribe_audio(
|
||||
},
|
||||
)
|
||||
|
||||
text = await whisper_model.transcribe(tmp_path, language)
|
||||
text = await whisper_model.transcribe(tmp_path, language, model_size)
|
||||
|
||||
return models.TranscriptionResponse(
|
||||
text=text,
|
||||
|
||||
@@ -22,7 +22,7 @@ from ..database import (
|
||||
Generation as DBGeneration,
|
||||
)
|
||||
from ..models import EffectConfig
|
||||
from ..utils.audio import validate_reference_audio, load_audio, save_audio
|
||||
from ..utils.audio import validate_reference_audio, validate_and_load_reference_audio, load_audio, save_audio
|
||||
from ..utils.images import validate_image, process_avatar
|
||||
from ..utils.cache import _get_cache_dir, clear_profile_cache
|
||||
from .tts import get_tts_model
|
||||
@@ -117,11 +117,16 @@ async def add_profile_sample(
|
||||
Returns:
|
||||
Created sample
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||
if not profile:
|
||||
raise ValueError(f"Profile {profile_id} not found")
|
||||
|
||||
is_valid, error_msg = validate_reference_audio(audio_path)
|
||||
# Validate and load audio in a single pass, off the event loop
|
||||
is_valid, error_msg, audio, sr = await asyncio.to_thread(
|
||||
validate_and_load_reference_audio, audio_path
|
||||
)
|
||||
if not is_valid:
|
||||
raise ValueError(f"Invalid reference audio: {error_msg}")
|
||||
|
||||
@@ -130,8 +135,7 @@ async def add_profile_sample(
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dest_path = profile_dir / f"{sample_id}.wav"
|
||||
audio, sr = load_audio(audio_path)
|
||||
save_audio(audio, str(dest_path), sr)
|
||||
await asyncio.to_thread(save_audio, audio, str(dest_path), sr)
|
||||
|
||||
db_sample = DBProfileSample(
|
||||
id=sample_id,
|
||||
|
||||
+24
-6
@@ -217,22 +217,40 @@ def validate_reference_audio(
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
result = validate_and_load_reference_audio(
|
||||
audio_path, min_duration, max_duration, min_rms
|
||||
)
|
||||
return (result[0], result[1])
|
||||
|
||||
|
||||
def validate_and_load_reference_audio(
|
||||
audio_path: str,
|
||||
min_duration: float = 2.0,
|
||||
max_duration: float = 30.0,
|
||||
min_rms: float = 0.01,
|
||||
) -> Tuple[bool, Optional[str], Optional[np.ndarray], Optional[int]]:
|
||||
"""
|
||||
Validate and load reference audio in a single pass.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message, audio_array, sample_rate)
|
||||
"""
|
||||
try:
|
||||
audio, sr = load_audio(audio_path)
|
||||
duration = len(audio) / sr
|
||||
|
||||
if duration < min_duration:
|
||||
return False, f"Audio too short (minimum {min_duration} seconds)"
|
||||
return False, f"Audio too short (minimum {min_duration} seconds)", None, None
|
||||
if duration > max_duration:
|
||||
return False, f"Audio too long (maximum {max_duration} seconds)"
|
||||
return False, f"Audio too long (maximum {max_duration} seconds)", None, None
|
||||
|
||||
rms = np.sqrt(np.mean(audio**2))
|
||||
if rms < min_rms:
|
||||
return False, "Audio is too quiet or silent"
|
||||
return False, "Audio is too quiet or silent", None, None
|
||||
|
||||
if np.abs(audio).max() > 0.99:
|
||||
return False, "Audio is clipping (reduce input gain)"
|
||||
return False, "Audio is clipping (reduce input gain)", None, None
|
||||
|
||||
return True, None
|
||||
return True, None, audio, sr
|
||||
except Exception as e:
|
||||
return False, f"Error validating audio: {str(e)}"
|
||||
return False, f"Error validating audio: {str(e)}", None, None
|
||||
|
||||
@@ -246,6 +246,8 @@ class ProgressManager:
|
||||
# Send heartbeat
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
except (BrokenPipeError, ConnectionResetError, asyncio.CancelledError):
|
||||
logger.debug(f"SSE client disconnected from {model_name}")
|
||||
finally:
|
||||
# Remove from listeners
|
||||
if model_name in self._listeners:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.2.3",
|
||||
"version": "0.3.0",
|
||||
"description": "Landing page for voicebox.sh",
|
||||
"scripts": {
|
||||
"dev": "bun --bun next dev --turbo",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "voicebox",
|
||||
"version": "0.2.3",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"app",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@voicebox/tauri",
|
||||
"private": true,
|
||||
"version": "0.2.3",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "voicebox"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
|
||||
@@ -23,15 +23,15 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
let project_root = env!("CARGO_MANIFEST_DIR");
|
||||
let gen_dir = format!("{}/gen", project_root);
|
||||
std::fs::create_dir_all(&gen_dir).expect("Failed to create gen directory");
|
||||
|
||||
// Compile macOS Liquid Glass icon
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let project_root = env!("CARGO_MANIFEST_DIR");
|
||||
// voicebox.icon is in tauri/assets/voicebox.icon (one level up from src-tauri)
|
||||
let icon_source = format!("{}/../assets/voicebox.icon", project_root);
|
||||
let gen_dir = format!("{}/gen", project_root);
|
||||
|
||||
std::fs::create_dir_all(&gen_dir).expect("Failed to create gen directory");
|
||||
|
||||
if std::path::Path::new(&icon_source).exists() {
|
||||
println!("cargo:rerun-if-changed={}", icon_source);
|
||||
@@ -76,6 +76,71 @@ fn main() {
|
||||
panic!("Icon compilation failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Generate voicebox.icns from the source PNG via sips + iconutil
|
||||
let icns_path = format!("{}/voicebox.icns", gen_dir);
|
||||
if !std::path::Path::new(&icns_path).exists() {
|
||||
let source_png = format!("{}/Assets/Voicebox.png", icon_source);
|
||||
if std::path::Path::new(&source_png).exists() {
|
||||
let iconset_dir = format!("{}/voicebox.iconset", gen_dir);
|
||||
std::fs::create_dir_all(&iconset_dir).ok();
|
||||
|
||||
let sizes: &[(u32, &str)] = &[
|
||||
(16, "icon_16x16.png"),
|
||||
(32, "[email protected]"),
|
||||
(32, "icon_32x32.png"),
|
||||
(64, "[email protected]"),
|
||||
(128, "icon_128x128.png"),
|
||||
(256, "[email protected]"),
|
||||
(256, "icon_256x256.png"),
|
||||
(512, "[email protected]"),
|
||||
(512, "icon_512x512.png"),
|
||||
(1024, "[email protected]"),
|
||||
];
|
||||
|
||||
for (size, name) in sizes {
|
||||
let dest = format!("{}/{}", iconset_dir, name);
|
||||
let status = Command::new("sips")
|
||||
.args([
|
||||
"-z",
|
||||
&size.to_string(),
|
||||
&size.to_string(),
|
||||
&source_png,
|
||||
"--out",
|
||||
&dest,
|
||||
])
|
||||
.output();
|
||||
if let Ok(out) = status {
|
||||
if !out.status.success() {
|
||||
eprintln!(
|
||||
"sips failed for {}: {}",
|
||||
name,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let iconutil_output = Command::new("iconutil")
|
||||
.args(["-c", "icns", "-o", &icns_path, &iconset_dir])
|
||||
.output();
|
||||
|
||||
match iconutil_output {
|
||||
Ok(out) if out.status.success() => {
|
||||
println!("Generated voicebox.icns");
|
||||
}
|
||||
Ok(out) => {
|
||||
eprintln!("iconutil failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to run iconutil: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up iconset directory
|
||||
std::fs::remove_dir_all(&iconset_dir).ok();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"cargo:warning=Icon source not found at {}, skipping icon compilation",
|
||||
@@ -84,5 +149,18 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all resource files exist so Tauri's bundler doesn't fail.
|
||||
// On non-macOS these are always stubs. On macOS, actool may not produce
|
||||
// Assets.car if the Xcode version doesn't support the .icon format.
|
||||
{
|
||||
let required = ["Assets.car", "voicebox.icns", "partial.plist"];
|
||||
for name in required {
|
||||
let path = format!("{}/{}", gen_dir, name);
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
std::fs::write(&path, b"").ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Voicebox",
|
||||
"version": "0.2.3",
|
||||
"version": "0.3.0",
|
||||
"identifier": "sh.voicebox.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
@@ -88,16 +88,27 @@ class TauriLifecycle implements PlatformLifecycle {
|
||||
}
|
||||
|
||||
subscribeToServerLogs(callback: (entry: ServerLogEntry) => void): () => void {
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
listen<ServerLogEntry>('server-log', (event) => {
|
||||
void listen<ServerLogEntry>('server-log', (event) => {
|
||||
callback(event.payload);
|
||||
}).then((fn) => {
|
||||
unlisten = fn;
|
||||
});
|
||||
})
|
||||
.then((fn) => {
|
||||
if (disposed) {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
unlisten = fn;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to subscribe to server logs:', error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
unlisten = null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@voicebox/web",
|
||||
"private": true,
|
||||
"version": "0.2.3",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user