mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 00:55:14 -07:00
Merge branch 'main' into fix/model-size-selection-ignored
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
name: Build Windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Build Python server
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
python build_binary.py
|
||||||
|
|
||||||
|
PLATFORM=$(rustc --print host-tuple)
|
||||||
|
mkdir -p ../tauri/src-tauri/binaries
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: "./tauri/src-tauri -> target"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
projectPath: tauri
|
||||||
|
tagName: v__VERSION__
|
||||||
|
releaseName: "voicebox v__VERSION__ (test build)"
|
||||||
|
releaseBody: "Test build for audio export fix"
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: true
|
||||||
|
args: ""
|
||||||
|
includeUpdaterJson: false
|
||||||
@@ -58,6 +58,7 @@ export function AudioSampleRecording({
|
|||||||
// Request microphone access when component mounts
|
// Request microphone access when component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showWaveform) return;
|
if (!showWaveform) return;
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
|
||||||
|
|
||||||
let stream: MediaStream | null = null;
|
let stream: MediaStream | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
} from '@/lib/hooks/useProfiles';
|
} from '@/lib/hooks/useProfiles';
|
||||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||||
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
import { convertToWav, formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
|
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
|
||||||
@@ -505,10 +505,23 @@ export function ProfileForm() {
|
|||||||
language: data.language,
|
language: data.language,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Convert non-WAV uploads to WAV so the backend can always use soundfile.
|
||||||
|
// Recorded audio is already WAV (from useAudioRecording's convertToWav call).
|
||||||
|
let fileToUpload: File = sampleFile;
|
||||||
|
if (!sampleFile.type.includes('wav') && !sampleFile.name.toLowerCase().endsWith('.wav')) {
|
||||||
|
try {
|
||||||
|
const wavBlob = await convertToWav(sampleFile);
|
||||||
|
const wavName = sampleFile.name.replace(/\.[^.]+$/, '.wav');
|
||||||
|
fileToUpload = new File([wavBlob], wavName, { type: 'audio/wav' });
|
||||||
|
} catch {
|
||||||
|
// If browser can't decode the format, send the original and let the backend try.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addSample.mutateAsync({
|
await addSample.mutateAsync({
|
||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
file: sampleFile,
|
file: fileToUpload,
|
||||||
referenceText: referenceText,
|
referenceText: referenceText,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ export function useAudioRecording({
|
|||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
const startTimeRef = useRef<number | null>(null);
|
const startTimeRef = useRef<number | null>(null);
|
||||||
|
const cancelledRef = useRef<boolean>(false);
|
||||||
|
|
||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
|
cancelledRef.current = false;
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
|
|
||||||
// Check if getUserMedia is available
|
// Check if getUserMedia is available
|
||||||
@@ -87,31 +89,34 @@ export function useAudioRecording({
|
|||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.onstop = async () => {
|
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' });
|
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||||
|
|
||||||
// Convert to WAV format to avoid needing ffmpeg on backend
|
// Stop all tracks now that we have the data
|
||||||
try {
|
|
||||||
const wavBlob = await convertToWav(webmBlob);
|
|
||||||
|
|
||||||
// Pass the actual recorded duration
|
|
||||||
const recordedDuration = startTimeRef.current
|
|
||||||
? (Date.now() - startTimeRef.current) / 1000
|
|
||||||
: undefined;
|
|
||||||
onRecordingComplete?.(wavBlob, recordedDuration);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error converting audio to WAV:', err);
|
|
||||||
// Fallback to original blob if conversion fails
|
|
||||||
const recordedDuration = startTimeRef.current
|
|
||||||
? (Date.now() - startTimeRef.current) / 1000
|
|
||||||
: undefined;
|
|
||||||
onRecordingComplete?.(webmBlob, recordedDuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop all tracks
|
|
||||||
streamRef.current?.getTracks().forEach((track) => {
|
streamRef.current?.getTracks().forEach((track) => {
|
||||||
track.stop();
|
track.stop();
|
||||||
});
|
});
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error converting audio to WAV:', err);
|
||||||
|
// Fallback to original blob if conversion fails
|
||||||
|
onRecordingComplete?.(webmBlob, recordedDuration);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.onerror = (event) => {
|
mediaRecorder.onerror = (event) => {
|
||||||
@@ -167,9 +172,10 @@ export function useAudioRecording({
|
|||||||
|
|
||||||
const cancelRecording = useCallback(() => {
|
const cancelRecording = useCallback(() => {
|
||||||
if (mediaRecorderRef.current) {
|
if (mediaRecorderRef.current) {
|
||||||
|
cancelledRef.current = true; // Must be set before stop() triggers onstop
|
||||||
|
chunksRef.current = [];
|
||||||
mediaRecorderRef.current.stop();
|
mediaRecorderRef.current.stop();
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
chunksRef.current = [];
|
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
-18
@@ -22,6 +22,11 @@ export function formatAudioDuration(seconds: number): string {
|
|||||||
* If the file has a recordedDuration property (from recording hooks),
|
* If the file has a recordedDuration property (from recording hooks),
|
||||||
* use that instead of trying to read metadata. This fixes issues on Windows
|
* use that instead of trying to read metadata. This fixes issues on Windows
|
||||||
* where WebM files from MediaRecorder don't have proper duration metadata.
|
* where WebM files from MediaRecorder don't have proper duration metadata.
|
||||||
|
*
|
||||||
|
* For uploaded files we use AudioContext.decodeAudioData which fully decodes
|
||||||
|
* the audio and returns the exact duration. This is more reliable than
|
||||||
|
* HTMLMediaElement.duration which can return incorrect large values for VBR
|
||||||
|
* MP3 files that lack a proper XING/VBRI header.
|
||||||
*/
|
*/
|
||||||
export async function getAudioDuration(
|
export async function getAudioDuration(
|
||||||
file: File & { recordedDuration?: number },
|
file: File & { recordedDuration?: number },
|
||||||
@@ -30,26 +35,39 @@ export async function getAudioDuration(
|
|||||||
return file.recordedDuration;
|
return file.recordedDuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
// Use Web Audio API for accurate duration — avoids VBR MP3 metadata issues.
|
||||||
const audio = new Audio();
|
try {
|
||||||
const url = URL.createObjectURL(file);
|
const audioContext = new AudioContext();
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
|
return audioBuffer.duration;
|
||||||
|
} finally {
|
||||||
|
await audioContext.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback: read duration from the media element (less accurate but works for WAV).
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const audio = new Audio();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
audio.addEventListener('loadedmetadata', () => {
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
||||||
resolve(audio.duration);
|
resolve(audio.duration);
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('Audio file has invalid duration metadata'));
|
reject(new Error('Audio file has invalid duration metadata'));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.addEventListener('error', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('Failed to load audio file'));
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.src = url;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
audio.addEventListener('error', () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
reject(new Error('Failed to load audio file'));
|
|
||||||
});
|
|
||||||
|
|
||||||
audio.src = url;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -29,9 +29,23 @@ class PyTorchTTSBackend:
|
|||||||
"""Get the best available device."""
|
"""Get the best available device."""
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
return "cuda"
|
return "cuda"
|
||||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
# Intel Arc / Intel Xe GPU via intel-extension-for-pytorch (IPEX)
|
||||||
# MPS can have issues, use CPU for stability
|
try:
|
||||||
return "cpu"
|
import intel_extension_for_pytorch # noqa: F401
|
||||||
|
if hasattr(torch, 'xpu') and torch.xpu.is_available():
|
||||||
|
return "xpu"
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
# Any GPU on Windows via DirectML (torch-directml)
|
||||||
|
try:
|
||||||
|
import torch_directml
|
||||||
|
if torch_directml.device_count() > 0:
|
||||||
|
return torch_directml.device(0)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
# MPS (Apple Silicon) — kept for completeness but MLX backend is preferred
|
||||||
|
if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||||
|
return "cpu" # MPS disabled for stability; MLX backend handles Apple Silicon
|
||||||
return "cpu"
|
return "cpu"
|
||||||
|
|
||||||
def is_loaded(self) -> bool:
|
def is_loaded(self) -> bool:
|
||||||
@@ -166,11 +180,21 @@ class PyTorchTTSBackend:
|
|||||||
|
|
||||||
# Load the model (tqdm is patched, but filters out non-download progress)
|
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||||
try:
|
try:
|
||||||
self.model = Qwen3TTSModel.from_pretrained(
|
# Don't pass device_map on CPU: accelerate's meta-tensor mechanism
|
||||||
model_path,
|
# causes "Cannot copy out of meta tensor" when moving to CPU.
|
||||||
device_map=self.device,
|
# Instead load directly then call .to(device) if needed.
|
||||||
torch_dtype=torch.float32 if self.device == "cpu" else torch.bfloat16,
|
if self.device == "cpu":
|
||||||
)
|
self.model = Qwen3TTSModel.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
torch_dtype=torch.float32,
|
||||||
|
low_cpu_mem_usage=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.model = Qwen3TTSModel.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
device_map=self.device,
|
||||||
|
torch_dtype=torch.bfloat16,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
# Exit the patch context
|
# Exit the patch context
|
||||||
tracker_context.__exit__(None, None, None)
|
tracker_context.__exit__(None, None, None)
|
||||||
@@ -358,9 +382,22 @@ class PyTorchSTTBackend:
|
|||||||
"""Get the best available device."""
|
"""Get the best available device."""
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
return "cuda"
|
return "cuda"
|
||||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
# Intel Arc / Intel Xe GPU via intel-extension-for-pytorch (IPEX)
|
||||||
# MPS support for Whisper
|
try:
|
||||||
return "cpu" # Use CPU for stability
|
import intel_extension_for_pytorch # noqa: F401
|
||||||
|
if hasattr(torch, 'xpu') and torch.xpu.is_available():
|
||||||
|
return "xpu"
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
# Any GPU on Windows via DirectML (torch-directml)
|
||||||
|
try:
|
||||||
|
import torch_directml
|
||||||
|
if torch_directml.device_count() > 0:
|
||||||
|
return torch_directml.device(0)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||||
|
return "cpu" # MPS disabled for stability
|
||||||
return "cpu"
|
return "cpu"
|
||||||
|
|
||||||
def is_loaded(self) -> bool:
|
def is_loaded(self) -> bool:
|
||||||
|
|||||||
@@ -4,8 +4,17 @@ Configuration module for voicebox backend.
|
|||||||
Handles data directory configuration for production bundling.
|
Handles data directory configuration for production bundling.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Allow users to override the HuggingFace model download directory.
|
||||||
|
# Set VOICEBOX_MODELS_DIR to an absolute path before starting the server.
|
||||||
|
# This sets HF_HUB_CACHE so all huggingface_hub downloads go to that path.
|
||||||
|
_custom_models_dir = os.environ.get("VOICEBOX_MODELS_DIR")
|
||||||
|
if _custom_models_dir:
|
||||||
|
os.environ["HF_HUB_CACHE"] = _custom_models_dir
|
||||||
|
print(f"[config] Model download path set to: {_custom_models_dir}")
|
||||||
|
|
||||||
# Default data directory (used in development)
|
# Default data directory (used in development)
|
||||||
_data_dir = Path("data")
|
_data_dir = Path("data")
|
||||||
|
|
||||||
|
|||||||
+146
-35
@@ -22,6 +22,24 @@ import uuid
|
|||||||
import asyncio
|
import asyncio
|
||||||
import signal
|
import signal
|
||||||
import os
|
import os
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_content_disposition(disposition_type: str, filename: str) -> str:
|
||||||
|
"""Build a Content-Disposition header that is safe for non-ASCII filenames.
|
||||||
|
|
||||||
|
Uses RFC 5987 ``filename*`` parameter so that browsers can decode
|
||||||
|
UTF-8 filenames while the ``filename`` fallback stays ASCII-only.
|
||||||
|
"""
|
||||||
|
ascii_name = "".join(
|
||||||
|
c for c in filename if c.isascii() and (c.isalnum() or c in " -_.")
|
||||||
|
).strip() or "download"
|
||||||
|
utf8_name = quote(filename, safe="")
|
||||||
|
return (
|
||||||
|
f'{disposition_type}; filename="{ascii_name}"; '
|
||||||
|
f"filename*=UTF-8''{utf8_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
from . import database, models, profiles, history, tts, transcribe, config, export_import, channels, stories, __version__
|
from . import database, models, profiles, history, tts, transcribe, config, export_import, channels, stories, __version__
|
||||||
from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||||
@@ -77,10 +95,39 @@ async def health():
|
|||||||
tts_model = tts.get_tts_model()
|
tts_model = tts.get_tts_model()
|
||||||
backend_type = get_backend_type()
|
backend_type = get_backend_type()
|
||||||
|
|
||||||
# Check for GPU availability (CUDA or MPS)
|
# Check for GPU availability (CUDA, MPS, Intel Arc XPU, or DirectML)
|
||||||
has_cuda = torch.cuda.is_available()
|
has_cuda = torch.cuda.is_available()
|
||||||
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
||||||
gpu_available = has_cuda or has_mps
|
|
||||||
|
# Intel Arc / Intel Xe via intel-extension-for-pytorch (IPEX)
|
||||||
|
has_xpu = False
|
||||||
|
xpu_name = None
|
||||||
|
try:
|
||||||
|
import intel_extension_for_pytorch as ipex # noqa: F401
|
||||||
|
if hasattr(torch, 'xpu') and torch.xpu.is_available():
|
||||||
|
has_xpu = True
|
||||||
|
try:
|
||||||
|
xpu_name = torch.xpu.get_device_name(0)
|
||||||
|
except Exception:
|
||||||
|
xpu_name = "Intel GPU"
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# DirectML backend (torch-directml) for any Windows GPU
|
||||||
|
has_directml = False
|
||||||
|
directml_name = None
|
||||||
|
try:
|
||||||
|
import torch_directml
|
||||||
|
if torch_directml.device_count() > 0:
|
||||||
|
has_directml = True
|
||||||
|
try:
|
||||||
|
directml_name = torch_directml.device_name(0)
|
||||||
|
except Exception:
|
||||||
|
directml_name = "DirectML GPU"
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
gpu_available = has_cuda or has_mps or has_xpu or has_directml or backend_type == "mlx"
|
||||||
|
|
||||||
gpu_type = None
|
gpu_type = None
|
||||||
if has_cuda:
|
if has_cuda:
|
||||||
@@ -89,6 +136,10 @@ async def health():
|
|||||||
gpu_type = "MPS (Apple Silicon)"
|
gpu_type = "MPS (Apple Silicon)"
|
||||||
elif backend_type == "mlx":
|
elif backend_type == "mlx":
|
||||||
gpu_type = "Metal (Apple Silicon via MLX)"
|
gpu_type = "Metal (Apple Silicon via MLX)"
|
||||||
|
elif has_xpu:
|
||||||
|
gpu_type = f"XPU ({xpu_name})"
|
||||||
|
elif has_directml:
|
||||||
|
gpu_type = f"DirectML ({directml_name})"
|
||||||
|
|
||||||
vram_used = None
|
vram_used = None
|
||||||
if has_cuda:
|
if has_cuda:
|
||||||
@@ -252,8 +303,13 @@ async def add_profile_sample(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Add a sample to a voice profile."""
|
"""Add a sample to a voice profile."""
|
||||||
# Save uploaded file to temporary location
|
# Preserve the uploaded file's extension so librosa can detect format correctly.
|
||||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
# Defaulting to .wav was causing soundfile to reject MP3/WebM content as invalid WAV.
|
||||||
|
_allowed_audio_exts = {'.wav', '.mp3', '.m4a', '.ogg', '.flac', '.aac', '.webm', '.opus'}
|
||||||
|
_uploaded_ext = Path(file.filename or '').suffix.lower()
|
||||||
|
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()
|
content = await file.read()
|
||||||
tmp.write(content)
|
tmp.write(content)
|
||||||
tmp_path = tmp.name
|
tmp_path = tmp.name
|
||||||
@@ -268,6 +324,8 @@ async def add_profile_sample(
|
|||||||
return sample
|
return sample
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to process audio file: {str(e)}")
|
||||||
finally:
|
finally:
|
||||||
# Clean up temp file
|
# Clean up temp file
|
||||||
Path(tmp_path).unlink(missing_ok=True)
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
@@ -388,7 +446,7 @@ async def export_profile(
|
|||||||
io.BytesIO(zip_bytes),
|
io.BytesIO(zip_bytes),
|
||||||
media_type="application/zip",
|
media_type="application/zip",
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": f'attachment; filename="{filename}"'
|
"Content-Disposition": _safe_content_disposition("attachment", filename)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -543,44 +601,44 @@ async def generate_speech(
|
|||||||
raise HTTPException(status_code=404, detail="Profile not found")
|
raise HTTPException(status_code=404, detail="Profile not found")
|
||||||
|
|
||||||
# Generate audio
|
# Generate audio
|
||||||
|
|
||||||
|
# Resolve model size and load the correct model FIRST.
|
||||||
|
# This must happen before create_voice_prompt_for_profile because that
|
||||||
|
# function calls load_model_async(None), which falls back to self.model_size.
|
||||||
|
# If the model is already loaded with the right size at that point, it
|
||||||
|
# returns immediately and the voice prompt is created by the correct model.
|
||||||
tts_model = tts.get_tts_model()
|
tts_model = tts.get_tts_model()
|
||||||
# Load the requested model size if different from current (async to not block)
|
|
||||||
model_size = data.model_size or "1.7B"
|
model_size = data.model_size or "1.7B"
|
||||||
|
|
||||||
# Check if model needs to be downloaded first
|
# Check if model needs to be downloaded first
|
||||||
model_path = tts_model._get_model_path(model_size)
|
model_path = tts_model._get_model_path(model_size)
|
||||||
if model_path.startswith("Qwen/"):
|
if not tts_model._is_model_cached(model_size):
|
||||||
# Model not cached - check if it exists remotely or needs download
|
# Model is not fully cached — kick off a background download and tell
|
||||||
from huggingface_hub import constants as hf_constants
|
# the client to retry once it's ready.
|
||||||
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
|
model_name = f"qwen-tts-{model_size}"
|
||||||
if not repo_cache.exists():
|
|
||||||
# Start download in background
|
|
||||||
model_name = f"qwen-tts-{model_size}"
|
|
||||||
|
|
||||||
async def download_model_background():
|
async def download_model_background():
|
||||||
try:
|
try:
|
||||||
await tts_model.load_model_async(model_size)
|
await tts_model.load_model_async(model_size)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
task_manager.error_download(model_name, str(e))
|
task_manager.error_download(model_name, str(e))
|
||||||
|
|
||||||
task_manager.start_download(model_name)
|
task_manager.start_download(model_name)
|
||||||
asyncio.create_task(download_model_background())
|
asyncio.create_task(download_model_background())
|
||||||
|
|
||||||
# Return 202 Accepted with download info
|
raise HTTPException(
|
||||||
raise HTTPException(
|
status_code=202,
|
||||||
status_code=202,
|
detail={
|
||||||
detail={
|
"message": f"Model {model_size} is being downloaded. Please wait and try again.",
|
||||||
"message": f"Model {model_size} is being downloaded. Please wait and try again.",
|
"model_name": model_name,
|
||||||
"model_name": model_name,
|
"downloading": True,
|
||||||
"downloading": True
|
},
|
||||||
}
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Load the requested model BEFORE creating voice prompt,
|
# Load (or switch to) the requested model before building the voice prompt
|
||||||
# so create_voice_prompt uses the correct model size
|
|
||||||
await tts_model.load_model_async(model_size)
|
await tts_model.load_model_async(model_size)
|
||||||
|
|
||||||
# Create voice prompt from profile
|
# Create voice prompt from profile (model is already loaded with correct size)
|
||||||
voice_prompt = await profiles.create_voice_prompt_for_profile(
|
voice_prompt = await profiles.create_voice_prompt_for_profile(
|
||||||
data.profile_id,
|
data.profile_id,
|
||||||
db,
|
db,
|
||||||
@@ -628,6 +686,59 @@ async def generate_speech(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/generate/stream")
|
||||||
|
async def stream_speech(
|
||||||
|
data: models.GenerationRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate speech and stream the WAV audio directly without saving to disk.
|
||||||
|
|
||||||
|
Returns raw WAV bytes via a StreamingResponse so the client can start
|
||||||
|
playing audio before the entire file has been received. This endpoint
|
||||||
|
does NOT create a history entry — use /generate for that.
|
||||||
|
"""
|
||||||
|
profile = await profiles.get_profile(data.profile_id, db)
|
||||||
|
if not profile:
|
||||||
|
raise HTTPException(status_code=404, detail="Profile not found")
|
||||||
|
|
||||||
|
tts_model = tts.get_tts_model()
|
||||||
|
model_size = data.model_size or "1.7B"
|
||||||
|
|
||||||
|
if not tts_model._is_model_cached(model_size):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Model {model_size} is not downloaded yet. Use /generate to trigger a download.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load the correct model before building the voice prompt (fixes issue #96)
|
||||||
|
await tts_model.load_model_async(model_size)
|
||||||
|
|
||||||
|
voice_prompt = await profiles.create_voice_prompt_for_profile(data.profile_id, db)
|
||||||
|
|
||||||
|
audio, sample_rate = await tts_model.generate(
|
||||||
|
data.text,
|
||||||
|
voice_prompt,
|
||||||
|
data.language,
|
||||||
|
data.seed,
|
||||||
|
data.instruct,
|
||||||
|
)
|
||||||
|
|
||||||
|
wav_bytes = tts.audio_to_wav_bytes(audio, sample_rate)
|
||||||
|
|
||||||
|
async def _wav_stream():
|
||||||
|
# Yield in chunks so large responses don't block the event loop
|
||||||
|
chunk_size = 64 * 1024 # 64 KB
|
||||||
|
for i in range(0, len(wav_bytes), chunk_size):
|
||||||
|
yield wav_bytes[i : i + chunk_size]
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_wav_stream(),
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="speech.wav"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# HISTORY ENDPOINTS
|
# HISTORY ENDPOINTS
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -756,7 +867,7 @@ async def export_generation(
|
|||||||
io.BytesIO(zip_bytes),
|
io.BytesIO(zip_bytes),
|
||||||
media_type="application/zip",
|
media_type="application/zip",
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": f'attachment; filename="{filename}"'
|
"Content-Disposition": _safe_content_disposition("attachment", filename)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -789,7 +900,7 @@ async def export_generation_audio(
|
|||||||
audio_path,
|
audio_path,
|
||||||
media_type="audio/wav",
|
media_type="audio/wav",
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": f'attachment; filename="{filename}"'
|
"Content-Disposition": _safe_content_disposition("attachment", filename)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1057,7 +1168,7 @@ async def export_story_audio(
|
|||||||
io.BytesIO(audio_bytes),
|
io.BytesIO(audio_bytes),
|
||||||
media_type="audio/wav",
|
media_type="audio/wav",
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": f'attachment; filename="{filename}"'
|
"Content-Disposition": _safe_content_disposition("attachment", filename)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@@ -32,11 +32,3 @@ def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
|
|||||||
sf.write(buffer, audio, sample_rate, format="WAV")
|
sf.write(buffer, audio, sample_rate, format="WAV")
|
||||||
buffer.seek(0)
|
buffer.seek(0)
|
||||||
return buffer.read()
|
return buffer.read()
|
||||||
|
|
||||||
|
|
||||||
def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
|
|
||||||
"""Convert audio array to WAV bytes."""
|
|
||||||
buffer = io.BytesIO()
|
|
||||||
sf.write(buffer, audio, sample_rate, format="WAV")
|
|
||||||
buffer.seek(0)
|
|
||||||
return buffer.read()
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
import { Banner } from '@/components/Banner';
|
||||||
import { Footer } from '@/components/Footer';
|
import { Footer } from '@/components/Footer';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<html lang="en" suppressHydrationWarning className="dark">
|
<html lang="en" suppressHydrationWarning className="dark">
|
||||||
<body className={inter.variable}>
|
<body className={inter.variable}>
|
||||||
<div className="relative min-h-screen bg-background font-sans flex flex-col">
|
<div className="relative min-h-screen bg-background font-sans flex flex-col">
|
||||||
|
<Banner />
|
||||||
<Header />
|
<Header />
|
||||||
<main className="container mx-auto px-4 sm:px-6 md:px-4 flex-1 py-4 sm:py-6 md:py-0">
|
<main className="container mx-auto px-4 sm:px-6 md:px-4 flex-1 py-4 sm:py-6 md:py-0">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
uvicorn
|
||||||
|
fastapi
|
||||||
|
sqlalchemy
|
||||||
|
torch
|
||||||
|
torchvision
|
||||||
|
soundfile
|
||||||
|
librosa
|
||||||
|
python-multipart
|
||||||
|
huggingface_hub
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"createUpdaterArtifacts": true,
|
"createUpdaterArtifacts": false,
|
||||||
"externalBin": ["binaries/voicebox-server"],
|
"externalBin": ["binaries/voicebox-server"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
|
|||||||
@@ -2,40 +2,25 @@ import type { PlatformFilesystem, FileFilter } from '@/platform/types';
|
|||||||
|
|
||||||
export const tauriFilesystem: PlatformFilesystem = {
|
export const tauriFilesystem: PlatformFilesystem = {
|
||||||
async saveFile(filename: string, blob: Blob, filters?: FileFilter[]) {
|
async saveFile(filename: string, blob: Blob, filters?: FileFilter[]) {
|
||||||
try {
|
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
const { writeFile } = await import('@tauri-apps/plugin-fs');
|
||||||
const filePath = await save({
|
|
||||||
defaultPath: filename,
|
|
||||||
filters: filters || [],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filePath) {
|
const filePath = await save({
|
||||||
let resolvedPath = '';
|
defaultPath: filename,
|
||||||
if (typeof filePath === 'string') {
|
filters: filters || [],
|
||||||
resolvedPath = filePath;
|
});
|
||||||
} else if (filePath && typeof filePath === 'object' && 'path' in filePath) {
|
|
||||||
resolvedPath = (filePath as { path: string }).path;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resolvedPath) {
|
if (!filePath) return; // User cancelled the dialog
|
||||||
throw new Error('Failed to resolve save path');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { writeFile } = await import('@tauri-apps/plugin-fs');
|
const resolvedPath = typeof filePath === 'string'
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
? filePath
|
||||||
await writeFile(resolvedPath, new Uint8Array(arrayBuffer));
|
: (filePath as { path: string }).path;
|
||||||
}
|
|
||||||
} catch (error) {
|
if (!resolvedPath) {
|
||||||
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
throw new Error('Failed to resolve save path from dialog');
|
||||||
// Fall back to browser download if Tauri dialog fails
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
await writeFile(resolvedPath, new Uint8Array(arrayBuffer));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
"@typescript-eslint/parser": "^7.0.0",
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
|||||||
+2
-1
@@ -1,9 +1,10 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, '../app/src'),
|
'@': path.resolve(__dirname, '../app/src'),
|
||||||
|
|||||||
Reference in New Issue
Block a user