mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-26 13:45:16 -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`
|
- 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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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,
|
||||||
|
|||||||
@@ -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.
Reference in New Issue
Block a user