Prepare feature branch for PR

This commit is contained in:
Omer Celik
2026-02-22 21:29:55 +00:00
parent 162cf4fb84
commit 831a50cf61
10 changed files with 1331 additions and 11 deletions
+9
View File
@@ -62,9 +62,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Self-documenting help system with `make help` - Self-documenting help system with `make help`
- Colored output for better readability - Colored output for better readability
- Supports parallel development server execution - 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 ### Changed
- **README** - Added Makefile reference and updated Quick Start with Makefile-based setup instructions alongside manual setup - **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 { Link, useMatchRoute, useRouterState } from '@tanstack/react-router';
import { Box, BookOpen, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react'; import { BookOpen, BookText, Box, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
import voiceboxLogo from '@/assets/voicebox-logo.png'; import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore'; import { useGenerationStore } from '@/stores/generationStore';
@@ -12,6 +12,7 @@ interface SidebarProps {
const tabs = [ const tabs = [
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' }, { id: 'main', path: '/', icon: Volume2, label: 'Generate' },
{ id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' }, { 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: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' }, { id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
{ id: 'models', path: '/models', icon: Box, label: 'Models' }, { id: 'models', path: '/models', icon: Box, label: 'Models' },
@@ -23,6 +24,9 @@ export function Sidebar({ isMacOS }: SidebarProps) {
const audioUrl = usePlayerStore((state) => state.audioUrl); const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl; const isPlayerVisible = !!audioUrl;
const matchRoute = useMatchRoute(); const matchRoute = useMatchRoute();
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
return ( return (
<div <div
@@ -41,10 +45,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
{tabs.map((tab) => { {tabs.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
// For index route, use exact match; for others, use default matching // For index route, use exact match; for others, use default matching
const isActive = const isActive = tab.path === '/' ? pathname === '/' : matchRoute({ to: tab.path });
tab.path === '/'
? matchRoute({ to: '/', exact: true })
: matchRoute({ to: tab.path });
return ( return (
<Link <Link
+26
View File
@@ -125,6 +125,32 @@
text-orientation: mixed; text-orientation: mixed;
letter-spacing: 0.1em; 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 { @keyframes fadeInScale {
+2 -1
View File
@@ -34,6 +34,7 @@ export interface GenerationRequest {
language: LanguageCode; language: LanguageCode;
seed?: number; seed?: number;
model_size?: '1.7B' | '0.6B'; model_size?: '1.7B' | '0.6B';
instruct?: string;
} }
export interface GenerationResponse { export interface GenerationResponse {
@@ -96,7 +97,7 @@ export interface ModelStatus {
model_name: string; model_name: string;
display_name: string; display_name: string;
downloaded: boolean; downloaded: boolean;
downloading: boolean; // True if download is in progress downloading: boolean; // True if download is in progress
size_mb?: number; size_mb?: number;
loaded: boolean; loaded: boolean;
} }
+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 { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
import { AppFrame } from '@/components/AppFrame/AppFrame'; import { AppFrame } from '@/components/AppFrame/AppFrame';
import { AudiobookTab } from '@/components/AudiobookTab/AudiobookTab';
import { AudioTab } from '@/components/AudioTab/AudioTab'; import { AudioTab } from '@/components/AudioTab/AudioTab';
import { MainEditor } from '@/components/MainEditor/MainEditor'; import { MainEditor } from '@/components/MainEditor/MainEditor';
import { ModelsTab } from '@/components/ModelsTab/ModelsTab'; import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
@@ -10,6 +11,7 @@ import { Toaster } from '@/components/ui/toaster';
import { VoicesTab } from '@/components/VoicesTab/VoicesTab'; import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks'; import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
// Simple platform check that works in both web and Tauri // Simple platform check that works in both web and Tauri
const isMacOS = () => navigator.platform.toLowerCase().includes('mac'); const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
@@ -86,6 +88,13 @@ const storiesRoute = createRoute({
component: StoriesTab, component: StoriesTab,
}); });
// Audiobook route
const audiobookRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/audiobook',
component: AudiobookTab,
});
// Voices route // Voices route
const voicesRoute = createRoute({ const voicesRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
@@ -118,6 +127,7 @@ const serverRoute = createRoute({
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
storiesRoute, storiesRoute,
audiobookRoute,
voicesRoute, voicesRoute,
audioRoute, audioRoute,
modelsRoute, modelsRoute,
+5 -4
View File
@@ -13,7 +13,7 @@
}, },
"app": { "app": {
"name": "@voicebox/app", "name": "@voicebox/app",
"version": "0.1.11", "version": "0.1.12",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -68,7 +68,7 @@
}, },
"landing": { "landing": {
"name": "@voicebox/landing", "name": "@voicebox/landing",
"version": "0.1.11", "version": "0.1.12",
"dependencies": { "dependencies": {
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
@@ -93,7 +93,7 @@
}, },
"tauri": { "tauri": {
"name": "@voicebox/tauri", "name": "@voicebox/tauri",
"version": "0.1.11", "version": "0.1.12",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
@@ -116,7 +116,7 @@
}, },
"web": { "web": {
"name": "@voicebox/web", "name": "@voicebox/web",
"version": "0.1.11", "version": "0.1.12",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"react": "^18.3.0", "react": "^18.3.0",
@@ -125,6 +125,7 @@
"zustand": "^4.5.0", "zustand": "^4.5.0",
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
"@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",
Binary file not shown.
Binary file not shown.