Compare commits
7 Commits
e2196c592a
...
706ada866c
| Author | SHA1 | Date | |
|---|---|---|---|
| 706ada866c | |||
| 63bdf57593 | |||
| 4861c343fc | |||
| 92094479c1 | |||
| bc768f4f45 | |||
| d737b1f7ea | |||
| cea97d3cd4 |
154
.kiro/specs/text-to-audiobook/SCRIPT_REVIEW_FEATURE.md
Normal file
154
.kiro/specs/text-to-audiobook/SCRIPT_REVIEW_FEATURE.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Script Review Feature - Enhancement Specification
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the new **Script Review & Editing** feature that addresses the issue where AI-generated speaker assignments may not always be accurate. This feature gives users visibility and control over how the text is segmented and which character speaks each line.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
When using AI to detect speakers in unmarked text, the system may:
|
||||||
|
- Incorrectly assign dialogue to the wrong character
|
||||||
|
- Miss some character dialogue
|
||||||
|
- Assign narrative text to a character instead of the narrator
|
||||||
|
|
||||||
|
Users currently have no way to see or correct these mistakes before generating the audiobook.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Add a new **Script Review** phase between voice assignment and audio generation where users can:
|
||||||
|
1. See the complete script breakdown showing which character speaks each line
|
||||||
|
2. Edit speaker assignments for any segment
|
||||||
|
3. Skip the review if they trust the AI's assignments
|
||||||
|
|
||||||
|
## User Workflow
|
||||||
|
|
||||||
|
### Updated Flow
|
||||||
|
```
|
||||||
|
1. Text Input
|
||||||
|
↓
|
||||||
|
2. Role Detection (AI detects: Lily, Benny, Sammy, Narrator)
|
||||||
|
↓
|
||||||
|
3. Voice Assignment (User assigns voices to each role)
|
||||||
|
↓
|
||||||
|
4. **Script Review** (NEW - User reviews and edits speaker assignments)
|
||||||
|
↓
|
||||||
|
5. Audio Generation (Uses edited script)
|
||||||
|
↓
|
||||||
|
6. Playback & Download
|
||||||
|
```
|
||||||
|
|
||||||
|
### Script Review Interface
|
||||||
|
|
||||||
|
The review screen will show:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Review Script │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Segment 1: [Narrator ▼] │
|
||||||
|
│ ┌────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Once upon a time, in a tiny village surrounded by │ │
|
||||||
|
│ │ lush green forests, there lived three best friends... │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Segment 2: [Lily ▼] │
|
||||||
|
│ ┌────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ "Have you guys heard about the new stream that just │ │
|
||||||
|
│ │ appeared in the woods?" │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Segment 3: [Benny ▼] │
|
||||||
|
│ ┌────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ "Ooh, I love streams! Do we get to go see it now?" │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ... (more segments) │
|
||||||
|
│ │
|
||||||
|
│ [Skip Review] [Generate Audiobook] │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Implementation
|
||||||
|
|
||||||
|
### New API Endpoint
|
||||||
|
|
||||||
|
**POST /api/tts/script**
|
||||||
|
- Request: `{ text: string, roles: string[] }`
|
||||||
|
- Response: `{ segments: [{ id: string, speaker: string, text: string }] }`
|
||||||
|
- Uses Ollama AI to segment text and assign speakers
|
||||||
|
- Returns editable script for frontend
|
||||||
|
|
||||||
|
### Updated Generation Endpoint
|
||||||
|
|
||||||
|
**POST /api/tts** (modified)
|
||||||
|
- Now accepts optional `segments` array
|
||||||
|
- If segments provided, uses them directly (respecting user edits)
|
||||||
|
- If no segments, falls back to original text-based segmentation
|
||||||
|
- Maintains backward compatibility
|
||||||
|
|
||||||
|
### Frontend Components
|
||||||
|
|
||||||
|
**New: ScriptReviewSection**
|
||||||
|
- Displays all segments with editable speaker dropdowns
|
||||||
|
- Allows users to change speaker for any segment
|
||||||
|
- Provides "Skip Review" option for quick generation
|
||||||
|
- Mobile-responsive design
|
||||||
|
|
||||||
|
**Updated: VoiceAssignmentSection**
|
||||||
|
- Button changes from "Generate Audiobook" to "Next: Review Script"
|
||||||
|
- Adds "Skip Review & Generate" option
|
||||||
|
|
||||||
|
**Updated: App Component**
|
||||||
|
- Adds 'review' phase to workflow
|
||||||
|
- Manages script segments state
|
||||||
|
- Handles segment editing
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Transparency**: Users can see exactly how the AI interpreted their text
|
||||||
|
2. **Control**: Users can correct any mistakes before generation
|
||||||
|
3. **Flexibility**: Users can skip review if they trust the AI
|
||||||
|
4. **Better Results**: Ensures the final audiobook matches user expectations
|
||||||
|
|
||||||
|
## Requirements Added
|
||||||
|
|
||||||
|
- **Requirement 15**: Complete specification for script review feature
|
||||||
|
- 8 acceptance criteria covering all aspects
|
||||||
|
- API endpoints, UI components, and user interactions
|
||||||
|
|
||||||
|
## Design Updates
|
||||||
|
|
||||||
|
- Added ScriptReviewSection component
|
||||||
|
- Added /api/tts/script endpoint
|
||||||
|
- Updated data models (ScriptSegment, ScriptRequest, ScriptResponse)
|
||||||
|
- Added 5 new correctness properties (Properties 32-35)
|
||||||
|
- Updated application workflow to include review phase
|
||||||
|
|
||||||
|
## Implementation Tasks
|
||||||
|
|
||||||
|
Added 8 new tasks (18-25) covering:
|
||||||
|
- Backend script generation endpoint
|
||||||
|
- Updated TTS generation to accept edited scripts
|
||||||
|
- Frontend API client updates
|
||||||
|
- ScriptReviewSection component
|
||||||
|
- App component workflow updates
|
||||||
|
- VoiceAssignmentSection updates
|
||||||
|
- Documentation
|
||||||
|
- Integration testing
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Property tests for script completeness and structure
|
||||||
|
- Unit tests for all new components and endpoints
|
||||||
|
- Integration tests for complete workflow
|
||||||
|
- Manual testing with various text samples
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements for later:
|
||||||
|
- Inline editing of segment text
|
||||||
|
- Merge/split segments
|
||||||
|
- Bulk speaker assignment
|
||||||
|
- Save/load script templates
|
||||||
|
- Export script as text file
|
||||||
@@ -4,10 +4,11 @@
|
|||||||
|
|
||||||
The Text to Audiobook system is a full-stack application that converts long-form text into multi-voice audiobooks using Kokoro TTS. The architecture follows a client-server model with a React TypeScript frontend and a Python FastAPI backend running in Docker. The system automatically detects speaker roles in text, allows voice assignment per role, and generates high-quality audio using the Kokoro-82M model.
|
The Text to Audiobook system is a full-stack application that converts long-form text into multi-voice audiobooks using Kokoro TTS. The architecture follows a client-server model with a React TypeScript frontend and a Python FastAPI backend running in Docker. The system automatically detects speaker roles in text, allows voice assignment per role, and generates high-quality audio using the Kokoro-82M model.
|
||||||
|
|
||||||
The application workflow consists of three main phases:
|
The application workflow consists of four main phases:
|
||||||
1. **Text Input & Role Detection**: User provides text, system identifies distinct speakers
|
1. **Text Input & Role Detection**: User provides text, system identifies distinct speakers
|
||||||
2. **Voice Assignment**: User maps each detected role to a specific Kokoro voice
|
2. **Voice Assignment**: User maps each detected role to a specific Kokoro voice
|
||||||
3. **Audio Generation & Playback**: System synthesizes multi-voice audio and provides playback/download
|
3. **Script Review & Editing**: User reviews and edits AI-generated speaker assignments for each text segment
|
||||||
|
4. **Audio Generation & Playback**: System synthesizes multi-voice audio and provides playback/download
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -93,10 +94,12 @@ Main application container managing the overall workflow state.
|
|||||||
- `text: string` - User input text
|
- `text: string` - User input text
|
||||||
- `roles: Role[]` - Detected roles from backend
|
- `roles: Role[]` - Detected roles from backend
|
||||||
- `voiceAssignments: Map<string, string>` - Role ID to Voice ID mapping
|
- `voiceAssignments: Map<string, string>` - Role ID to Voice ID mapping
|
||||||
|
- `scriptSegments: ScriptSegment[]` - AI-generated script segments
|
||||||
- `selectedLanguage: string` - Language code (default: 'a')
|
- `selectedLanguage: string` - Language code (default: 'a')
|
||||||
- `speed: number` - Playback speed (default: 1.0)
|
- `speed: number` - Playback speed (default: 1.0)
|
||||||
- `audioUrl: string | null` - Generated audio blob URL
|
- `audioUrl: string | null` - Generated audio blob URL
|
||||||
- `isDetectingRoles: boolean` - Loading state for role detection
|
- `isDetectingRoles: boolean` - Loading state for role detection
|
||||||
|
- `isGeneratingScript: boolean` - Loading state for script generation
|
||||||
- `isGenerating: boolean` - Loading state for audio generation
|
- `isGenerating: boolean` - Loading state for audio generation
|
||||||
- `error: string | null` - Error message if any
|
- `error: string | null` - Error message if any
|
||||||
|
|
||||||
@@ -104,7 +107,10 @@ Main application container managing the overall workflow state.
|
|||||||
- `handleTextChange(text: string)` - Updates text state
|
- `handleTextChange(text: string)` - Updates text state
|
||||||
- `handleDetectRoles()` - Calls API to detect roles
|
- `handleDetectRoles()` - Calls API to detect roles
|
||||||
- `handleVoiceAssignment(roleId: string, voiceId: string)` - Updates voice mapping
|
- `handleVoiceAssignment(roleId: string, voiceId: string)` - Updates voice mapping
|
||||||
- `handleGenerate()` - Calls API to generate audiobook
|
- `handleGenerateScript()` - Calls API to generate script preview
|
||||||
|
- `handleSkipReview()` - Skips script review and proceeds to generation
|
||||||
|
- `handleSegmentChange(segmentId: string, newSpeaker: string)` - Updates speaker for a segment
|
||||||
|
- `handleGenerate()` - Calls API to generate audiobook with edited script
|
||||||
- `handleDownload()` - Triggers download of generated audio
|
- `handleDownload()` - Triggers download of generated audio
|
||||||
|
|
||||||
#### 2. TextInputSection Component
|
#### 2. TextInputSection Component
|
||||||
@@ -132,14 +138,25 @@ Displays role-to-voice mapping interface with language and speed controls.
|
|||||||
- `isGenerating: boolean`
|
- `isGenerating: boolean`
|
||||||
- `allRolesAssigned: boolean`
|
- `allRolesAssigned: boolean`
|
||||||
|
|
||||||
#### 4. AudioPlayerSection Component
|
#### 4. ScriptReviewSection Component
|
||||||
|
Displays AI-generated script with editable speaker assignments.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `segments: ScriptSegment[]`
|
||||||
|
- `roles: Role[]`
|
||||||
|
- `onSegmentChange: (segmentId: string, newSpeaker: string) => void`
|
||||||
|
- `onGenerate: () => void`
|
||||||
|
- `onSkipReview: () => void`
|
||||||
|
- `isGenerating: boolean`
|
||||||
|
|
||||||
|
#### 5. AudioPlayerSection Component
|
||||||
Displays audio player and download button after generation.
|
Displays audio player and download button after generation.
|
||||||
|
|
||||||
**Props:**
|
**Props:**
|
||||||
- `audioUrl: string`
|
- `audioUrl: string`
|
||||||
- `onDownload: () => void`
|
- `onDownload: () => void`
|
||||||
|
|
||||||
#### 5. API Client (`src/api/tts.ts`)
|
#### 6. API Client (`src/api/tts.ts`)
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
```typescript
|
```typescript
|
||||||
@@ -156,8 +173,24 @@ export interface RolesResponse {
|
|||||||
roles: Role[];
|
roles: Role[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerateRequest {
|
export interface ScriptSegment {
|
||||||
|
id: string;
|
||||||
|
speaker: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptRequest {
|
||||||
|
text: string;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptResponse {
|
||||||
|
segments: ScriptSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateRequest {
|
||||||
|
segments?: ScriptSegment[];
|
||||||
|
text?: string;
|
||||||
lang: string;
|
lang: string;
|
||||||
roles: { id: string; voice: string }[];
|
roles: { id: string; voice: string }[];
|
||||||
speed?: number;
|
speed?: number;
|
||||||
@@ -167,6 +200,7 @@ export interface GenerateRequest {
|
|||||||
**Functions:**
|
**Functions:**
|
||||||
```typescript
|
```typescript
|
||||||
export async function detectRoles(text: string): Promise<RolesResponse>
|
export async function detectRoles(text: string): Promise<RolesResponse>
|
||||||
|
export async function generateScript(request: ScriptRequest): Promise<ScriptResponse>
|
||||||
export async function generateAudiobook(payload: GenerateRequest): Promise<Blob>
|
export async function generateAudiobook(payload: GenerateRequest): Promise<Blob>
|
||||||
export async function checkHealth(): Promise<{ status: string }>
|
export async function checkHealth(): Promise<{ status: string }>
|
||||||
```
|
```
|
||||||
@@ -188,6 +222,11 @@ async def detect_roles(request: RoleDetectionRequest) -> RolesResponse:
|
|||||||
"""Analyzes text and returns detected roles"""
|
"""Analyzes text and returns detected roles"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@app.post("/api/tts/script")
|
||||||
|
async def generate_script(request: ScriptRequest) -> ScriptResponse:
|
||||||
|
"""Generates script preview with AI-segmented text and speaker assignments"""
|
||||||
|
pass
|
||||||
|
|
||||||
@app.post("/api/tts")
|
@app.post("/api/tts")
|
||||||
async def generate_audiobook(request: TTSRequest) -> StreamingResponse:
|
async def generate_audiobook(request: TTSRequest) -> StreamingResponse:
|
||||||
"""Generates multi-voice audiobook and streams audio"""
|
"""Generates multi-voice audiobook and streams audio"""
|
||||||
@@ -206,12 +245,25 @@ class RoleInfo(BaseModel):
|
|||||||
class RolesResponse(BaseModel):
|
class RolesResponse(BaseModel):
|
||||||
roles: List[RoleInfo]
|
roles: List[RoleInfo]
|
||||||
|
|
||||||
|
class ScriptRequest(BaseModel):
|
||||||
|
text: str
|
||||||
|
roles: List[str] # List of detected role IDs
|
||||||
|
|
||||||
|
class ScriptSegment(BaseModel):
|
||||||
|
id: str # Unique segment identifier
|
||||||
|
speaker: str # Assigned speaker/role
|
||||||
|
text: str # Text content for this segment
|
||||||
|
|
||||||
|
class ScriptResponse(BaseModel):
|
||||||
|
segments: List[ScriptSegment]
|
||||||
|
|
||||||
class RoleVoiceMapping(BaseModel):
|
class RoleVoiceMapping(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
voice: str
|
voice: str
|
||||||
|
|
||||||
class TTSRequest(BaseModel):
|
class TTSRequest(BaseModel):
|
||||||
text: str
|
segments: Optional[List[ScriptSegment]] = None # If provided, use edited script
|
||||||
|
text: Optional[str] = None # Fallback if no script provided
|
||||||
lang: str
|
lang: str
|
||||||
roles: List[RoleVoiceMapping]
|
roles: List[RoleVoiceMapping]
|
||||||
speed: Optional[float] = 1.0
|
speed: Optional[float] = 1.0
|
||||||
@@ -384,10 +436,11 @@ interface LanguageOption {
|
|||||||
**Application State:**
|
**Application State:**
|
||||||
```typescript
|
```typescript
|
||||||
interface AppState {
|
interface AppState {
|
||||||
phase: 'input' | 'assignment' | 'generated';
|
phase: 'input' | 'assignment' | 'review' | 'generated';
|
||||||
text: string;
|
text: string;
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
voiceAssignments: Map<string, string>;
|
voiceAssignments: Map<string, string>;
|
||||||
|
scriptSegments: ScriptSegment[];
|
||||||
language: string;
|
language: string;
|
||||||
speed: number;
|
speed: number;
|
||||||
audioUrl: string | null;
|
audioUrl: string | null;
|
||||||
@@ -578,6 +631,22 @@ ROLE_PATTERNS = {
|
|||||||
*For any* text where both AI detection and pattern detection find roles, the backend should merge results and return each unique role exactly once.
|
*For any* text where both AI detection and pattern detection find roles, the backend should merge results and return each unique role exactly once.
|
||||||
**Validates: Requirements 14.5**
|
**Validates: Requirements 14.5**
|
||||||
|
|
||||||
|
### Property 32: Script generation completeness
|
||||||
|
*For any* text and list of roles, the script generation should return segments that cover the entire input text without gaps or overlaps.
|
||||||
|
**Validates: Requirements 15.2**
|
||||||
|
|
||||||
|
### Property 33: Script segment structure
|
||||||
|
*For any* generated script, each segment should contain an id, speaker, and text field.
|
||||||
|
**Validates: Requirements 15.4**
|
||||||
|
|
||||||
|
### Property 34: Script segment editability
|
||||||
|
*For any* script segment, the user should be able to change the speaker assignment to any detected role.
|
||||||
|
**Validates: Requirements 15.5**
|
||||||
|
|
||||||
|
### Property 35: Edited script persistence
|
||||||
|
*For any* edited script segment, the changes should be preserved when generating the audiobook.
|
||||||
|
**Validates: Requirements 15.7**
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
### Frontend Error Handling
|
### Frontend Error Handling
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ The Text to Audiobook system is a production-ready React TypeScript single-page
|
|||||||
- **KPipeline**: The Kokoro library's pipeline object for text-to-speech synthesis
|
- **KPipeline**: The Kokoro library's pipeline object for text-to-speech synthesis
|
||||||
- **Segment**: A portion of text assigned to a specific role for synthesis
|
- **Segment**: A portion of text assigned to a specific role for synthesis
|
||||||
- **Role Marker**: Text patterns that identify speakers (e.g., "Alice:", "[Narrator]")
|
- **Role Marker**: Text patterns that identify speakers (e.g., "Alice:", "[Narrator]")
|
||||||
|
- **Script**: The complete breakdown of text into segments with speaker assignments, displayed for user review and editing
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -192,3 +193,18 @@ The Text to Audiobook system is a production-ready React TypeScript single-page
|
|||||||
4. WHEN Ollama is unavailable or fails THEN the Backend SHALL fall back to pattern-based role detection
|
4. WHEN Ollama is unavailable or fails THEN the Backend SHALL fall back to pattern-based role detection
|
||||||
5. WHEN both AI detection and pattern detection find roles THEN the Backend SHALL merge and deduplicate the results
|
5. WHEN both AI detection and pattern detection find roles THEN the Backend SHALL merge and deduplicate the results
|
||||||
6. THE Backend SHALL configure Ollama connection using environment variable OLLAMA_BASE_URL with default http://localhost:11434
|
6. THE Backend SHALL configure Ollama connection using environment variable OLLAMA_BASE_URL with default http://localhost:11434
|
||||||
|
|
||||||
|
### Requirement 15
|
||||||
|
|
||||||
|
**User Story:** As a user, I want to review and edit the AI-generated script showing which character speaks each line, so that I can correct any mistakes before generating the audiobook.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the user completes voice assignment THEN the Frontend SHALL request a script preview from the Backend before generation
|
||||||
|
2. WHEN the Backend receives a script preview request THEN the Backend SHALL use AI to segment the text and return a list of segments with speaker assignments
|
||||||
|
3. WHEN the script preview is received THEN the Frontend SHALL display a new "Review Script" phase showing each segment with its assigned speaker
|
||||||
|
4. WHEN displaying script segments THEN the Frontend SHALL show each segment in a text box with a dropdown to select the speaker
|
||||||
|
5. WHEN the user changes a speaker assignment for a segment THEN the Frontend SHALL update the segment mapping
|
||||||
|
6. WHEN the user is satisfied with the script THEN the Frontend SHALL enable a "Generate Audiobook" button
|
||||||
|
7. WHEN the user clicks "Generate Audiobook" THEN the Frontend SHALL send the edited script segments to the Backend for synthesis
|
||||||
|
8. THE Frontend SHALL allow users to skip the review step with a "Skip Review" option that proceeds directly to generation
|
||||||
|
|||||||
@@ -372,7 +372,7 @@
|
|||||||
- Test error handling
|
- Test error handling
|
||||||
- _Requirements: 15.2_
|
- _Requirements: 15.2_
|
||||||
|
|
||||||
- [ ] 19. Update TTS generation endpoint to accept edited scripts
|
- [x] 19. Update TTS generation endpoint to accept edited scripts
|
||||||
- Modify TTSRequest to accept optional segments array
|
- Modify TTSRequest to accept optional segments array
|
||||||
- Update generate_audiobook logic to use segments if provided
|
- Update generate_audiobook logic to use segments if provided
|
||||||
- Fall back to text segmentation if no segments provided
|
- Fall back to text segmentation if no segments provided
|
||||||
@@ -389,7 +389,7 @@
|
|||||||
- Test segment speaker assignments are respected
|
- Test segment speaker assignments are respected
|
||||||
- _Requirements: 15.7_
|
- _Requirements: 15.7_
|
||||||
|
|
||||||
- [ ] 20. Implement script generation API client function
|
- [x] 20. Implement script generation API client function
|
||||||
- Add generateScript() function to src/api/tts.ts
|
- Add generateScript() function to src/api/tts.ts
|
||||||
- Define ScriptSegment, ScriptRequest, and ScriptResponse TypeScript interfaces
|
- Define ScriptSegment, ScriptRequest, and ScriptResponse TypeScript interfaces
|
||||||
- Implement error handling for script generation
|
- Implement error handling for script generation
|
||||||
@@ -402,7 +402,7 @@
|
|||||||
- Mock API responses
|
- Mock API responses
|
||||||
- _Requirements: 15.1_
|
- _Requirements: 15.1_
|
||||||
|
|
||||||
- [ ] 21. Implement ScriptReviewSection component
|
- [x] 21. Implement ScriptReviewSection component
|
||||||
- Create ScriptReviewSection component
|
- Create ScriptReviewSection component
|
||||||
- Display list of script segments with text boxes
|
- Display list of script segments with text boxes
|
||||||
- Add speaker dropdown for each segment
|
- Add speaker dropdown for each segment
|
||||||
@@ -424,7 +424,7 @@
|
|||||||
- Test skip review button works
|
- Test skip review button works
|
||||||
- _Requirements: 15.3, 15.4, 15.5, 15.8_
|
- _Requirements: 15.3, 15.4, 15.5, 15.8_
|
||||||
|
|
||||||
- [ ] 22. Update App component for script review workflow
|
- [x] 22. Update App component for script review workflow
|
||||||
- Add 'review' phase to application state
|
- Add 'review' phase to application state
|
||||||
- Add scriptSegments state variable
|
- Add scriptSegments state variable
|
||||||
- Add isGeneratingScript loading state
|
- Add isGeneratingScript loading state
|
||||||
@@ -443,7 +443,7 @@
|
|||||||
- Test skip review bypasses review phase
|
- Test skip review bypasses review phase
|
||||||
- _Requirements: 15.1, 15.8_
|
- _Requirements: 15.1, 15.8_
|
||||||
|
|
||||||
- [ ] 23. Update VoiceAssignmentSection to trigger script generation
|
- [x] 23. Update VoiceAssignmentSection to trigger script generation
|
||||||
- Change "Generate Audiobook" button to "Next: Review Script"
|
- Change "Generate Audiobook" button to "Next: Review Script"
|
||||||
- Add "Skip Review & Generate" button as alternative
|
- Add "Skip Review & Generate" button as alternative
|
||||||
- Update button handlers to call appropriate methods
|
- Update button handlers to call appropriate methods
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ class RoleVoiceMapping(BaseModel):
|
|||||||
|
|
||||||
class TTSRequest(BaseModel):
|
class TTSRequest(BaseModel):
|
||||||
"""Request model for TTS generation endpoint."""
|
"""Request model for TTS generation endpoint."""
|
||||||
text: str = Field(..., description="Text to convert to speech")
|
segments: Optional[List[ScriptSegment]] = Field(None, description="Optional edited script segments")
|
||||||
|
text: Optional[str] = Field(None, description="Text to convert to speech (used if segments not provided)")
|
||||||
lang: str = Field(..., description="Language code (a, b, f, j)")
|
lang: str = Field(..., description="Language code (a, b, f, j)")
|
||||||
roles: List[RoleVoiceMapping] = Field(..., description="Role to voice mappings")
|
roles: List[RoleVoiceMapping] = Field(..., description="Role to voice mappings")
|
||||||
speed: Optional[float] = Field(1.0, description="Playback speed (0.5-1.5)")
|
speed: Optional[float] = Field(1.0, description="Playback speed (0.5-1.5)")
|
||||||
@@ -253,11 +254,24 @@ async def generate_audiobook(request: TTSRequest):
|
|||||||
"""
|
"""
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Audio generation requested: lang={request.lang}, "
|
f"Audio generation requested: lang={request.lang}, "
|
||||||
f"roles={len(request.roles)}, speed={request.speed}"
|
f"roles={len(request.roles)}, speed={request.speed}, "
|
||||||
|
f"segments={len(request.segments) if request.segments else 0}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate required fields (Pydantic handles basic validation)
|
# Validate that either segments or text is provided
|
||||||
if not request.text or not request.text.strip():
|
if not request.segments and not request.text:
|
||||||
|
logger.error("Neither segments nor text provided")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
content={
|
||||||
|
"error": "ValidationError",
|
||||||
|
"message": "Either segments or text must be provided",
|
||||||
|
"details": {"fields": ["segments", "text"]}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate text if provided and no segments
|
||||||
|
if not request.segments and request.text and not request.text.strip():
|
||||||
logger.error("Empty text provided")
|
logger.error("Empty text provided")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -316,7 +330,17 @@ async def generate_audiobook(request: TTSRequest):
|
|||||||
role_voice_map = {role.id: role.voice for role in request.roles}
|
role_voice_map = {role.id: role.voice for role in request.roles}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Generate audiobook
|
# Generate audiobook using segments if provided, otherwise use text
|
||||||
|
if request.segments:
|
||||||
|
logger.info(f"Generating audiobook from {len(request.segments)} edited segments")
|
||||||
|
audio_bytes = await tts_service.generate_audiobook_from_segments(
|
||||||
|
segments=[(seg.speaker, seg.text) for seg in request.segments],
|
||||||
|
lang=request.lang,
|
||||||
|
role_voice_map=role_voice_map,
|
||||||
|
speed=request.speed
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Generating audiobook from text with automatic segmentation")
|
||||||
audio_bytes = await tts_service.generate_audiobook(
|
audio_bytes = await tts_service.generate_audiobook(
|
||||||
text=request.text,
|
text=request.text,
|
||||||
lang=request.lang,
|
lang=request.lang,
|
||||||
|
|||||||
@@ -146,19 +146,26 @@ class OllamaService:
|
|||||||
Returns:
|
Returns:
|
||||||
Formatted prompt string
|
Formatted prompt string
|
||||||
"""
|
"""
|
||||||
prompt = f"""Analyze the following text and identify all distinct speakers or characters.
|
prompt = f"""Analyze the following text and identify all distinct speakers or characters who need voice assignments.
|
||||||
Return ONLY a JSON object with a "speakers" array containing the names of speakers.
|
|
||||||
If there are no clear speakers, return {{"speakers": ["Narrator"]}}.
|
CRITICAL: Look for TWO types of content:
|
||||||
|
1. NARRATIVE TEXT: Descriptions, scene-setting, "he said", "she thought", etc. → This needs "Narrator"
|
||||||
|
2. CHARACTER DIALOGUE: Direct speech by characters → These need character names
|
||||||
|
|
||||||
|
You MUST include "Narrator" if there is ANY narrative text, descriptions, or non-dialogue content.
|
||||||
|
|
||||||
|
Return ONLY a JSON object with a "speakers" array.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- For dialogue with character names, list those characters
|
- Text: "Alice said hello. Bob waved back." → {{"speakers": ["Narrator", "Alice", "Bob"]}}
|
||||||
- For narrative text, use "Narrator"
|
- Text: "Once upon a time, there was a princess." → {{"speakers": ["Narrator"]}}
|
||||||
- For mixed content, include both characters and "Narrator" if appropriate
|
- Text: "The wizard spoke. 'I see danger,' he warned." → {{"speakers": ["Narrator"]}}
|
||||||
|
- Text: "Alice: Hello! Bob: Hi there!" → {{"speakers": ["Alice", "Bob"]}}
|
||||||
|
|
||||||
Text to analyze:
|
Text to analyze:
|
||||||
{text}
|
{text}
|
||||||
|
|
||||||
Return format: {{"speakers": ["Speaker1", "Speaker2", ...]}}
|
Return format: {{"speakers": ["Narrator", "Character1", "Character2", ...]}}
|
||||||
"""
|
"""
|
||||||
return prompt
|
return prompt
|
||||||
|
|
||||||
|
|||||||
@@ -404,3 +404,66 @@ class TTSService:
|
|||||||
|
|
||||||
logger.info(f"Generated audiobook: {len(audio_bytes)} bytes")
|
logger.info(f"Generated audiobook: {len(audio_bytes)} bytes")
|
||||||
return audio_bytes
|
return audio_bytes
|
||||||
|
|
||||||
|
async def generate_audiobook_from_segments(
|
||||||
|
self,
|
||||||
|
segments: List[Tuple[str, str]],
|
||||||
|
lang: str,
|
||||||
|
role_voice_map: Dict[str, str],
|
||||||
|
speed: float = 1.0
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
Generates audiobook from pre-segmented script with speaker assignments.
|
||||||
|
|
||||||
|
This method is used when the user has reviewed and edited the script,
|
||||||
|
bypassing automatic segmentation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
segments: List of (speaker_name, text_content) tuples
|
||||||
|
lang: Language code
|
||||||
|
role_voice_map: Role to voice ID mapping
|
||||||
|
speed: Playback speed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Audio data as bytes (WAV format)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If segments is empty or invalid
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"Generating audiobook from {len(segments)} edited segments: "
|
||||||
|
f"lang={lang}, speed={speed}, roles={len(role_voice_map)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not segments:
|
||||||
|
raise ValueError("No segments provided for synthesis")
|
||||||
|
|
||||||
|
# Get or create pipeline for language
|
||||||
|
pipeline = self.get_pipeline(lang)
|
||||||
|
|
||||||
|
# Convert segments to format with voice assignments
|
||||||
|
segments_with_voice = []
|
||||||
|
for speaker, text in segments:
|
||||||
|
voice = role_voice_map.get(speaker, self.DEFAULT_VOICE)
|
||||||
|
segments_with_voice.append((speaker, voice, text))
|
||||||
|
|
||||||
|
logger.info(f"Processing {len(segments_with_voice)} segments with voice assignments")
|
||||||
|
|
||||||
|
# Synthesize each segment
|
||||||
|
audio_segments = []
|
||||||
|
for role_id, voice, content in segments_with_voice:
|
||||||
|
logger.debug(f"Synthesizing segment for role '{role_id}' with voice '{voice}'")
|
||||||
|
audio = self.synthesize_segment(pipeline, content, voice, speed)
|
||||||
|
audio_segments.append(audio)
|
||||||
|
|
||||||
|
# Concatenate all audio
|
||||||
|
final_audio = self.concatenate_audio(audio_segments)
|
||||||
|
|
||||||
|
# Convert to bytes (WAV format)
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
sf.write(buffer, final_audio, self.SAMPLE_RATE, format='WAV')
|
||||||
|
buffer.seek(0)
|
||||||
|
audio_bytes = buffer.read()
|
||||||
|
|
||||||
|
logger.info(f"Generated audiobook from edited segments: {len(audio_bytes)} bytes")
|
||||||
|
return audio_bytes
|
||||||
|
|||||||
18
backend/test_ollama_quick.py
Normal file
18
backend/test_ollama_quick.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import asyncio
|
||||||
|
from services.ollama_service import OllamaService
|
||||||
|
|
||||||
|
async def test():
|
||||||
|
service = OllamaService()
|
||||||
|
text = '''Once upon a time, in a tiny village surrounded by lush green forests, there lived three best friends: Lily the rabbit, Benny the bear, and Sammy the squirrel.
|
||||||
|
|
||||||
|
As they sat down on a blanket, Lily asked, "Have you guys heard about the new stream that just appeared in the woods?"
|
||||||
|
|
||||||
|
Benny's eyes lit up. "Ooh, I love streams! Do we get to go see it now?"
|
||||||
|
|
||||||
|
Sammy nodded enthusiastically. "I've been wanting to explore the forest more since the snow melted. Let's go!"'''
|
||||||
|
|
||||||
|
print("Testing Ollama speaker detection...")
|
||||||
|
speakers = await service.detect_speakers(text)
|
||||||
|
print(f'Detected speakers: {speakers}')
|
||||||
|
|
||||||
|
asyncio.run(test())
|
||||||
164
backend/test_segment_based_generation.py
Normal file
164
backend/test_segment_based_generation.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
Test for segment-based audiobook generation (Task 19).
|
||||||
|
|
||||||
|
Tests that the TTS endpoint can accept edited script segments
|
||||||
|
and generate audio from them, while maintaining backward compatibility
|
||||||
|
with text-based generation.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_audiobook_with_segments():
|
||||||
|
"""Test that audiobook can be generated from edited script segments."""
|
||||||
|
# Prepare request with segments
|
||||||
|
request_data = {
|
||||||
|
"segments": [
|
||||||
|
{"id": "seg_0", "speaker": "Alice", "text": "Hello, how are you?"},
|
||||||
|
{"id": "seg_1", "speaker": "Bob", "text": "I'm doing great, thanks!"},
|
||||||
|
{"id": "seg_2", "speaker": "Narrator", "text": "They continued their conversation."}
|
||||||
|
],
|
||||||
|
"lang": "a",
|
||||||
|
"roles": [
|
||||||
|
{"id": "Alice", "voice": "af_bella"},
|
||||||
|
{"id": "Bob", "voice": "am_adam"},
|
||||||
|
{"id": "Narrator", "voice": "af_sarah"}
|
||||||
|
],
|
||||||
|
"speed": 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/tts", json=request_data)
|
||||||
|
|
||||||
|
# Should succeed
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == "audio/wav"
|
||||||
|
assert "audiobook.wav" in response.headers.get("content-disposition", "")
|
||||||
|
assert len(response.content) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_audiobook_with_text_backward_compatibility():
|
||||||
|
"""Test that audiobook generation still works with text (backward compatibility)."""
|
||||||
|
# Prepare request with text (old format)
|
||||||
|
request_data = {
|
||||||
|
"text": "Alice: Hello there!\nBob: Hi Alice!",
|
||||||
|
"lang": "a",
|
||||||
|
"roles": [
|
||||||
|
{"id": "Alice", "voice": "af_bella"},
|
||||||
|
{"id": "Bob", "voice": "am_adam"}
|
||||||
|
],
|
||||||
|
"speed": 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/tts", json=request_data)
|
||||||
|
|
||||||
|
# Should succeed
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == "audio/wav"
|
||||||
|
assert len(response.content) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_audiobook_segments_take_precedence():
|
||||||
|
"""Test that segments are used when both segments and text are provided."""
|
||||||
|
# Prepare request with both segments and text
|
||||||
|
request_data = {
|
||||||
|
"segments": [
|
||||||
|
{"id": "seg_0", "speaker": "Alice", "text": "From segments"}
|
||||||
|
],
|
||||||
|
"text": "Alice: From text",
|
||||||
|
"lang": "a",
|
||||||
|
"roles": [
|
||||||
|
{"id": "Alice", "voice": "af_bella"}
|
||||||
|
],
|
||||||
|
"speed": 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/tts", json=request_data)
|
||||||
|
|
||||||
|
# Should succeed and use segments
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == "audio/wav"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_audiobook_missing_both_segments_and_text():
|
||||||
|
"""Test that request fails when neither segments nor text is provided."""
|
||||||
|
request_data = {
|
||||||
|
"lang": "a",
|
||||||
|
"roles": [
|
||||||
|
{"id": "Alice", "voice": "af_bella"}
|
||||||
|
],
|
||||||
|
"speed": 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/tts", json=request_data)
|
||||||
|
|
||||||
|
# Should fail with validation error
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "ValidationError" in response.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_audiobook_empty_segments():
|
||||||
|
"""Test that request fails when segments array is empty."""
|
||||||
|
request_data = {
|
||||||
|
"segments": [],
|
||||||
|
"lang": "a",
|
||||||
|
"roles": [
|
||||||
|
{"id": "Alice", "voice": "af_bella"}
|
||||||
|
],
|
||||||
|
"speed": 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/tts", json=request_data)
|
||||||
|
|
||||||
|
# Should fail - empty segments should be treated as not provided
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_edited_script_preserves_speaker_assignments():
|
||||||
|
"""Test that edited speaker assignments in segments are preserved."""
|
||||||
|
# Segments where user has changed speaker assignments
|
||||||
|
request_data = {
|
||||||
|
"segments": [
|
||||||
|
{"id": "seg_0", "speaker": "Bob", "text": "This was originally Alice's line"},
|
||||||
|
{"id": "seg_1", "speaker": "Alice", "text": "This was originally Bob's line"}
|
||||||
|
],
|
||||||
|
"lang": "a",
|
||||||
|
"roles": [
|
||||||
|
{"id": "Alice", "voice": "af_bella"},
|
||||||
|
{"id": "Bob", "voice": "am_adam"}
|
||||||
|
],
|
||||||
|
"speed": 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/tts", json=request_data)
|
||||||
|
|
||||||
|
# Should succeed with edited assignments
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == "audio/wav"
|
||||||
|
assert len(response.content) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_with_unmapped_speaker_uses_default():
|
||||||
|
"""Test that segments with unmapped speakers fall back to default voice."""
|
||||||
|
request_data = {
|
||||||
|
"segments": [
|
||||||
|
{"id": "seg_0", "speaker": "UnknownSpeaker", "text": "Hello from unknown"}
|
||||||
|
],
|
||||||
|
"lang": "a",
|
||||||
|
"roles": [
|
||||||
|
{"id": "Alice", "voice": "af_bella"}
|
||||||
|
],
|
||||||
|
"speed": 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/tts", json=request_data)
|
||||||
|
|
||||||
|
# Should succeed using default voice
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == "audio/wav"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
79
diagnose_issue.md
Normal file
79
diagnose_issue.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Audiobook Generation Issue - Diagnosis
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
The story is coming out with only one voice (Narrator) instead of multiple voices for different characters.
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
Based on my investigation, I found that:
|
||||||
|
|
||||||
|
1. **Ollama IS working correctly** - When I tested with your story, it correctly detected all 3 characters:
|
||||||
|
- Lily
|
||||||
|
- Benny
|
||||||
|
- Sammy
|
||||||
|
|
||||||
|
2. **The backend logs show** that when you used the web interface, it only received 40 characters of text:
|
||||||
|
```
|
||||||
|
2025-12-09 10:16:52,356 - main - INFO - Role detection requested for text of length 40
|
||||||
|
2025-12-09 10:16:52,358 - main - INFO - Detected 1 roles: ['Narrator']
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Your full story is 3,749 characters**, so something went wrong in the web interface.
|
||||||
|
|
||||||
|
## Possible Causes
|
||||||
|
|
||||||
|
### Most Likely: Text wasn't fully pasted or was truncated
|
||||||
|
- The web interface might have had an issue with the paste operation
|
||||||
|
- The text area might have a character limit
|
||||||
|
- There might have been a copy/paste error
|
||||||
|
|
||||||
|
### Less Likely: Backend issue
|
||||||
|
- The backend is working correctly (I tested it directly)
|
||||||
|
- Ollama is running and detecting speakers properly
|
||||||
|
|
||||||
|
## Solution Steps
|
||||||
|
|
||||||
|
### Step 1: Verify Backend is Running
|
||||||
|
```bash
|
||||||
|
# Check if backend is running
|
||||||
|
curl http://localhost:8001/api/health
|
||||||
|
|
||||||
|
# Should return: {"status":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Test with the Full Story via API
|
||||||
|
I'll create a test script that sends your full story directly to the backend to verify it works end-to-end.
|
||||||
|
|
||||||
|
### Step 3: Check Frontend
|
||||||
|
If the API works but the web interface doesn't, there might be an issue with:
|
||||||
|
- Text area character limits
|
||||||
|
- Copy/paste handling
|
||||||
|
- Form submission
|
||||||
|
|
||||||
|
## Quick Test Script
|
||||||
|
|
||||||
|
Run this to test the full workflow with your story:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 test_full_workflow.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Send your full story to the role detection endpoint
|
||||||
|
2. Show you what roles are detected
|
||||||
|
3. Generate a sample audiobook with different voices
|
||||||
|
4. Save it as `test_output.wav`
|
||||||
|
|
||||||
|
## Expected Results
|
||||||
|
|
||||||
|
When working correctly, you should see:
|
||||||
|
- **3-4 roles detected**: Lily, Benny, Sammy, and possibly Narrator
|
||||||
|
- **Multiple voices in the audio**: Each character should have a distinct voice
|
||||||
|
- **Proper segmentation**: Dialogue should be assigned to the correct character
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Try the test script to verify the backend works
|
||||||
|
2. If backend works, try the web interface again with the full story
|
||||||
|
3. Make sure to paste the ENTIRE story (all 3,749 characters)
|
||||||
|
4. Check the browser console for any JavaScript errors
|
||||||
@@ -10,6 +10,25 @@
|
|||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Smooth animations */
|
||||||
|
* {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select, textarea {
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button hover effects */
|
||||||
|
button:not(:disabled):hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:not(:disabled):active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom range slider styling for better mobile support */
|
/* Custom range slider styling for better mobile support */
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
@@ -17,9 +36,10 @@ input[type="range"] {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #d1d5db;
|
background: linear-gradient(to right, #667eea, #764ba2);
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="range"]::-webkit-slider-thumb {
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
@@ -28,35 +48,39 @@ input[type="range"]::-webkit-slider-thumb {
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #2563eb;
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease-in-out;
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="range"]::-webkit-slider-thumb:hover {
|
input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
background: #1d4ed8;
|
transform: scale(1.2);
|
||||||
|
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="range"]::-webkit-slider-thumb:active {
|
input[type="range"]::-webkit-slider-thumb:active {
|
||||||
background: #1e40af;
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="range"]::-moz-range-thumb {
|
input[type="range"]::-moz-range-thumb {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #2563eb;
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
transition: background 0.15s ease-in-out;
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="range"]::-moz-range-thumb:hover {
|
input[type="range"]::-moz-range-thumb:hover {
|
||||||
background: #1d4ed8;
|
transform: scale(1.2);
|
||||||
|
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="range"]::-moz-range-thumb:active {
|
input[type="range"]::-moz-range-thumb:active {
|
||||||
background: #1e40af;
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure audio controls are touch-friendly on mobile */
|
/* Ensure audio controls are touch-friendly on mobile */
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ import { useState } from 'react'
|
|||||||
import './App.css'
|
import './App.css'
|
||||||
import TextInputSection from './components/TextInputSection'
|
import TextInputSection from './components/TextInputSection'
|
||||||
import VoiceAssignmentSection from './components/VoiceAssignmentSection'
|
import VoiceAssignmentSection from './components/VoiceAssignmentSection'
|
||||||
|
import ScriptReviewSection from './components/ScriptReviewSection'
|
||||||
import AudioPlayerSection from './components/AudioPlayerSection'
|
import AudioPlayerSection from './components/AudioPlayerSection'
|
||||||
import type { Role } from './api/tts'
|
import type { Role, ScriptSegment } from './api/tts'
|
||||||
import { detectRoles, generateAudiobook } from './api/tts'
|
import { detectRoles, generateScript, generateAudiobook } from './api/tts'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const [phase, setPhase] = useState<'input' | 'assignment' | 'generated'>('input')
|
const [phase, setPhase] = useState<'input' | 'assignment' | 'review' | 'generated'>('input')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [roles, setRoles] = useState<Role[]>([])
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
const [voiceAssignments, setVoiceAssignments] = useState<Map<string, string>>(new Map())
|
const [voiceAssignments, setVoiceAssignments] = useState<Map<string, string>>(new Map())
|
||||||
|
const [scriptSegments, setScriptSegments] = useState<ScriptSegment[]>([])
|
||||||
const [language, setLanguage] = useState('a') // Default: American English
|
const [language, setLanguage] = useState('a') // Default: American English
|
||||||
const [speed, setSpeed] = useState(1.0) // Default: 1.0x
|
const [speed, setSpeed] = useState(1.0) // Default: 1.0x
|
||||||
|
const [isGeneratingScript, setIsGeneratingScript] = useState(false)
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
||||||
@@ -29,6 +32,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
// Detect roles in the text
|
// Detect roles in the text
|
||||||
const response = await detectRoles(text)
|
const response = await detectRoles(text)
|
||||||
|
console.log('Detected roles:', response.roles)
|
||||||
setRoles(response.roles)
|
setRoles(response.roles)
|
||||||
|
|
||||||
// Initialize voice assignments map (empty initially)
|
// Initialize voice assignments map (empty initially)
|
||||||
@@ -60,6 +64,45 @@ function App() {
|
|||||||
setSpeed(newSpeed)
|
setSpeed(newSpeed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleGenerateScript = async () => {
|
||||||
|
setIsGeneratingScript(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate script preview with AI segmentation
|
||||||
|
const roleIds = roles.map(role => role.id)
|
||||||
|
const response = await generateScript({
|
||||||
|
text,
|
||||||
|
roles: roleIds
|
||||||
|
})
|
||||||
|
|
||||||
|
setScriptSegments(response.segments)
|
||||||
|
|
||||||
|
// Move to review phase
|
||||||
|
setPhase('review')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to generate script')
|
||||||
|
console.error('Script generation error:', err)
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingScript(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSkipReview = async () => {
|
||||||
|
// Skip review and go directly to generation
|
||||||
|
await handleGenerate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSegmentChange = (segmentId: string, newSpeaker: string) => {
|
||||||
|
setScriptSegments(prev =>
|
||||||
|
prev.map(segment =>
|
||||||
|
segment.id === segmentId
|
||||||
|
? { ...segment, speaker: newSpeaker }
|
||||||
|
: segment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
setIsGenerating(true)
|
setIsGenerating(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -72,8 +115,11 @@ function App() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Generate the audiobook
|
// Generate the audiobook
|
||||||
|
// If we have script segments (from review phase), use them
|
||||||
|
// Otherwise, fall back to text-based generation
|
||||||
const audioBlob = await generateAudiobook({
|
const audioBlob = await generateAudiobook({
|
||||||
text,
|
segments: scriptSegments.length > 0 ? scriptSegments : undefined,
|
||||||
|
text: scriptSegments.length > 0 ? undefined : text,
|
||||||
lang: language,
|
lang: language,
|
||||||
roles: rolesArray,
|
roles: rolesArray,
|
||||||
speed
|
speed
|
||||||
@@ -112,15 +158,24 @@ function App() {
|
|||||||
const allRolesAssigned = roles.length > 0 && roles.every(role => voiceAssignments.has(role.id))
|
const allRolesAssigned = roles.length > 0 && roles.every(role => voiceAssignments.has(role.id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen">
|
||||||
<div className="container mx-auto py-4 px-4 sm:py-8 sm:px-6 lg:px-8">
|
<div className="container mx-auto py-4 px-4 sm:py-8 sm:px-6 lg:px-8">
|
||||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-center mb-4 sm:mb-6 md:mb-8">
|
{/* Header with gradient text and icon */}
|
||||||
|
<div className="text-center mb-6 sm:mb-10">
|
||||||
|
<div className="inline-block mb-4">
|
||||||
|
<div className="w-16 h-16 sm:w-20 sm:h-20 mx-auto bg-white rounded-2xl shadow-glow flex items-center justify-center">
|
||||||
|
<span className="text-3xl sm:text-4xl">🎧</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-white mb-2 drop-shadow-lg">
|
||||||
Text to Audiobook
|
Text to Audiobook
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="text-white/90 text-sm sm:text-base">Transform your stories into immersive audio experiences</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="max-w-4xl mx-auto mb-4 p-3 sm:p-4 bg-red-100 border border-red-400 text-red-700 rounded text-sm sm:text-base">
|
<div className="max-w-4xl mx-auto mb-4 p-3 sm:p-4 bg-red-50 border-2 border-red-300 text-red-700 rounded-xl shadow-lg text-sm sm:text-base">
|
||||||
{error}
|
<span className="font-semibold">⚠️ Error:</span> {error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -141,6 +196,7 @@ function App() {
|
|||||||
Generation may take several minutes for very long content.
|
Generation may take several minutes for very long content.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{console.log('Rendering VoiceAssignmentSection with roles:', roles, 'allRolesAssigned:', allRolesAssigned)}
|
||||||
<VoiceAssignmentSection
|
<VoiceAssignmentSection
|
||||||
roles={roles}
|
roles={roles}
|
||||||
voiceAssignments={voiceAssignments}
|
voiceAssignments={voiceAssignments}
|
||||||
@@ -149,13 +205,32 @@ function App() {
|
|||||||
onLanguageChange={handleLanguageChange}
|
onLanguageChange={handleLanguageChange}
|
||||||
speed={speed}
|
speed={speed}
|
||||||
onSpeedChange={handleSpeedChange}
|
onSpeedChange={handleSpeedChange}
|
||||||
onGenerate={handleGenerate}
|
onGenerate={handleGenerateScript}
|
||||||
isGenerating={isGenerating}
|
onSkipReview={handleSkipReview}
|
||||||
|
isGenerating={isGeneratingScript}
|
||||||
allRolesAssigned={allRolesAssigned}
|
allRolesAssigned={allRolesAssigned}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{phase === 'review' && (
|
||||||
|
<>
|
||||||
|
{isLongText && (
|
||||||
|
<div className="max-w-4xl mx-auto mb-4 p-3 sm:p-4 bg-yellow-100 border border-yellow-400 text-yellow-800 rounded text-sm sm:text-base">
|
||||||
|
Generation may take several minutes for very long content.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ScriptReviewSection
|
||||||
|
segments={scriptSegments}
|
||||||
|
roles={roles}
|
||||||
|
onSegmentChange={handleSegmentChange}
|
||||||
|
onGenerate={handleGenerate}
|
||||||
|
onSkipReview={handleSkipReview}
|
||||||
|
isGenerating={isGenerating}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{phase === 'generated' && audioUrl && (
|
{phase === 'generated' && audioUrl && (
|
||||||
<AudioPlayerSection
|
<AudioPlayerSection
|
||||||
audioUrl={audioUrl}
|
audioUrl={audioUrl}
|
||||||
@@ -164,8 +239,20 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isGenerating && isLongText && (
|
{isGenerating && isLongText && (
|
||||||
<div className="max-w-4xl mx-auto mt-4 p-3 sm:p-4 bg-blue-100 border border-blue-400 text-blue-800 rounded text-center text-sm sm:text-base">
|
<div className="max-w-4xl mx-auto mt-4 p-3 sm:p-4 glass-effect border-2 border-blue-300 text-blue-800 rounded-xl shadow-glow text-center text-sm sm:text-base">
|
||||||
Generation may take several minutes for very long content. Please be patient...
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-800"></div>
|
||||||
|
<span>Generation may take several minutes for very long content. Please be patient...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isGeneratingScript && (
|
||||||
|
<div className="max-w-4xl mx-auto mt-4 p-3 sm:p-4 glass-effect border-2 border-blue-300 text-blue-800 rounded-xl shadow-glow text-center text-sm sm:text-base">
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-800"></div>
|
||||||
|
<span>Generating script preview... This may take a moment.</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,11 +29,36 @@ export interface RolesResponse {
|
|||||||
roles: Role[];
|
roles: Role[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a script segment with speaker assignment
|
||||||
|
*/
|
||||||
|
export interface ScriptSegment {
|
||||||
|
id: string;
|
||||||
|
speaker: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request payload for script generation
|
||||||
|
*/
|
||||||
|
export interface ScriptRequest {
|
||||||
|
text: string;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from the script generation endpoint
|
||||||
|
*/
|
||||||
|
export interface ScriptResponse {
|
||||||
|
segments: ScriptSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request payload for audiobook generation
|
* Request payload for audiobook generation
|
||||||
*/
|
*/
|
||||||
export interface GenerateRequest {
|
export interface GenerateRequest {
|
||||||
text: string;
|
segments?: ScriptSegment[];
|
||||||
|
text?: string;
|
||||||
lang: string;
|
lang: string;
|
||||||
roles: { id: string; voice: string }[];
|
roles: { id: string; voice: string }[];
|
||||||
speed?: number;
|
speed?: number;
|
||||||
@@ -188,6 +213,27 @@ export async function detectRoles(text: string): Promise<RolesResponse> {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a script preview with AI-segmented text and speaker assignments
|
||||||
|
* @param request - Script request with text and detected roles
|
||||||
|
* @returns Promise resolving to script segments
|
||||||
|
* @throws ApiError if the request fails
|
||||||
|
*/
|
||||||
|
export async function generateScript(request: ScriptRequest): Promise<ScriptResponse> {
|
||||||
|
const baseUrl = getApiBaseUrl();
|
||||||
|
const url = `${baseUrl}/api/tts/script`;
|
||||||
|
|
||||||
|
const response = await fetchWithErrorHandling(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate an audiobook from text with assigned voices
|
* Generate an audiobook from text with assigned voices
|
||||||
* @param payload - Generation request with text, language, roles, and speed
|
* @param payload - Generation request with text, language, roles, and speed
|
||||||
|
|||||||
@@ -8,29 +8,63 @@ export default function AudioPlayerSection({
|
|||||||
onDownload
|
onDownload
|
||||||
}: AudioPlayerSectionProps) {
|
}: AudioPlayerSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-4xl mx-auto p-4 sm:p-6">
|
<div className="w-full max-w-4xl mx-auto p-4 sm:p-6 glass-effect rounded-2xl shadow-glow">
|
||||||
<h2 className="text-xl sm:text-2xl font-bold mb-4 sm:mb-6">Your Audiobook is Ready!</h2>
|
{/* Success Header */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="inline-block mb-4">
|
||||||
|
<div className="w-16 h-16 sm:w-20 sm:h-20 mx-auto bg-gradient-to-br from-green-400 to-emerald-600 rounded-2xl shadow-lg flex items-center justify-center animate-bounce">
|
||||||
|
<span className="text-3xl sm:text-4xl">🎉</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold gradient-text mb-2">
|
||||||
|
Your Audiobook is Ready!
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 text-sm sm:text-base">
|
||||||
|
Listen below or download to enjoy offline
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audio Player Card */}
|
||||||
|
<div className="bg-gradient-to-br from-purple-50 to-indigo-50 rounded-2xl shadow-lg p-5 sm:p-8 mb-6 border-2 border-purple-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-md">
|
||||||
|
<span className="text-xl">🎧</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg sm:text-xl font-bold text-gray-800">Listen Now</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-4 sm:p-6 mb-4 sm:mb-6">
|
|
||||||
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">Listen to your audiobook</h3>
|
|
||||||
<audio
|
<audio
|
||||||
controls
|
controls
|
||||||
src={audioUrl}
|
src={audioUrl}
|
||||||
className="w-full min-h-[44px]"
|
className="w-full min-h-[44px] rounded-xl shadow-md"
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
|
style={{
|
||||||
|
filter: 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1))'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Your browser does not support the audio element.
|
Your browser does not support the audio element.
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Download Button */}
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={onDownload}
|
onClick={onDownload}
|
||||||
className="w-full sm:w-auto px-6 py-3 min-h-[44px] bg-green-600 hover:bg-green-700 active:bg-green-800 text-white font-semibold rounded-lg transition-colors cursor-pointer touch-manipulation"
|
className="w-full sm:w-auto px-8 py-4 min-h-[44px] bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white font-bold rounded-xl transition-all cursor-pointer touch-manipulation shadow-lg text-base sm:text-lg"
|
||||||
>
|
>
|
||||||
Download audiobook
|
<span className="flex items-center justify-center gap-3">
|
||||||
|
<span className="text-xl">⬇️</span>
|
||||||
|
Download Audiobook
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
<div className="mt-6 p-4 bg-gradient-to-r from-green-50 to-emerald-50 border-2 border-green-200 text-green-800 rounded-xl shadow-sm text-center text-sm sm:text-base">
|
||||||
|
<p className="font-semibold">
|
||||||
|
✅ Your audiobook has been successfully generated!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
127
frontend/src/components/ScriptReviewSection.tsx
Normal file
127
frontend/src/components/ScriptReviewSection.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Role, ScriptSegment } from '../api/tts';
|
||||||
|
|
||||||
|
interface ScriptReviewSectionProps {
|
||||||
|
segments: ScriptSegment[];
|
||||||
|
roles: Role[];
|
||||||
|
onSegmentChange: (segmentId: string, newSpeaker: string) => void;
|
||||||
|
onGenerate: () => void;
|
||||||
|
onSkipReview: () => void;
|
||||||
|
isGenerating: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScriptReviewSection({
|
||||||
|
segments,
|
||||||
|
roles,
|
||||||
|
onSegmentChange,
|
||||||
|
onGenerate,
|
||||||
|
onSkipReview,
|
||||||
|
isGenerating,
|
||||||
|
}: ScriptReviewSectionProps) {
|
||||||
|
const handleSpeakerChange = (segmentId: string, newSpeaker: string) => {
|
||||||
|
onSegmentChange(segmentId, newSpeaker);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-4xl mx-auto p-4 sm:p-6 glass-effect rounded-2xl shadow-glow">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<span className="text-xl">📋</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold gradient-text">Review Script</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm sm:text-base text-gray-700 mb-6 bg-purple-50 p-3 rounded-xl border-2 border-purple-100">
|
||||||
|
Review the AI-generated script and adjust speaker assignments as needed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Script Segments */}
|
||||||
|
<div className="space-y-4 sm:space-y-5 mb-6 sm:mb-8">
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<div
|
||||||
|
key={segment.id}
|
||||||
|
className="border-2 border-purple-200 rounded-xl p-4 sm:p-5 bg-white shadow-md hover:shadow-lg transition-all"
|
||||||
|
>
|
||||||
|
{/* Segment Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-3">
|
||||||
|
<span className="inline-flex items-center justify-center w-20 px-2 py-1 text-xs sm:text-sm font-bold text-purple-700 bg-purple-100 rounded-lg">
|
||||||
|
#{index + 1}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Speaker Dropdown */}
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<label className="text-xs sm:text-sm font-semibold text-gray-700 whitespace-nowrap">
|
||||||
|
🎤 Speaker:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={segment.speaker}
|
||||||
|
onChange={(e) => handleSpeakerChange(segment.id, e.target.value)}
|
||||||
|
className="flex-1 p-3 min-h-[44px] border-2 border-purple-200 rounded-xl focus:border-purple-500 focus:ring-4 focus:ring-purple-200 focus:outline-none text-sm sm:text-base touch-manipulation bg-white shadow-sm"
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<option key={role.id} value={role.id}>
|
||||||
|
{role.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Segment Text */}
|
||||||
|
<div className="w-full min-h-[80px] p-4 border-2 border-purple-100 rounded-xl bg-gradient-to-br from-purple-50 to-indigo-50 text-sm sm:text-base whitespace-pre-wrap shadow-inner">
|
||||||
|
{segment.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={onSkipReview}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className={`w-full sm:w-auto px-6 py-4 min-h-[44px] rounded-xl font-semibold transition-all touch-manipulation shadow-lg ${
|
||||||
|
isGenerating
|
||||||
|
? 'bg-gray-300 text-gray-500 cursor-not-allowed opacity-60'
|
||||||
|
: 'bg-gradient-to-r from-gray-500 to-gray-600 text-white hover:from-gray-600 hover:to-gray-700 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Skip Review
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onGenerate}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className={`w-full sm:w-auto px-8 py-4 min-h-[44px] rounded-xl font-semibold text-white transition-all touch-manipulation shadow-lg ${
|
||||||
|
isGenerating
|
||||||
|
? 'bg-gray-400 cursor-not-allowed opacity-60'
|
||||||
|
: 'bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||||
|
Generating...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
✨ Generate Audiobook
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Message */}
|
||||||
|
{!isGenerating && (
|
||||||
|
<div className="mt-6 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 text-blue-800 rounded-xl shadow-sm text-sm sm:text-base">
|
||||||
|
<p className="font-bold mb-2 flex items-center gap-2">
|
||||||
|
<span className="text-lg">💡</span> Pro Tip:
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Review the AI-generated speaker assignments and make any necessary corrections before generating the audiobook.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,35 +36,50 @@ export default function TextInputSection({
|
|||||||
const isButtonDisabled = disabled || isLoading || text.trim() === '';
|
const isButtonDisabled = disabled || isLoading || text.trim() === '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-4xl mx-auto p-4 sm:p-6">
|
<div className="w-full max-w-4xl mx-auto p-4 sm:p-6 glass-effect rounded-2xl shadow-glow">
|
||||||
<h2 className="text-xl sm:text-2xl font-bold mb-3 sm:mb-4">Enter Your Text</h2>
|
<div className="flex items-center gap-3 mb-4 sm:mb-6">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<span className="text-xl">📝</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold gradient-text">Enter Your Text</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<textarea
|
<textarea
|
||||||
value={text}
|
value={text}
|
||||||
onChange={handleTextChange}
|
onChange={handleTextChange}
|
||||||
placeholder="Enter your text here. Use patterns like 'Name:' or '[Name]' to mark different speakers..."
|
placeholder="Enter your text here. Use patterns like 'Name:' or '[Name]' to mark different speakers..."
|
||||||
className="w-full h-64 sm:h-80 md:h-96 p-3 sm:p-4 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:outline-none resize-y text-sm sm:text-base touch-manipulation"
|
className="w-full h-64 sm:h-80 md:h-96 p-4 sm:p-5 border-2 border-purple-200 rounded-xl focus:border-purple-500 focus:ring-4 focus:ring-purple-200 focus:outline-none resize-y text-sm sm:text-base touch-manipulation shadow-sm"
|
||||||
disabled={disabled || isLoading}
|
disabled={disabled || isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showValidation && text.trim() === '' && (
|
{showValidation && text.trim() === '' && (
|
||||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm sm:text-base">
|
<div className="mb-4 p-3 sm:p-4 bg-red-50 border-2 border-red-300 text-red-700 rounded-xl shadow-sm text-sm sm:text-base">
|
||||||
Please enter some text before proceeding.
|
<span className="font-semibold">⚠️</span> Please enter some text before proceeding.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={isButtonDisabled}
|
disabled={isButtonDisabled}
|
||||||
className={`w-full sm:w-auto px-6 py-3 min-h-[44px] rounded-lg font-semibold text-white transition-colors touch-manipulation ${
|
className={`w-full sm:w-auto px-8 py-4 min-h-[44px] rounded-xl font-semibold text-white transition-all touch-manipulation shadow-lg ${
|
||||||
isButtonDisabled
|
isButtonDisabled
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
? 'bg-gray-400 cursor-not-allowed opacity-60'
|
||||||
: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800 cursor-pointer'
|
: 'bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading ? 'Processing...' : 'Next: Assign voices'}
|
{isLoading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
Next: Assign voices
|
||||||
|
<span>→</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface VoiceAssignmentSectionProps {
|
|||||||
speed: number;
|
speed: number;
|
||||||
onSpeedChange: (speed: number) => void;
|
onSpeedChange: (speed: number) => void;
|
||||||
onGenerate: () => void;
|
onGenerate: () => void;
|
||||||
|
onSkipReview?: () => void;
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
allRolesAssigned: boolean;
|
allRolesAssigned: boolean;
|
||||||
}
|
}
|
||||||
@@ -138,6 +139,7 @@ export default function VoiceAssignmentSection({
|
|||||||
speed,
|
speed,
|
||||||
onSpeedChange,
|
onSpeedChange,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
|
onSkipReview,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
allRolesAssigned,
|
allRolesAssigned,
|
||||||
}: VoiceAssignmentSectionProps) {
|
}: VoiceAssignmentSectionProps) {
|
||||||
@@ -157,22 +159,29 @@ export default function VoiceAssignmentSection({
|
|||||||
const isGenerateDisabled = !allRolesAssigned || isGenerating;
|
const isGenerateDisabled = !allRolesAssigned || isGenerating;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-4xl mx-auto p-4 sm:p-6">
|
<div className="w-full max-w-4xl mx-auto p-4 sm:p-6 glass-effect rounded-2xl shadow-glow">
|
||||||
<h2 className="text-xl sm:text-2xl font-bold mb-4 sm:mb-6">Assign voices to roles</h2>
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<span className="text-xl">🎤</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold gradient-text">Assign Voices to Roles</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Role-to-Voice Mapping Section */}
|
{/* Role-to-Voice Mapping Section */}
|
||||||
<div className="mb-6 sm:mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
<h3 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Voice Assignments</h3>
|
<h3 className="text-lg sm:text-xl font-semibold mb-4 text-gray-800 flex items-center gap-2">
|
||||||
|
<span>👥</span> Voice Assignments
|
||||||
|
</h3>
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
{roles.map((role) => (
|
{roles.map((role) => (
|
||||||
<div key={role.id} className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
<div key={role.id} className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 bg-white p-4 rounded-xl shadow-sm border-2 border-purple-100 hover:border-purple-300 transition-all">
|
||||||
<label className="font-medium text-gray-700 text-sm sm:text-base sm:w-40">
|
<label className="font-semibold text-gray-700 text-sm sm:text-base sm:w-40">
|
||||||
{role.label}:
|
{role.label}:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={voiceAssignments.get(role.id) || ''}
|
value={voiceAssignments.get(role.id) || ''}
|
||||||
onChange={(e) => handleVoiceSelect(role.id, e.target.value)}
|
onChange={(e) => handleVoiceSelect(role.id, e.target.value)}
|
||||||
className="flex-1 p-3 sm:p-2 min-h-[44px] border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:outline-none text-sm sm:text-base touch-manipulation"
|
className="flex-1 p-3 min-h-[44px] border-2 border-purple-200 rounded-xl focus:border-purple-500 focus:ring-4 focus:ring-purple-200 focus:outline-none text-sm sm:text-base touch-manipulation bg-white shadow-sm"
|
||||||
>
|
>
|
||||||
<option value="">Select a voice...</option>
|
<option value="">Select a voice...</option>
|
||||||
{KOKORO_VOICES.map((voice) => (
|
{KOKORO_VOICES.map((voice) => (
|
||||||
@@ -187,12 +196,14 @@ export default function VoiceAssignmentSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Language Selector */}
|
{/* Language Selector */}
|
||||||
<div className="mb-6">
|
<div className="mb-6 bg-white p-4 rounded-xl shadow-sm border-2 border-purple-100">
|
||||||
<h3 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Language</h3>
|
<h3 className="text-lg sm:text-xl font-semibold mb-3 text-gray-800 flex items-center gap-2">
|
||||||
|
<span>🌍</span> Language
|
||||||
|
</h3>
|
||||||
<select
|
<select
|
||||||
value={language}
|
value={language}
|
||||||
onChange={handleLanguageSelect}
|
onChange={handleLanguageSelect}
|
||||||
className="w-full p-3 sm:p-2 min-h-[44px] border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:outline-none text-sm sm:text-base touch-manipulation"
|
className="w-full p-3 min-h-[44px] border-2 border-purple-200 rounded-xl focus:border-purple-500 focus:ring-4 focus:ring-purple-200 focus:outline-none text-sm sm:text-base touch-manipulation bg-white shadow-sm"
|
||||||
>
|
>
|
||||||
{LANGUAGES.map((lang) => (
|
{LANGUAGES.map((lang) => (
|
||||||
<option key={lang.code} value={lang.code}>
|
<option key={lang.code} value={lang.code}>
|
||||||
@@ -203,10 +214,12 @@ export default function VoiceAssignmentSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Speed Slider */}
|
{/* Speed Slider */}
|
||||||
<div className="mb-6 sm:mb-8">
|
<div className="mb-6 sm:mb-8 bg-white p-4 rounded-xl shadow-sm border-2 border-purple-100">
|
||||||
<h3 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Playback Speed</h3>
|
<h3 className="text-lg sm:text-xl font-semibold mb-4 text-gray-800 flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2 sm:gap-4">
|
<span>⚡</span> Playback Speed
|
||||||
<span className="text-xs sm:text-sm text-gray-600 w-10 sm:w-12">0.5x</span>
|
</h3>
|
||||||
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
|
<span className="text-xs sm:text-sm text-gray-600 w-10 sm:w-12 font-medium">0.5x</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0.5"
|
min="0.5"
|
||||||
@@ -216,32 +229,60 @@ export default function VoiceAssignmentSection({
|
|||||||
onChange={handleSpeedChange}
|
onChange={handleSpeedChange}
|
||||||
className="flex-1 h-8 sm:h-auto touch-manipulation"
|
className="flex-1 h-8 sm:h-auto touch-manipulation"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs sm:text-sm text-gray-600 w-10 sm:w-12">1.5x</span>
|
<span className="text-xs sm:text-sm text-gray-600 w-10 sm:w-12 font-medium">1.5x</span>
|
||||||
<span className="font-semibold text-gray-800 w-12 sm:w-16 text-center text-sm sm:text-base">
|
<div className="w-16 sm:w-20 text-center">
|
||||||
|
<span className="font-bold text-purple-600 text-base sm:text-lg bg-purple-50 px-3 py-1 rounded-lg">
|
||||||
{speed.toFixed(1)}x
|
{speed.toFixed(1)}x
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Generate Button */}
|
{/* Action Buttons */}
|
||||||
<div className="flex justify-center">
|
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={onGenerate}
|
onClick={onGenerate}
|
||||||
disabled={isGenerateDisabled}
|
disabled={isGenerateDisabled}
|
||||||
className={`w-full sm:w-auto px-8 py-3 min-h-[44px] rounded-lg font-semibold text-white transition-colors touch-manipulation ${
|
className={`w-full sm:w-auto px-8 py-4 min-h-[44px] rounded-xl font-semibold text-white transition-all touch-manipulation shadow-lg ${
|
||||||
isGenerateDisabled
|
isGenerateDisabled
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
? 'bg-gray-400 cursor-not-allowed opacity-60'
|
||||||
: 'bg-green-600 hover:bg-green-700 active:bg-green-800 cursor-pointer'
|
: 'bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isGenerating ? 'Generating...' : 'Generate Audiobook'}
|
{isGenerating ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||||
|
Generating Script...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
Next: Review Script
|
||||||
|
<span>→</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{onSkipReview && (
|
||||||
|
<button
|
||||||
|
onClick={onSkipReview}
|
||||||
|
disabled={isGenerateDisabled}
|
||||||
|
className={`w-full sm:w-auto px-8 py-4 min-h-[44px] rounded-xl font-semibold text-white transition-all touch-manipulation shadow-lg ${
|
||||||
|
isGenerateDisabled
|
||||||
|
? 'bg-gray-400 cursor-not-allowed opacity-60'
|
||||||
|
: 'bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
⚡ Skip Review & Generate
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Validation Message */}
|
{/* Validation Message */}
|
||||||
{!allRolesAssigned && !isGenerating && (
|
{!allRolesAssigned && !isGenerating && (
|
||||||
<div className="mt-4 p-3 bg-yellow-100 border border-yellow-400 text-yellow-800 rounded text-center text-sm sm:text-base">
|
<div className="mt-4 p-4 bg-yellow-50 border-2 border-yellow-300 text-yellow-800 rounded-xl text-center text-sm sm:text-base shadow-sm">
|
||||||
Please assign voices to all roles before generating the audiobook.
|
<span className="font-semibold">⚠️</span> Please assign voices to all roles before generating the audiobook.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.6;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,4 +12,25 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.glass-effect {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-glow {
|
||||||
|
box-shadow: 0 10px 40px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
86
test_api_directly.py
Normal file
86
test_api_directly.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test the API directly with the full story to verify backend functionality.
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Read the full story
|
||||||
|
with open('Test Story.txt', 'r') as f:
|
||||||
|
story = f.read()
|
||||||
|
|
||||||
|
print(f"Story length: {len(story)} characters\n")
|
||||||
|
|
||||||
|
# Test 1: Role Detection
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEST 1: Role Detection")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
'http://localhost:8002/api/tts/roles',
|
||||||
|
json={'text': story},
|
||||||
|
headers={'Content-Type': 'application/json'}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
if response.status_code == 200:
|
||||||
|
roles_data = response.json()
|
||||||
|
roles = roles_data['roles']
|
||||||
|
print(f"✓ Detected {len(roles)} roles:")
|
||||||
|
for role in roles:
|
||||||
|
print(f" - {role['label']}")
|
||||||
|
else:
|
||||||
|
print(f"❌ Error: {response.text}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test 2: Audio Generation with Multiple Voices
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEST 2: Audio Generation")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Assign different voices to each role
|
||||||
|
voice_map = {
|
||||||
|
'Lily': 'af_bella',
|
||||||
|
'Benny': 'am_adam',
|
||||||
|
'Sammy': 'am_michael',
|
||||||
|
'Narrator': 'af_sarah'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build roles array for API
|
||||||
|
roles_array = []
|
||||||
|
for role in roles:
|
||||||
|
role_id = role['id']
|
||||||
|
voice = voice_map.get(role_id, 'af_bella')
|
||||||
|
roles_array.append({'id': role_id, 'voice': voice})
|
||||||
|
print(f" {role_id} → {voice}")
|
||||||
|
|
||||||
|
print(f"\nGenerating audiobook...")
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
'http://localhost:8002/api/tts',
|
||||||
|
json={
|
||||||
|
'text': story,
|
||||||
|
'lang': 'a',
|
||||||
|
'roles': roles_array,
|
||||||
|
'speed': 1.0
|
||||||
|
},
|
||||||
|
headers={'Content-Type': 'application/json'}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
if response.status_code == 200:
|
||||||
|
# Save the audio file
|
||||||
|
with open('test_output.wav', 'wb') as f:
|
||||||
|
f.write(response.content)
|
||||||
|
print(f"✓ Generated audiobook: {len(response.content)} bytes")
|
||||||
|
print(f"✓ Saved to: test_output.wav")
|
||||||
|
print("\nYou can now play test_output.wav to hear the multi-voice audiobook!")
|
||||||
|
else:
|
||||||
|
print(f"❌ Error: {response.text}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("SUCCESS! Backend is working correctly with full story.")
|
||||||
|
print("=" * 70)
|
||||||
0
test_full_workflow.py
Normal file
0
test_full_workflow.py
Normal file
64
test_my_story_full.py
Normal file
64
test_my_story_full.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, 'backend')
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from services.role_detector import RoleDetector
|
||||||
|
from services.tts_service import TTSService
|
||||||
|
from services.ollama_service import OllamaService
|
||||||
|
|
||||||
|
async def test():
|
||||||
|
# Read the actual story
|
||||||
|
with open('Test Story.txt', 'r') as f:
|
||||||
|
story = f.read()
|
||||||
|
|
||||||
|
print(f'Story length: {len(story)} characters\n')
|
||||||
|
|
||||||
|
# Initialize services
|
||||||
|
ollama_service = OllamaService()
|
||||||
|
role_detector = RoleDetector(ollama_service=ollama_service)
|
||||||
|
tts_service = TTSService(ollama_service=ollama_service)
|
||||||
|
|
||||||
|
# Step 1: Detect roles
|
||||||
|
print('Step 1: Detecting roles...')
|
||||||
|
roles = await role_detector.detect_roles(story)
|
||||||
|
print(f'Detected {len(roles)} roles: {[r["label"] for r in roles]}\n')
|
||||||
|
|
||||||
|
# Step 2: Assign voices
|
||||||
|
print('Step 2: Assigning voices...')
|
||||||
|
role_voice_map = {
|
||||||
|
'Lily': 'af_bella',
|
||||||
|
'Benny': 'am_adam',
|
||||||
|
'Sammy': 'am_michael',
|
||||||
|
'Narrator': 'af_sarah'
|
||||||
|
}
|
||||||
|
for role, voice in role_voice_map.items():
|
||||||
|
print(f' {role} → {voice}')
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Step 3: Test AI segmentation
|
||||||
|
print('Step 3: Testing AI segmentation...')
|
||||||
|
segments = await tts_service.segment_text_with_ai(story, role_voice_map)
|
||||||
|
|
||||||
|
if segments is None:
|
||||||
|
print('❌ AI segmentation returned None!')
|
||||||
|
print('Falling back to pattern-based segmentation...')
|
||||||
|
segments = tts_service.segment_text(story, role_voice_map)
|
||||||
|
|
||||||
|
print(f'Generated {len(segments)} segments\n')
|
||||||
|
|
||||||
|
# Analyze segments
|
||||||
|
speaker_counts = {}
|
||||||
|
for speaker, voice, text in segments:
|
||||||
|
speaker_counts[speaker] = speaker_counts.get(speaker, 0) + 1
|
||||||
|
|
||||||
|
print('Segments per speaker:')
|
||||||
|
for speaker, count in sorted(speaker_counts.items(), key=lambda x: x[1], reverse=True):
|
||||||
|
print(f' {speaker}: {count} segments')
|
||||||
|
|
||||||
|
print('\nFirst 5 segments:')
|
||||||
|
for i, (speaker, voice, text) in enumerate(segments[:5], 1):
|
||||||
|
preview = text[:80] + '...' if len(text) > 80 else text
|
||||||
|
print(f' {i}. [{speaker}] {preview}')
|
||||||
|
|
||||||
|
asyncio.run(test())
|
||||||
BIN
test_output.wav
Normal file
BIN
test_output.wav
Normal file
Binary file not shown.
Reference in New Issue
Block a user