Compare commits

..
Author SHA1 Message Date
James Pine 53f640a027 merge: resolve conflicts with latest main 2026-03-13 02:41:28 -07:00
Omer Celik 831a50cf61 Prepare feature branch for PR 2026-02-22 21:29:55 +00:00
10 changed files with 1333 additions and 151 deletions
+9
View File
@@ -71,9 +71,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Self-documenting help system with `make help`
- Colored output for better readability
- Supports parallel development server execution
- **Audiobook Tab** - New long-form narration workflow in the app
- Import/paste `.txt` book content and review/edit before generation
- Generate a quick 5-sentence preview before full run
- Chunk long text automatically and process chunk-by-chunk with retry support
- Auto-create and update a Story during generation, with export shortcut
- **Text chunking utility** - Added reusable sentence-aware chunking for large text inputs (`app/src/lib/utils/textChunking.ts`)
### Changed
- **README** - Added Makefile reference and updated Quick Start with Makefile-based setup instructions alongside manual setup
- **Navigation** - Added Audiobook route/tab to the app sidebar
- **Generation API types** - Added optional `instruct` field to `GenerationRequest`
- **App styling** - Added `scrollbar-visible` utility styles for long-scroll panels/editors
---
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -1,5 +1,5 @@
import { Link, useMatchRoute } from '@tanstack/react-router';
import { Box, BookOpen, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
import { Link, useMatchRoute, useRouterState } from '@tanstack/react-router';
import { BookOpen, BookText, Box, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
@@ -12,6 +12,7 @@ interface SidebarProps {
const tabs = [
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
{ id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' },
{ id: 'audiobook', path: '/audiobook', icon: BookText, label: 'Audiobook' },
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
@@ -23,6 +24,9 @@ export function Sidebar({ isMacOS }: SidebarProps) {
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const matchRoute = useMatchRoute();
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
return (
<div
@@ -41,10 +45,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
{tabs.map((tab) => {
const Icon = tab.icon;
// For index route, use exact match; for others, use default matching
const isActive =
tab.path === '/'
? matchRoute({ to: '/', exact: true })
: matchRoute({ to: tab.path });
const isActive = tab.path === '/' ? pathname === '/' : matchRoute({ to: tab.path });
return (
<Link
+26
View File
@@ -125,6 +125,32 @@
text-orientation: mixed;
letter-spacing: 0.1em;
}
.scrollbar-visible {
scrollbar-width: thin;
-ms-overflow-style: auto;
scrollbar-color: #d8ab4f #2b2b2b;
}
.scrollbar-visible::-webkit-scrollbar {
display: block;
width: 10px;
height: 10px;
}
.scrollbar-visible::-webkit-scrollbar-track {
background: #2b2b2b;
}
.scrollbar-visible::-webkit-scrollbar-thumb {
background: #d8ab4f;
border-radius: 9999px;
border: 2px solid #131313;
}
.scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: #e2b85e;
}
}
@keyframes fadeInScale {
+99
View File
@@ -0,0 +1,99 @@
export interface TextChunk {
id: string;
text: string;
charCount: number;
wordCount: number;
}
function normalizeText(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
}
function splitParagraphIntoSentences(paragraph: string): string[] {
const trimmed = paragraph.trim();
if (!trimmed) {
return [];
}
const matches = trimmed.match(/[^.!?]+[.!?]+(?:["')\]]+)?|[^.!?]+$/g);
if (!matches || matches.length === 0) {
return [trimmed];
}
return matches.map((sentence) => sentence.trim()).filter(Boolean);
}
export function chunkText(
rawText: string,
targetChunkSize: number,
maxChunkSize: number,
): TextChunk[] {
const text = normalizeText(rawText);
if (!text) {
return [];
}
const safeTarget = Math.max(200, Math.min(targetChunkSize, maxChunkSize));
const paragraphs = text
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter(Boolean);
const chunks: string[] = [];
let current = '';
const pushCurrent = () => {
const normalized = current.trim();
if (!normalized) {
return;
}
chunks.push(normalized);
current = '';
};
for (const paragraph of paragraphs) {
const sentences = splitParagraphIntoSentences(paragraph);
for (const sentence of sentences) {
// Keep sentence integrity. If one sentence exceeds maxChunkSize,
// keep it as a single oversized chunk and let UI ask for manual edit.
if (sentence.length > maxChunkSize) {
pushCurrent();
chunks.push(sentence);
continue;
}
if (!current) {
current = sentence;
continue;
}
const candidate = `${current} ${sentence}`;
if (candidate.length <= safeTarget) {
current = candidate;
continue;
}
if (candidate.length <= maxChunkSize && current.length < Math.floor(safeTarget * 0.75)) {
current = candidate;
continue;
}
pushCurrent();
current = sentence;
}
if (current.length >= Math.floor(safeTarget * 0.8)) {
pushCurrent();
}
}
pushCurrent();
return chunks.map((chunkTextValue, index) => ({
id: `chunk-${index + 1}`,
text: chunkTextValue,
charCount: chunkTextValue.length,
wordCount: chunkTextValue.split(/\s+/).filter(Boolean).length,
}));
}
+10
View File
@@ -1,5 +1,6 @@
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
import { AppFrame } from '@/components/AppFrame/AppFrame';
import { AudiobookTab } from '@/components/AudiobookTab/AudiobookTab';
import { AudioTab } from '@/components/AudioTab/AudioTab';
import { MainEditor } from '@/components/MainEditor/MainEditor';
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
@@ -10,6 +11,7 @@ import { Toaster } from '@/components/ui/toaster';
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
// Simple platform check that works in both web and Tauri
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
@@ -86,6 +88,13 @@ const storiesRoute = createRoute({
component: StoriesTab,
});
// Audiobook route
const audiobookRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/audiobook',
component: AudiobookTab,
});
// Voices route
const voicesRoute = createRoute({
getParentRoute: () => rootRoute,
@@ -118,6 +127,7 @@ const serverRoute = createRoute({
const routeTree = rootRoute.addChildren([
indexRoute,
storiesRoute,
audiobookRoute,
voicesRoute,
audioRoute,
modelsRoute,
+1 -93
View File
@@ -222,75 +222,6 @@ async def health():
)
@app.get("/health/filesystem", response_model=models.FilesystemHealthResponse)
async def filesystem_health():
"""Check filesystem health: directory existence, write permissions, and disk space."""
import shutil
dirs_to_check = {
"generations": config.get_generations_dir(),
"profiles": config.get_profiles_dir(),
"data": config.get_data_dir(),
}
checks: list[models.DirectoryCheck] = []
all_ok = True
for _label, dir_path in dirs_to_check.items():
exists = dir_path.exists()
writable = False
error = None
if exists:
# Probe writability with a temp file
probe = dir_path / ".voicebox_probe"
try:
probe.write_text("ok")
probe.unlink()
writable = True
except PermissionError:
error = "Permission denied"
except OSError as e:
error = str(e)
finally:
try:
probe.unlink(missing_ok=True)
except Exception:
pass
else:
error = "Directory does not exist"
if not exists or not writable:
all_ok = False
checks.append(
models.DirectoryCheck(
path=str(dir_path),
exists=exists,
writable=writable,
error=error,
)
)
# Disk space for the data directory
disk_free_mb = None
disk_total_mb = None
try:
usage = shutil.disk_usage(str(config.get_data_dir()))
disk_free_mb = round(usage.free / (1024 * 1024), 1)
disk_total_mb = round(usage.total / (1024 * 1024), 1)
if disk_free_mb < 500:
all_ok = False
except OSError:
all_ok = False
return models.FilesystemHealthResponse(
healthy=all_ok,
disk_free_mb=disk_free_mb,
disk_total_mb=disk_total_mb,
directories=checks,
)
# ============================================
# VOICE PROFILE ENDPOINTS
# ============================================
@@ -797,30 +728,7 @@ async def generate_speech(
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
from .utils.audio import save_audio
import errno
try:
save_audio(audio, str(audio_path), sample_rate)
except BrokenPipeError:
raise HTTPException(
status_code=500,
detail="Audio save failed: broken pipe (the output stream was closed unexpectedly)",
)
except OSError as save_err:
err_no = getattr(save_err, "errno", None) or (
getattr(save_err.__cause__, "errno", None)
if save_err.__cause__
else None
)
if err_no == errno.ENOENT:
msg = f"Audio save failed: directory not found — {audio_path.parent}"
elif err_no == errno.EACCES:
msg = f"Audio save failed: permission denied — {audio_path.parent}"
elif err_no == errno.ENOSPC:
msg = "Audio save failed: no disk space remaining"
else:
msg = f"Audio save failed: {save_err}"
raise HTTPException(status_code=500, detail=msg)
save_audio(audio, str(audio_path), sample_rate)
# Create history entry
generation = await history.create_generation(
-16
View File
@@ -131,22 +131,6 @@ class HealthResponse(BaseModel):
backend_variant: Optional[str] = None # Binary variant (cpu or cuda)
class DirectoryCheck(BaseModel):
"""Health status for a single directory."""
path: str
exists: bool
writable: bool
error: Optional[str] = None
class FilesystemHealthResponse(BaseModel):
"""Response model for filesystem health check."""
healthy: bool
disk_free_mb: Optional[float] = None
disk_total_mb: Optional[float] = None
directories: List[DirectoryCheck]
class ModelStatus(BaseModel):
"""Response model for model status."""
model_name: str
+3 -32
View File
@@ -70,43 +70,14 @@ def save_audio(
sample_rate: int = 24000,
) -> None:
"""
Save audio file with atomic write and error handling.
Writes to a temporary file first, then atomically renames to the
target path. This prevents corrupted/partial WAV files if the
process is interrupted mid-write.
Save audio file.
Args:
audio: Audio array
path: Output path
sample_rate: Sample rate
Raises:
OSError: If file cannot be written
"""
from pathlib import Path
import os
temp_path = f"{path}.tmp"
try:
# Ensure parent directory exists
Path(path).parent.mkdir(parents=True, exist_ok=True)
# Write to temporary file first
sf.write(temp_path, audio, sample_rate)
# Atomic rename to final path
os.replace(temp_path, path)
except Exception as e:
# Clean up temp file on failure
try:
if Path(temp_path).exists():
Path(temp_path).unlink()
except Exception:
pass # Best effort cleanup
raise OSError(f"Failed to save audio to {path}: {e}") from e
sf.write(path, audio, sample_rate)
def trim_tts_output(
+5 -4
View File
@@ -13,7 +13,7 @@
},
"app": {
"name": "@voicebox/app",
"version": "0.1.11",
"version": "0.1.12",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -68,7 +68,7 @@
},
"landing": {
"name": "@voicebox/landing",
"version": "0.1.11",
"version": "0.1.12",
"dependencies": {
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
@@ -93,7 +93,7 @@
},
"tauri": {
"name": "@voicebox/tauri",
"version": "0.1.11",
"version": "0.1.12",
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
@@ -116,7 +116,7 @@
},
"web": {
"name": "@voicebox/web",
"version": "0.1.11",
"version": "0.1.12",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"react": "^18.3.0",
@@ -125,6 +125,7 @@
"zustand": "^4.5.0",
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",