diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 584788c0..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - main - -jobs: - frontend-quality: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Typecheck app + web - run: bun run typecheck - - - name: Build web smoke test - run: bun run build:web diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index edf29609..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,310 +0,0 @@ -name: Release - -on: - workflow_dispatch: - push: - tags: - - "v*" - -jobs: - release: - permissions: - contents: write - strategy: - fail-fast: false - matrix: - include: - - platform: "macos-latest" - args: "--target aarch64-apple-darwin" - python-version: "3.12" - backend: "mlx" - - platform: "macos-15-intel" - args: "--target x86_64-apple-darwin" - python-version: "3.12" - backend: "pytorch" - - platform: "windows-latest" - args: "" - python-version: "3.12" - backend: "pytorch" - - platform: "ubuntu-22.04" - # --config override disables updater-artifact generation on Linux. - # tauri.conf.json has createUpdaterArtifacts: "v1Compatible" which - # on Linux wants to synthesize a .AppImage.tar.gz by downloading - # linuxdeploy at build time — this is what silently hangs CI - # (see v0.4.2 round 2, 25 min of no output after rpm bundling). - # We ship deb+rpm only; Linux users update via apt/dnf, not the - # Tauri in-app updater. - args: '--target x86_64-unknown-linux-gnu --bundles deb,rpm --verbose --config {"bundle":{"createUpdaterArtifacts":false}}' - python-version: "3.12" - backend: "pytorch" - - runs-on: ${{ matrix.platform }} - - steps: - - uses: actions/checkout@v4 - - # Ubuntu runners ship with ~14 GB free; pip + PyInstaller + torch can - # peak well above that during the build. Reclaim ~25 GB by pruning - # preinstalled toolchains we don't use. This is what likely tripped - # the March 2026 Linux release attempts (see commit 103e98b - # "github runners suck") — not a code issue, a disk-pressure one. - - name: Free up disk space (ubuntu) - if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace') - # Pinned to v1.3.1 (SHA) — this job runs with contents: write and - # handles signing secrets later, so we don't want a floating ref. - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be - with: - tool-cache: false - android: true - dotnet: true - haskell: true - # large-packages: true would `apt-get remove '^llvm-.*'`, which - # cascade-removes reverse deps that won't be pulled back in by the - # `llvm-dev` install below. The other flags already free ~20 GB, - # enough for the Python + torch + PyInstaller build. - large-packages: false - swap-storage: true - - - name: Install dependencies (ubuntu only) - if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace') - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev libasound2-dev - - - name: Install LLVM (macOS) - if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel' - run: | - brew install llvm@20 - echo "$(brew --prefix llvm@20)/bin" >> $GITHUB_PATH - echo "LLVM_CONFIG=$(brew --prefix llvm@20)/bin/llvm-config" >> $GITHUB_ENV - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: "pip" - - - name: Install CPU-only PyTorch (Linux) - if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace') - run: | - pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu - - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install pyinstaller - pip install -r backend/requirements.txt - pip install --no-deps chatterbox-tts - pip install --no-deps hume-tada - - - name: Install MLX dependencies (Apple Silicon only) - if: matrix.backend == 'mlx' - run: | - pip install -r backend/requirements-mlx.txt - # mlx-audio>=0.3.1 and mlx-lm>=0.31.1 both declare transformers>=5.x, - # which conflicts with our 4.57.x cap. The runtime APIs we use work - # fine on transformers 4.57.x in practice (verified in dev), so install - # them --no-deps. mlx-audio's other runtime deps (huggingface_hub, - # librosa, numpy, numba, pyloudnorm) are already in requirements.txt; - # miniaudio is in requirements-mlx.txt (needed by mlx_audio.stt, - # not transitively pulled by anything else — see issue #505); the - # rest (sounddevice, protobuf, sentencepiece, pyyaml, jinja2) are - # pulled in by other engines. - pip install --no-deps mlx-lm==0.31.1 - pip install --no-deps mlx-audio==0.4.1 - - - name: Build Python server (Linux/macOS) - if: matrix.platform != 'windows-latest' - run: | - chmod +x scripts/build-server.sh - ./scripts/build-server.sh - - - name: Build Python server (Windows) - if: matrix.platform == 'windows-latest' - shell: bash - run: | - cd backend - python build_binary.py - - # Get platform tuple - PLATFORM=$(rustc --print host-tuple) - - # Create binaries directory - mkdir -p ../tauri/src-tauri/binaries - - # Copy with platform suffix - cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe - echo "Built voicebox-server-${PLATFORM}.exe" - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Install Rust stable - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ (matrix.platform == 'macos-latest' && 'aarch64-apple-darwin') || (matrix.platform == 'macos-15-intel' && 'x86_64-apple-darwin') || '' }} - - - name: Rust cache - uses: swatinem/rust-cache@v2 - with: - workspaces: "./tauri/src-tauri -> target" - - - name: Install dependencies - run: bun install - - - name: Install Apple API key - if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel' - run: | - mkdir -p ~/.appstoreconnect/private_keys/ - cd ~/.appstoreconnect/private_keys/ - echo ${{ secrets.APPLE_API_KEY_BASE64 }} >> AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64 - base64 --decode -i AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64 -o AuthKey_${{ secrets.APPLE_API_KEY }}.p8 - rm AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64 - - - name: Install Codesigning Certificate - if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel' - uses: apple-actions/import-codesign-certs@v3 - with: - p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} - p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - - - name: Disk / environment snapshot (pre-bundle debug) - if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace') - run: | - echo "=== df -h ===" - df -h - echo "=== free -h ===" - free -h - echo "=== Rust / Cargo ===" - rustc --version - cargo --version - echo "=== Bun ===" - bun --version - echo "=== Tauri CLI ===" - cd tauri && bun run tauri --version - - - name: Extract release notes from CHANGELOG.md - id: changelog - shell: bash - run: | - # Get the version from the tag (strip leading 'v') - VERSION="${GITHUB_REF_NAME#v}" - - # Extract the section for this version from CHANGELOG.md - # Matches from "## [X.Y.Z]" until the next "## [" heading - NOTES=$(sed -n "/^## \[${VERSION}\]/,/^## \[/{/^## \[${VERSION}\]/d;/^## \[/d;p;}" CHANGELOG.md) - - # Fall back to a placeholder if the version isn't in the changelog - if [ -z "$(echo "$NOTES" | tr -d '[:space:]')" ]; then - NOTES="See the assets below to download and install this version." - fi - - # Use multiline output syntax - { - echo "notes<> "$GITHUB_OUTPUT" - - # Linux hang watchdog: previous releases silently wedged inside tauri - # bundling (possibly linuxdeploy/AppImage download, possibly cargo link). - # Cap the step at 30 min so we get logs instead of waiting out the 6hr - # job timeout. Other platforms historically complete in ~25 min, so 45 - # is comfortable. - - uses: tauri-apps/tauri-action@v0.6 - timeout-minutes: ${{ (contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')) && 30 || 45 }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_PROVIDER_SHORT_NAME: ${{ secrets.APPLE_PROVIDER_SHORT_NAME }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - # Stream subprocess stdout/stderr so the hang is visible in logs. - CARGO_TERM_VERBOSE: "true" - RUST_BACKTRACE: "1" - with: - projectPath: tauri - tagName: v__VERSION__ - releaseName: "voicebox v__VERSION__" - releaseBody: ${{ steps.changelog.outputs.notes }} - releaseDraft: true - prerelease: false - args: ${{ matrix.args }} - includeUpdaterJson: true - - build-cuda-windows: - runs-on: windows-latest - permissions: - contents: write - - steps: - - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: "pip" - - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install pyinstaller - pip install -r backend/requirements.txt - pip install --no-deps chatterbox-tts - pip install --no-deps hume-tada - - - name: Install PyTorch with CUDA 12.8 - run: | - pip install torch --index-url https://download.pytorch.org/whl/cu128 --force-reinstall --no-deps - pip install torchaudio --index-url https://download.pytorch.org/whl/cu128 --force-reinstall --no-deps - - - name: Verify CUDA support in torch - run: | - python -c "import torch; print(f'CUDA available in build: {torch.cuda.is_available()}'); print(f'CUDA version: {torch.version.cuda}')" - - - name: Build CUDA server binary (onedir) - shell: bash - working-directory: backend - env: - # Include Blackwell (sm_120) via PTX forward compatibility. - # Pre-built PyTorch cu128 wheels ship native kernels for sm_80/86/89/90 - # but not sm_120. Setting this env var causes torch.utils.cpp_extension - # (and any JIT-compiled kernels) to target Blackwell GPUs as well. - TORCH_CUDA_ARCH_LIST: "8.0;8.6;8.9;9.0;12.0+PTX" - run: python build_binary.py --cuda - - - name: Package into server core + CUDA libs archives - shell: bash - run: | - python scripts/package_cuda.py \ - backend/dist/voicebox-server-cuda/ \ - --output release-assets/ \ - --cuda-libs-version cu128-v1 \ - --torch-compat ">=2.7.0,<2.11.0" - - - name: Upload archives to GitHub Release - if: startsWith(github.ref, 'refs/tags/') - uses: softprops/action-gh-release@v2 - with: - files: | - release-assets/voicebox-server-cuda.tar.gz - release-assets/voicebox-server-cuda.tar.gz.sha256 - release-assets/cuda-libs-cu128-v1.tar.gz - release-assets/cuda-libs-cu128-v1.tar.gz.sha256 - release-assets/cuda-libs.json - draft: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload onedir as workflow artifact - uses: actions/upload-artifact@v4 - with: - name: voicebox-server-cuda-windows - path: backend/dist/voicebox-server-cuda/ - retention-days: 7 diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index 79e6a92a..ddd9e190 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -234,10 +234,10 @@ export function FloatingGenerateBox({ ref={containerRef} className={cn( 'fixed right-auto', - isStoriesRoute - ? // Position aligned with story list: after sidebar + padding, width 360px - 'left-[calc(5rem+2rem)] w-[360px]' - : 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]', + isStoriesRoute + ? // Position aligned with story list: after sidebar + padding, width 360px + 'left-[calc(5rem+2rem)] w-[360px]' + : 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]', )} style={{ // On stories route: offset by track editor height when visible diff --git a/app/src/components/ServerSettings/ModelManagement.tsx b/app/src/components/ServerSettings/ModelManagement.tsx index 4fc72321..5e137908 100644 --- a/app/src/components/ServerSettings/ModelManagement.tsx +++ b/app/src/components/ServerSettings/ModelManagement.tsx @@ -11,6 +11,7 @@ import { HardDrive, Heart, Loader2, + Plus, RotateCcw, Scale, Trash2, @@ -34,9 +35,12 @@ import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { Progress } from '@/components/ui/progress'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; @@ -248,6 +252,45 @@ export function ModelManagement() { sizeMb?: number; } | null>(null); + // Add Custom Model dialog state + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [newModelRepoId, setNewModelRepoId] = useState(''); + const [newModelDisplayName, setNewModelDisplayName] = useState(''); + + const addCustomModelMutation = useMutation({ + mutationFn: (data: { hf_repo_id: string; display_name: string }) => + apiClient.addCustomModel(data), + onSuccess: async () => { + toast({ title: 'Custom model added', description: 'The model has been registered.' }); + setAddDialogOpen(false); + setNewModelRepoId(''); + setNewModelDisplayName(''); + await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' }); + }, + onError: (error: Error) => { + toast({ title: 'Failed to add model', description: error.message, variant: 'destructive' }); + }, + }); + + const removeCustomModelMutation = useMutation({ + mutationFn: (modelId: string) => apiClient.removeCustomModel(modelId), + onSuccess: async () => { + toast({ title: 'Custom model removed', description: 'The model has been unregistered.' }); + await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' }); + }, + onError: (error: Error) => { + toast({ title: 'Failed to remove model', description: error.message, variant: 'destructive' }); + }, + }); + + const handleAddCustomModel = () => { + if (!newModelRepoId.trim() || !newModelDisplayName.trim()) return; + addCustomModelMutation.mutate({ + hf_repo_id: newModelRepoId.trim(), + display_name: newModelDisplayName.trim(), + }); + }; + const handleDownload = async (modelName: string) => { setDismissedErrors((prev) => { const next = new Set(prev); @@ -409,9 +452,11 @@ export function ModelManagement() { const whisperModels = modelStatus?.models.filter((m) => m.model_name.startsWith('whisper')) ?? []; // Build sections - const sections: { label: string; models: ModelStatus[] }[] = [ + const customModels = modelStatus?.models.filter(m => m.model_name.startsWith('custom:')) ?? []; + const sections: { label: string; models: ModelStatus[]; isCustom?: boolean }[] = [ { label: 'Voice Generation', models: voiceModels }, { label: 'Transcription', models: whisperModels }, + { label: 'Custom Models', models: customModels, isCustom: true }, ]; // Get detail modal state for selected model @@ -523,9 +568,16 @@ export function ModelManagement() {
{sections.map((section) => (
-

- {section.label} -

+
+

+ {section.label} +

+ {section.isCustom && ( + + )} +
{section.models.map((model) => { const { isDownloading, hasError } = getModelState(model); @@ -883,6 +935,28 @@ export function ModelManagement() { Delete Model + {freshSelectedModel.model_name.startsWith('custom:') && ( + + )}
) : ( + + + + + {/* Migration confirmation dialog */} { - return this.request<{ message: string }>(`/models/${modelName}`, { + return this.request<{ message: string }>(`/models/${encodeURIComponent(modelName)}`, { + method: 'DELETE', + }); + } + + // ── Custom Models ───────────────────────────────────────────────────── + // CRUD operations for user-defined HuggingFace TTS models. + // Custom models are persisted in data/custom_models.json on the backend. + // + // @author AJ - Kamyab (Ankit Jain) + + /** List all registered custom models. */ + async listCustomModels(): Promise { + return this.request('/custom-models'); + } + + /** Register a new custom HuggingFace model (does NOT trigger download). */ + async addCustomModel(data: CustomModelCreate): Promise { + return this.request('/custom-models', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + /** + * Remove a custom model from the config. + * This only removes the registration — cached HuggingFace files are NOT deleted. + * Use deleteModel("custom:slug") to also clear the HF cache. + */ + async removeCustomModel(modelId: string): Promise<{ message: string }> { + return this.request<{ message: string }>(`/custom-models/${encodeURIComponent(modelId)}`, { method: 'DELETE', }); } diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index 86e3012f..2211dcc9 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -61,7 +61,8 @@ export interface GenerationRequest { text: string; language: LanguageCode; seed?: number; - model_size?: '1.7B' | '0.6B' | '1B' | '3B'; + /** Model identifier — built-in size ("1.7B", "0.6B") or custom model ID ("custom:slug") */ + model_size?: string; engine?: | 'qwen' | 'qwen_custom_voice' @@ -70,6 +71,7 @@ export interface GenerationRequest { | 'chatterbox_turbo' | 'tada' | 'kokoro'; + /** Natural language instruction for speech delivery control (e.g. "speak slowly") */ instruct?: string; max_chunk_chars?: number; crossfade_ms?: number; @@ -187,6 +189,8 @@ export interface ModelStatus { downloading: boolean; // True if download is in progress size_mb?: number; loaded: boolean; + /** True for user-added custom HuggingFace models (model_name uses "custom:slug" format) */ + is_custom?: boolean; } export interface HuggingFaceModelInfo { @@ -213,6 +217,32 @@ export interface ModelDownloadRequest { model_name: string; } +/** + * Request payload for registering a custom HuggingFace TTS model. + * After adding, the model appears in model management and generation dropdowns. + * + * @author AJ - Kamyab (Ankit Jain) + */ +export interface CustomModelCreate { + /** Full HuggingFace repository ID, e.g. "AryanNsc/IND-QWENTTS-V1" */ + hf_repo_id: string; + /** User-friendly name shown in the UI */ + display_name: string; +} + +/** Custom model as returned by the backend after creation or listing. */ +export interface CustomModelResponse { + /** Auto-generated slug ID derived from the repo path (e.g. "aryansc-ind-qwentts-v1") */ + id: string; + hf_repo_id: string; + display_name: string; + added_at: string; +} + +export interface CustomModelListResponse { + models: CustomModelResponse[]; +} + export interface ActiveDownloadTask { model_name: string; status: string; diff --git a/app/src/lib/hooks/useGenerationForm.ts b/app/src/lib/hooks/useGenerationForm.ts index 06ec7242..c2a76cb7 100644 --- a/app/src/lib/hooks/useGenerationForm.ts +++ b/app/src/lib/hooks/useGenerationForm.ts @@ -12,11 +12,23 @@ import { useGenerationStore } from '@/stores/generationStore'; import { useServerStore } from '@/stores/serverStore'; import { useUIStore } from '@/stores/uiStore'; +/** + * Zod schema for the generation form. + * + * `modelSize` is a free-form string rather than a strict enum + * because it can be either a built-in size ("1.7B", "0.6B") or + * a custom model identifier ("custom:"). + * + * @modified AJ - Kamyab (Ankit Jain) — Changed modelSize from enum to string for custom model support + */ const generationSchema = z.object({ text: z.string().min(1, '').max(50000), language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]), seed: z.number().int().optional(), - modelSize: z.enum(['1.7B', '0.6B', '1B', '3B']).optional(), + modelSize: z.string().regex( + /^(1\.7B|0\.6B|1B|3B|custom:[a-z0-9][a-z0-9._-]*[a-z0-9])$/, + 'Must be a built-in size (1.7B, 0.6B, 1B, 3B) or custom model (custom:)', + ).optional(), instruct: z.string().max(500).optional(), engine: z .enum([ @@ -84,53 +96,67 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) { try { const engine = data.engine || 'qwen'; - const modelName = - engine === 'luxtts' - ? 'luxtts' - : engine === 'chatterbox' - ? 'chatterbox-tts' - : engine === 'chatterbox_turbo' - ? 'chatterbox-turbo' - : engine === 'tada' - ? data.modelSize === '3B' - ? 'tada-3b-ml' - : 'tada-1b' - : engine === 'kokoro' - ? 'kokoro' - : engine === 'qwen_custom_voice' - ? `qwen-custom-voice-${data.modelSize}` - : `qwen-tts-${data.modelSize}`; - const displayName = - engine === 'luxtts' - ? 'LuxTTS' - : engine === 'chatterbox' - ? 'Chatterbox TTS' - : engine === 'chatterbox_turbo' - ? 'Chatterbox Turbo' - : engine === 'tada' - ? data.modelSize === '3B' - ? 'TADA 3B Multilingual' - : 'TADA 1B' - : engine === 'kokoro' - ? 'Kokoro 82M' - : engine === 'qwen_custom_voice' - ? data.modelSize === '1.7B' - ? 'Qwen CustomVoice 1.7B' - : 'Qwen CustomVoice 0.6B' - : data.modelSize === '1.7B' - ? 'Qwen TTS 1.7B' - : 'Qwen TTS 0.6B'; + const modelSize = data.modelSize || '1.7B'; + let modelName = ''; + let displayName = ''; + + if (modelSize.startsWith('custom:')) { + modelName = modelSize; + displayName = modelSize.replace('custom:', ''); + } else { + modelName = + engine === 'luxtts' + ? 'luxtts' + : engine === 'chatterbox' + ? 'chatterbox-tts' + : engine === 'chatterbox_turbo' + ? 'chatterbox-turbo' + : engine === 'tada' + ? modelSize === '3B' + ? 'tada-3b-ml' + : 'tada-1b' + : engine === 'kokoro' + ? 'kokoro' + : engine === 'qwen_custom_voice' + ? `qwen-custom-voice-${modelSize}` + : `qwen-tts-${modelSize}`; + displayName = + engine === 'luxtts' + ? 'LuxTTS' + : engine === 'chatterbox' + ? 'Chatterbox TTS' + : engine === 'chatterbox_turbo' + ? 'Chatterbox Turbo' + : engine === 'tada' + ? modelSize === '3B' + ? 'TADA 3B Multilingual' + : 'TADA 1B' + : engine === 'kokoro' + ? 'Kokoro 82M' + : engine === 'qwen_custom_voice' + ? modelSize === '1.7B' + ? 'Qwen CustomVoice 1.7B' + : 'Qwen CustomVoice 0.6B' + : modelSize === '1.7B' + ? 'Qwen TTS 1.7B' + : 'Qwen TTS 0.6B'; + } // Check if model needs downloading try { const modelStatus = await apiClient.getModelStatus(); const model = modelStatus.models.find((m) => m.model_name === modelName); - if (model && !model.downloaded) { - setDownloadingModelName(modelName); - setDownloadingDisplayName(displayName); + if (model) { + displayName = model.display_name; + if (!model.downloaded) { + // Not yet downloaded — enable progress tracking UI + setDownloadingModelName(modelName); + setDownloadingDisplayName(displayName); + } } } catch (error) { + // Non-fatal: generation will still attempt and may trigger download on the backend console.error('Failed to check model status:', error); } @@ -146,7 +172,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) { text: data.text, language: data.language, seed: data.seed, - model_size: hasModelSizes ? data.modelSize : undefined, + model_size: (hasModelSizes || data.modelSize?.startsWith('custom:')) ? data.modelSize : undefined, engine, instruct: supportsInstruct ? data.instruct || undefined : undefined, max_chunk_chars: maxChunkChars, diff --git a/app/src/lib/hooks/useModelStatus.ts b/app/src/lib/hooks/useModelStatus.ts new file mode 100644 index 00000000..b80ffbf9 --- /dev/null +++ b/app/src/lib/hooks/useModelStatus.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api/client'; + +/** + * Shared hook for fetching model status and splitting models into + * built-in and custom groups. + * + * Used by GenerationForm, FloatingGenerateBox, and ModelManagement + * so the query key, refetch interval, and filtering logic stay + * consistent in one place. + * + * @author AJ - Kamyab (Ankit Jain) — Extracted from inline useQuery calls + */ +export function useModelStatus() { + const { data: modelStatus, ...rest } = useQuery({ + queryKey: ['modelStatus'], + queryFn: () => apiClient.getModelStatus(), + refetchInterval: 10000, + }); + + const builtInModels = useMemo( + () => modelStatus?.models.filter((m) => m.model_name.startsWith('qwen-tts')) ?? [], + [modelStatus], + ); + const customModels = useMemo( + () => modelStatus?.models.filter((m) => m.is_custom) ?? [], + [modelStatus], + ); + + return { modelStatus, builtInModels, customModels, ...rest }; +} diff --git a/backend/backends/mlx_backend.py b/backend/backends/mlx_backend.py index ab54f536..16169740 100644 --- a/backend/backends/mlx_backend.py +++ b/backend/backends/mlx_backend.py @@ -40,11 +40,22 @@ def _get_model_path(self, model_size: str) -> str: Get the MLX model path. Args: - model_size: Model size (1.7B or 0.6B) + model_size: Model size (1.7B or 0.6B) or custom model ID (custom:slug) Returns: HuggingFace Hub model ID for MLX """ + # Handle custom model IDs + if model_size.startswith("custom:"): + custom_id = model_size[len("custom:"):] + from ..custom_models import get_hf_repo_id_for_custom_model + hf_repo_id = get_hf_repo_id_for_custom_model(custom_id) + if not hf_repo_id: + raise ValueError(f"Custom model '{custom_id}' not found") + logger.info("Will download custom model from HuggingFace Hub: %s", hf_repo_id) + return hf_repo_id + + mlx_model_map = { "1.7B": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16", "0.6B": "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16", @@ -58,6 +69,7 @@ def _get_model_path(self, model_size: str) -> str: return hf_model_id + def _is_model_cached(self, model_size: str) -> bool: return is_model_cached( self._get_model_path(model_size), diff --git a/backend/backends/pytorch_backend.py b/backend/backends/pytorch_backend.py index ec66d5d5..bcf981b0 100644 --- a/backend/backends/pytorch_backend.py +++ b/backend/backends/pytorch_backend.py @@ -46,11 +46,21 @@ def _get_model_path(self, model_size: str) -> str: Get the HuggingFace Hub model ID. Args: - model_size: Model size (1.7B or 0.6B) + model_size: Model size (1.7B or 0.6B) or custom model ID (custom:slug) Returns: HuggingFace Hub model ID """ + # Handle custom model IDs + # @modified AJ - Kamyab (Ankit Jain) — Added custom model path resolution + if model_size.startswith("custom:"): + custom_id = model_size[len("custom:"):] + from ..custom_models import get_hf_repo_id_for_custom_model + hf_repo_id = get_hf_repo_id_for_custom_model(custom_id) + if not hf_repo_id: + raise ValueError(f"Custom model '{custom_id}' not found") + return hf_repo_id + hf_model_map = { "1.7B": "Qwen/Qwen3-TTS-12Hz-1.7B-Base", "0.6B": "Qwen/Qwen3-TTS-12Hz-0.6B-Base", @@ -61,6 +71,7 @@ def _get_model_path(self, model_size: str) -> str: return hf_model_map[model_size] + def _is_model_cached(self, model_size: str) -> bool: return is_model_cached(self._get_model_path(model_size)) diff --git a/backend/build_binary.py b/backend/build_binary.py index 875d6733..efae623f 100644 --- a/backend/build_binary.py +++ b/backend/build_binary.py @@ -81,7 +81,6 @@ def build_server(cuda=False): args.extend(["--paths", str(qwen_tts_path)]) logger.info("Using local qwen_tts source from: %s", qwen_tts_path) - # Add common hidden imports args.extend( [ "--hidden-import", @@ -119,6 +118,8 @@ def build_server(cuda=False): "--hidden-import", "backend.utils.hf_progress", "--hidden-import", + "backend.custom_models", + "--hidden-import", "backend.services.cuda", "--hidden-import", "backend.services.effects", diff --git a/backend/config.py b/backend/config.py index 0cbce59d..798ad2fb 100644 --- a/backend/config.py +++ b/backend/config.py @@ -2,10 +2,16 @@ Configuration module for voicebox backend. Handles data directory configuration for production bundling. +When running inside a PyInstaller --onefile bundle, the data directory +defaults to a platform-appropriate user data path (via platformdirs) +so that files like custom_models.json persist across runs. +In development the default is the local 'data' directory. +The --data-dir CLI flag in server.py can override either default. """ import logging import os +import sys from pathlib import Path logger = logging.getLogger(__name__) @@ -18,9 +24,20 @@ os.environ["HF_HUB_CACHE"] = _custom_models_dir logger.info("Model download path set to: %s", _custom_models_dir) -# Default data directory (used in development) -_data_dir = Path("data").resolve() +# Default data directory: +# - Inside a PyInstaller bundle: use a platform-appropriate user data dir +# - In development: use the local 'data' folder next to the source +if getattr(sys, '_MEIPASS', None): + try: + from platformdirs import user_data_dir + _data_dir = Path(user_data_dir("voicebox", ensure_exists=False)) + except ImportError: + # Fallback if platformdirs is not installed + _data_dir = Path.home() / ".voicebox" +else: + _data_dir = Path("data") +_data_dir = _data_dir.resolve() def _path_relative_to_any_data_dir(path: Path) -> Path | None: """Extract the path within a data dir from an absolute or relative path.""" @@ -36,7 +53,6 @@ def _path_relative_to_any_data_dir(path: Path) -> Path | None: return None - def set_data_dir(path: str | Path): """ Set the data directory path. diff --git a/backend/custom_models.py b/backend/custom_models.py new file mode 100644 index 00000000..710da1d2 --- /dev/null +++ b/backend/custom_models.py @@ -0,0 +1,231 @@ +""" +Custom voice model management module. + +Handles adding, removing, and listing user-defined HuggingFace TTS models. +Models are persisted in a JSON config file in the data directory. + +@author AJ - Kamyab (Ankit Jain) +""" + +import json +import logging +import os +import re +import tempfile +import threading +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from . import config + +logger = logging.getLogger(__name__) + +# Module-level lock to serialise in-process reads/writes to the config file. +_config_lock = threading.Lock() + + +def _get_config_path() -> Path: + """Get path to the custom models JSON config file.""" + return config.get_data_dir() / "custom_models.json" + + +def _load_config() -> dict: + """Load custom models config from disk. + + On IOError the file is simply missing — return an empty config. + On JSONDecodeError the file is corrupt — back it up, log the error, + and re-raise so callers do not accidentally overwrite it. + """ + path = _get_config_path() + if not path.exists(): + return {"models": []} + try: + with open(path, "r") as f: + return json.load(f) + except json.JSONDecodeError as exc: + # Back up the corrupt file so we don't lose data + backup = path.with_suffix( + f".json.corrupt.{datetime.utcnow().strftime('%Y%m%dT%H%M%S')}" + ) + try: + path.rename(backup) + logger.error( + "Corrupt custom_models.json backed up to %s: %s", backup, exc + ) + except OSError as rename_err: + logger.error( + "Failed to back up corrupt config %s: %s (original error: %s)", + path, rename_err, exc, + ) + raise + except IOError: + return {"models": []} + + +def _save_config(data: dict) -> None: + """Save custom models config to disk atomically. + + Writes to a temp file in the same directory, fsyncs, then atomically + replaces the original via os.replace. The caller MUST hold _config_lock. + """ + path = _get_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + fd = None + tmp_path = None + try: + fd_int, tmp_path = tempfile.mkstemp( + dir=str(path.parent), suffix=".tmp", prefix=".custom_models_" + ) + fd = os.fdopen(fd_int, "w") + json.dump(data, fd, indent=2, default=str) + fd.flush() + os.fsync(fd.fileno()) + fd.close() + fd = None # prevent double-close + os.replace(tmp_path, str(path)) + tmp_path = None # prevent cleanup + finally: + if fd is not None: + fd.close() + if tmp_path is not None: + try: + os.unlink(tmp_path) + except OSError: + pass + + +# Regex for valid HuggingFace repo IDs: owner/repo where each segment is +# non-empty and contains only alphanumeric characters, dots, underscores, +# and hyphens. +_HF_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") + + +def _generate_id(hf_repo_id: str) -> str: + """Generate a slug ID from a HuggingFace repo ID. + + Example: 'AryanNsc/IND-QWENTTS-V1' -> 'aryansc-ind-qwentts-v1' + """ + slug = hf_repo_id.lower().replace("/", "-") + slug = re.sub(r"[^a-z0-9-]", "-", slug) + slug = re.sub(r"-+", "-", slug).strip("-") + return slug + + +def list_custom_models() -> List[dict]: + """List all custom models. + + Returns: + List of custom model dicts + """ + with _config_lock: + data = _load_config() + return data.get("models", []) + + +def get_custom_model(model_id: str) -> Optional[dict]: + """Get a single custom model by ID. + + Args: + model_id: Custom model ID (slug) + + Returns: + Model dict or None if not found + """ + models = list_custom_models() + for model in models: + if model["id"] == model_id: + return model + return None + + +def add_custom_model(hf_repo_id: str, display_name: str) -> dict: + """Add a new custom model. + + Args: + hf_repo_id: HuggingFace repo ID (e.g. 'AryanNsc/IND-QWENTTS-V1') + display_name: User-friendly display name + + Returns: + Created model dict + + Raises: + ValueError: If model already exists or inputs are invalid + """ + hf_repo_id = hf_repo_id.strip() + display_name = display_name.strip() + + if not hf_repo_id: + raise ValueError("HuggingFace repo ID is required") + if not display_name: + raise ValueError("Display name is required") + if not _HF_REPO_RE.match(hf_repo_id): + raise ValueError( + "HuggingFace repo ID must be in format 'owner/model-name' " + "(alphanumeric, dots, underscores, and hyphens only, no leading/trailing slashes)" + ) + + model_id = _generate_id(hf_repo_id) + + with _config_lock: + data = _load_config() + models = data.get("models", []) + + # Check for duplicates + for existing in models: + if existing["id"] == model_id: + raise ValueError(f"Model '{hf_repo_id}' already exists") + if existing["hf_repo_id"] == hf_repo_id: + raise ValueError(f"Model with repo ID '{hf_repo_id}' already exists") + + model = { + "id": model_id, + "display_name": display_name, + "hf_repo_id": hf_repo_id, + "added_at": datetime.utcnow().isoformat() + "Z", + } + + models.append(model) + data["models"] = models + _save_config(data) + + return model + + +def remove_custom_model(model_id: str) -> bool: + """Remove a custom model by ID. + + Args: + model_id: Custom model ID (slug) + + Returns: + True if removed, False if not found + """ + with _config_lock: + data = _load_config() + models = data.get("models", []) + + original_count = len(models) + models = [m for m in models if m["id"] != model_id] + + if len(models) == original_count: + return False + + data["models"] = models + _save_config(data) + return True + + +def get_hf_repo_id_for_custom_model(model_id: str) -> Optional[str]: + """Get the HuggingFace repo ID for a custom model. + + Args: + model_id: Custom model ID (slug, without 'custom:' prefix) + + Returns: + HuggingFace repo ID or None if not found + """ + model = get_custom_model(model_id) + if model: + return model["hf_repo_id"] + return None diff --git a/backend/main.py b/backend/main.py index fa8f78f5..f9310512 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,7 @@ from .app import app # noqa: F401 -- re-export for uvicorn "backend.main:app" from . import config, database + if __name__ == "__main__": parser = argparse.ArgumentParser(description="voicebox backend server") parser.add_argument( diff --git a/backend/models.py b/backend/models.py index f2b590d3..2a6353dc 100644 --- a/backend/models.py +++ b/backend/models.py @@ -76,7 +76,11 @@ class GenerationRequest(BaseModel): text: str = Field(..., min_length=1, max_length=50000) language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$") seed: Optional[int] = Field(None, ge=0) - model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B|1B|3B)$") + model_size: Optional[str] = Field( + default="1.7B", + pattern=r'^(1\.7B|0\.6B|1B|3B|custom:[a-z0-9]([a-z0-9._-]*[a-z0-9])?)$', + description="Built-in model size (1.7B, 0.6B, 1B, 3B) or custom model identifier (custom:)", + ) instruct: Optional[str] = Field(None, max_length=500) engine: Optional[str] = Field(default="qwen", pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$") max_chunk_chars: int = Field( @@ -213,6 +217,7 @@ class ModelStatus(BaseModel): downloading: bool = False # True if download is in progress size_mb: Optional[float] = None loaded: bool = False + is_custom: bool = False # True for user-added custom models — @modified AJ - Kamyab (Ankit Jain) class ModelStatusListResponse(BaseModel): @@ -255,6 +260,31 @@ class ActiveGenerationTask(BaseModel): started_at: datetime +class CustomModelCreate(BaseModel): + """Request model for adding a custom model.""" + hf_repo_id: str = Field( + ..., + min_length=3, + max_length=200, + pattern=r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$", + description="HuggingFace repo ID in the form '/' with alphanumeric, dots, underscores, and hyphens.", + ) + display_name: str = Field(..., min_length=1, max_length=100) + + +class CustomModelResponse(BaseModel): + """Response model for a custom model.""" + id: str + hf_repo_id: str + display_name: str + added_at: datetime + + +class CustomModelListResponse(BaseModel): + """Response model for custom model list.""" + models: List[CustomModelResponse] + + class ActiveTasksResponse(BaseModel): """Response model for active tasks.""" diff --git a/backend/requirements.txt b/backend/requirements.txt index b230fe3d..aaf6583b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -60,3 +60,4 @@ httpx>=0.27.0 # Utilities python-multipart>=0.0.6 Pillow>=10.0.0 +platformdirs>=4.0.0 diff --git a/backend/routes/models.py b/backend/routes/models.py index 7cbb7b04..04845001 100644 --- a/backend/routes/models.py +++ b/backend/routes/models.py @@ -8,12 +8,17 @@ from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session +import logging + from .. import models +from .. import custom_models from ..utils.platform_detect import get_backend_type from ..services.task_queue import create_background_task from ..utils.progress import get_progress_manager from ..utils.tasks import get_task_manager +logger = logging.getLogger(__name__) + router = APIRouter() @@ -266,14 +271,14 @@ async def get_model_status(): statuses = [] - for config in model_configs: + for model_cfg in model_configs: try: downloaded = False size_mb = None loaded = False if cache_info: - repo_id = config["hf_repo_id"] + repo_id = model_cfg["hf_repo_id"] for repo in cache_info.repos: if repo.repo_id == repo_id: has_model_weights = False @@ -307,7 +312,7 @@ async def get_model_status(): if not downloaded: try: cache_dir = hf_constants.HF_HUB_CACHE - repo_cache = Path(cache_dir) / ("models--" + config["hf_repo_id"].replace("/", "--")) + repo_cache = Path(cache_dir) / ("models--" + model_cfg["hf_repo_id"].replace("/", "--")) if repo_cache.exists(): blobs_dir = repo_cache / "blobs" @@ -340,11 +345,11 @@ async def get_model_status(): pass try: - loaded = config["check_loaded"]() + loaded = model_cfg["check_loaded"]() except Exception: loaded = False - is_downloading = config["hf_repo_id"] in active_download_repos + is_downloading = model_cfg["hf_repo_id"] in active_download_repos if is_downloading: downloaded = False @@ -352,9 +357,9 @@ async def get_model_status(): statuses.append( models.ModelStatus( - model_name=config["model_name"], - display_name=config["display_name"], - hf_repo_id=config["hf_repo_id"], + model_name=model_cfg["model_name"], + display_name=model_cfg["display_name"], + hf_repo_id=model_cfg["hf_repo_id"], downloaded=downloaded, downloading=is_downloading, size_mb=size_mb, @@ -363,17 +368,17 @@ async def get_model_status(): ) except Exception: try: - loaded = config["check_loaded"]() + loaded = model_cfg["check_loaded"]() except Exception: loaded = False - is_downloading = config["hf_repo_id"] in active_download_repos + is_downloading = model_cfg["hf_repo_id"] in active_download_repos statuses.append( models.ModelStatus( - model_name=config["model_name"], - display_name=config["display_name"], - hf_repo_id=config["hf_repo_id"], + model_name=model_cfg["model_name"], + display_name=model_cfg["display_name"], + hf_repo_id=model_cfg["hf_repo_id"], downloaded=False, downloading=is_downloading, size_mb=None, @@ -381,6 +386,47 @@ async def get_model_status(): ) ) + # ── Inject custom model entries ────────────────────────────────────── + try: + from huggingface_hub import constants as hf_constants + + custom_entries = custom_models.list_custom_models() + for entry in custom_entries: + cm_name = f"custom:{entry['id']}" + hf_repo = entry.get("hf_repo_id", "") + display = entry.get("display_name", cm_name) + downloaded = False + cm_size_mb = None + try: + cache_dir = hf_constants.HF_HUB_CACHE + repo_cache = Path(cache_dir) / ("models--" + hf_repo.replace("/", "--")) + if repo_cache.exists(): + snapshots = repo_cache / "snapshots" + if snapshots.exists() and ( + any(snapshots.rglob("*.safetensors")) + or any(snapshots.rglob("*.bin")) + ): + downloaded = True + total = sum(f.stat().st_size for f in repo_cache.rglob("*") if f.is_file()) + cm_size_mb = total / (1024 * 1024) + except Exception: + pass + + statuses.append( + models.ModelStatus( + model_name=cm_name, + display_name=display, + hf_repo_id=hf_repo, + downloaded=downloaded, + downloading=hf_repo in active_download_repos, + size_mb=cm_size_mb, + loaded=False, + is_custom=True, + ) + ) + except Exception: + logger.warning("Failed to inject custom model status", exc_info=True) + return models.ModelStatusListResponse(models=statuses) @@ -447,14 +493,30 @@ async def delete_model(model_name: str): from huggingface_hub import constants as hf_constants from ..backends import get_model_config, unload_model_by_config - config = get_model_config(model_name) - if not config: + # Handle custom models + if model_name.startswith("custom:"): + custom_id = model_name[len("custom:"):] + cm = custom_models.get_custom_model(custom_id) + if not cm: + raise HTTPException(status_code=404, detail=f"Custom model '{custom_id}' not found") + hf_repo_id = cm.get("hf_repo_id", "") + try: + cache_dir = hf_constants.HF_HUB_CACHE + repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--")) + if repo_cache_dir.exists(): + shutil.rmtree(repo_cache_dir) + return {"message": f"Custom model {model_name} cache deleted successfully"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to delete custom model cache: {str(e)}") from e + + model_config = get_model_config(model_name) + if not model_config: raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}") - hf_repo_id = config.hf_repo_id + hf_repo_id = model_config.hf_repo_id try: - unload_model_by_config(config) + unload_model_by_config(model_config) cache_dir = hf_constants.HF_HUB_CACHE repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--")) @@ -465,11 +527,48 @@ async def delete_model(model_name: str): try: shutil.rmtree(repo_cache_dir) except OSError as e: - raise HTTPException(status_code=500, detail=f"Failed to delete model cache directory: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to delete model cache directory: {str(e)}") from e return {"message": f"Model {model_name} deleted successfully"} except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}") from e + + +# ── Custom Model CRUD ────────────────────────────────────────────────── + +@router.get("/custom-models") +async def list_custom_models_endpoint(): + """List all registered custom models.""" + return {"models": custom_models.list_custom_models()} + + +@router.post("/custom-models") +async def add_custom_model_endpoint(data: models.CustomModelCreate): + """Register a new HuggingFace custom model (does NOT trigger download).""" + try: + result = custom_models.add_custom_model( + hf_repo_id=data.hf_repo_id, + display_name=data.display_name, + ) + return result + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to add custom model: {str(e)}") from e + + +@router.delete("/custom-models/{model_id}") +async def remove_custom_model_endpoint(model_id: str): + """Unregister a custom model (does NOT delete cached files).""" + try: + removed = custom_models.remove_custom_model(model_id) + if not removed: + raise HTTPException(status_code=404, detail=f"Custom model '{model_id}' not found") + return {"message": f"Custom model '{model_id}' removed successfully"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to remove custom model: {str(e)}") from e diff --git a/backend/voicebox-server.spec b/backend/voicebox-server.spec index ab8566c8..acafc0dd 100644 --- a/backend/voicebox-server.spec +++ b/backend/voicebox-server.spec @@ -5,7 +5,7 @@ from PyInstaller.utils.hooks import copy_metadata datas = [] binaries = [] -hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.backends.qwen_custom_voice_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.hume_backend', 'tada', 'tada.modules', 'tada.modules.tada', 'tada.modules.encoder', 'tada.modules.decoder', 'tada.modules.aligner', 'tada.modules.acoustic_spkr_verf', 'tada.nn', 'tada.nn.vibevoice', 'tada.utils', 'tada.utils.gray_code', 'tada.utils.text', 'backend.utils.dac_shim', 'torchaudio', 'backend.backends.kokoro_backend', 'en_core_web_sm', 'loguru', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt'] +hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.backends.qwen_custom_voice_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.custom_models', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.hume_backend', 'tada', 'tada.modules', 'tada.modules.tada', 'tada.modules.encoder', 'tada.modules.decoder', 'tada.modules.aligner', 'tada.modules.acoustic_spkr_verf', 'tada.nn', 'tada.nn.vibevoice', 'tada.utils', 'tada.utils.gray_code', 'tada.utils.text', 'backend.utils.dac_shim', 'torchaudio', 'backend.backends.kokoro_backend', 'en_core_web_sm', 'loguru', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt'] datas += copy_metadata('qwen-tts') datas += copy_metadata('requests') datas += copy_metadata('transformers') diff --git a/data/custom_models.json b/data/custom_models.json new file mode 100644 index 00000000..64d4a959 --- /dev/null +++ b/data/custom_models.json @@ -0,0 +1,3 @@ +{ + "models": [] +} \ No newline at end of file diff --git a/tauri/src-tauri/build.rs b/tauri/src-tauri/build.rs index 317d74d3..0fd1fcbe 100644 --- a/tauri/src-tauri/build.rs +++ b/tauri/src-tauri/build.rs @@ -67,17 +67,18 @@ fn main() { match output { Ok(output) => { + // @modified AJ - Kamyab (Ankit Jain) — Graceful fallback when full Xcode is not installed if !output.status.success() { eprintln!("actool stderr: {}", String::from_utf8_lossy(&output.stderr)); eprintln!("actool stdout: {}", String::from_utf8_lossy(&output.stdout)); - panic!("actool failed to compile icon"); + println!("cargo:warning=actool failed to compile icon (full Xcode may be required). Continuing without custom icon."); + } else { + println!("Successfully compiled icon to {}", gen_dir); } - println!("Successfully compiled icon to {}", gen_dir); } Err(e) => { eprintln!("Failed to execute xcrun actool: {}", e); - eprintln!("Make sure you have Xcode Command Line Tools installed"); - panic!("Icon compilation failed"); + println!("cargo:warning=Could not run actool (full Xcode may be required). Continuing without custom icon."); } }