mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 13:20:39 -07:00
Prepare feature branch for PR
This commit is contained in:
@@ -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`
|
||||
- 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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface GenerationRequest {
|
||||
language: LanguageCode;
|
||||
seed?: number;
|
||||
model_size?: '1.7B' | '0.6B';
|
||||
instruct?: string;
|
||||
}
|
||||
|
||||
export interface GenerationResponse {
|
||||
@@ -96,7 +97,7 @@ export interface ModelStatus {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
downloaded: boolean;
|
||||
downloading: boolean; // True if download is in progress
|
||||
downloading: boolean; // True if download is in progress
|
||||
size_mb?: number;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user