ai-audiobook-maker
Version:
AI Audiobook Maker - Convert PDFs and text files to audiobooks using OpenAI TTS
455 lines (382 loc) โข 16 kB
JavaScript
const ConfigManager = require('./ConfigManager');
const FileHandler = require('./FileHandler');
const TTSService = require('./TTSService');
const VoicePreview = require('./VoicePreview');
const ProgressManager = require('./ProgressManager');
const inquirer = require('inquirer');
const chalk = require('chalk');
const path = require('path');
const fs = require('fs-extra');
const ora = require('ora');
class AudiobookMaker {
constructor() {
this.configManager = null;
this.fileHandler = null;
this.ttsService = null;
this.voicePreview = null;
this.progressManager = null;
}
async initialize() {
this.configManager = new ConfigManager();
await this.configManager.initialize();
this.fileHandler = new FileHandler();
this.progressManager = new ProgressManager(this.configManager.configDir);
await this.progressManager.initialize();
}
async manageConfig() {
await this.configManager.manageApiKey();
}
async runInteractive() {
// Check for resumable sessions first
const resumeSession = await this.progressManager.showResumeDialog();
if (resumeSession) {
return await this.resumeSession(resumeSession);
}
// Main menu
await this.showMainMenu();
}
async showMainMenu() {
while (true) {
console.log(chalk.cyan('\n๐ง AI Audiobook Maker - Main Menu'));
console.log(chalk.gray('Use arrow keys to navigate, Enter to select\n'));
const { action } = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: 'What would you like to do?',
choices: [
{ name: '๐ Convert a file to audiobook', value: 'convert' },
{ name: '๐ค Preview voices', value: 'preview' },
{ name: 'โ๏ธ Manage API key', value: 'config' },
{ name: '๐ View session history', value: 'history' },
{ name: '๐งน Clear cache', value: 'clear_cache' },
{ name: 'โ Exit', value: 'exit' }
]
}
]);
switch (action) {
case 'convert':
await this.startConversion();
break;
case 'preview':
await this.previewVoicesOnly();
break;
case 'config':
await this.configManager.manageApiKey();
break;
case 'history':
await this.showSessionHistory();
break;
case 'clear_cache':
await this.configManager.clearCache();
break;
case 'exit':
console.log(chalk.yellow('\n๐ Goodbye! Thank you for using AI Audiobook Maker! ๐'));
process.exit(0);
}
}
}
async startConversion() {
try {
const apiKey = await this.configManager.ensureApiKey();
this.ttsService = new TTSService(apiKey, this.configManager.getCacheDir());
this.voicePreview = new VoicePreview(this.ttsService);
// Select file
const filePath = await this.fileHandler.selectFile();
if (!filePath) return;
// Process the file
await this.processFile(filePath);
} catch (error) {
console.log(chalk.red(`โ Error: ${error.message}`));
}
}
async processFile(filePath, cliOptions = {}) {
try {
// Initialize services if not already done
if (!this.ttsService) {
const apiKey = await this.configManager.ensureApiKey();
this.ttsService = new TTSService(apiKey, this.configManager.getCacheDir());
this.voicePreview = new VoicePreview(this.ttsService);
}
console.log(chalk.cyan('\n๐ Analyzing file...'));
// Read and analyze file
const fileData = await this.fileHandler.readFile(filePath);
const chunks = this.fileHandler.splitTextIntoChunks(fileData.content);
const costInfo = this.fileHandler.calculateCost(fileData.content);
// Display file info
this.displayFileInfo(fileData, costInfo, chunks.length);
// Check for existing session
const existingSession = await this.progressManager.findExistingSession(filePath);
if (existingSession && existingSession.progress.completedChunks > 0) {
const resumeConfirmed = await this.promptResumeExisting(existingSession);
if (resumeConfirmed) {
return await this.resumeSession(existingSession, { chunks, fileData });
}
}
// Get conversion settings
const settings = await this.getConversionSettings(cliOptions);
if (!settings) return;
// Create new session
const session = await this.progressManager.createSession(filePath, settings);
await this.progressManager.updateProgress(session.id, {
totalChunks: chunks.length,
status: 'processing'
});
// Start conversion
await this.convertToAudio(session, chunks, fileData, settings);
} catch (error) {
console.log(chalk.red(`โ Error processing file: ${error.message}`));
}
}
displayFileInfo(fileData, costInfo, chunkCount) {
console.log(chalk.green('\nโ
File analyzed successfully!'));
console.log(chalk.white('\n๐ File Information:'));
console.log(chalk.gray(` Type: ${fileData.type.toUpperCase()}`));
console.log(chalk.gray(` Characters: ${fileData.characterCount.toLocaleString()}`));
console.log(chalk.gray(` Words: ${fileData.wordCount.toLocaleString()}`));
if (fileData.pageCount) {
console.log(chalk.gray(` Pages: ${fileData.pageCount}`));
}
console.log(chalk.gray(` Chunks: ${chunkCount}`));
console.log(chalk.gray(` Estimated cost: $${costInfo.estimatedCost.toFixed(2)} USD`));
const estimatedTime = this.ttsService?.estimateProcessingTime(fileData.characterCount) || '~Unknown';
console.log(chalk.gray(` Estimated time: ${estimatedTime}`));
}
async promptResumeExisting(session) {
const progress = `${session.progress.completedChunks}/${session.progress.totalChunks}`;
console.log(chalk.yellow('\nโ ๏ธ Found existing conversion for this file'));
console.log(chalk.gray(` Progress: ${progress} chunks (${session.progress.percentage}%)`));
console.log(chalk.gray(` Last updated: ${this.progressManager.getTimeAgo(session.updatedAt)}`));
const { resume } = await inquirer.prompt([
{
type: 'confirm',
name: 'resume',
message: 'Resume previous conversion?',
default: true
}
]);
return resume;
}
async getConversionSettings(cliOptions = {}) {
// Use CLI options if provided
if (cliOptions.voice && cliOptions.speed && cliOptions.model) {
return {
voice: cliOptions.voice,
speed: cliOptions.speed,
model: cliOptions.model,
outputOptions: 'single'
};
}
// Interactive voice selection
const voice = await this.voicePreview.showVoiceSelection();
if (!voice) return null;
// Get advanced settings
const advancedSettings = await this.voicePreview.getAdvancedSettings();
return {
voice,
...advancedSettings
};
}
async convertToAudio(session, chunks, fileData, settings) {
const outputDir = path.join(process.cwd(), 'audiobook_output', `${path.basename(session.filePath, path.extname(session.filePath))}_${session.id}`);
await fs.ensureDir(outputDir);
await this.progressManager.updateProgress(session.id, { outputDir });
console.log(chalk.cyan('\n๐๏ธ Starting audio conversion...'));
console.log(chalk.gray(`Output directory: ${outputDir}\n`));
try {
// Process chunks
const audioFiles = await this.ttsService.processTextChunks(
chunks,
{
voice: settings.voice,
model: settings.model,
speed: settings.speed,
outputDir
},
(progress) => {
this.progressManager.updateProgress(session.id, {
currentChunk: progress.current,
filePath: progress.filePath
});
}
);
// Handle output options
if (settings.outputOptions === 'single' || settings.outputOptions === 'both') {
const finalOutputPath = path.join(outputDir, `${path.basename(session.filePath, path.extname(session.filePath))}_audiobook.mp3`);
console.log(chalk.cyan('\n๐ Combining audio files...'));
await this.ttsService.concatenateAudioFiles(audioFiles, finalOutputPath);
await this.progressManager.updateProgress(session.id, {
finalOutputPath,
status: 'completed'
});
console.log(chalk.green('\n๐ Audiobook creation completed!'));
console.log(chalk.white(`๐ Single file: ${finalOutputPath}`));
if (settings.outputOptions === 'single') {
// Clean up individual chunk files
await this.cleanupChunkFiles(audioFiles);
}
}
if (settings.outputOptions === 'separate' || settings.outputOptions === 'both') {
console.log(chalk.green('\n๐ Individual chapter files available:'));
audioFiles.forEach((file, index) => {
console.log(chalk.white(` Chapter ${index + 1}: ${file}`));
});
}
// Display final summary
await this.displayCompletionSummary(session, fileData, audioFiles.length);
} catch (error) {
await this.progressManager.updateProgress(session.id, {
status: 'failed',
error: error.message
});
throw error;
}
}
async resumeSession(session, additionalData = null) {
console.log(chalk.cyan(`\n๐ Resuming session: ${session.fileName}`));
try {
// Re-initialize services
const apiKey = await this.configManager.ensureApiKey();
this.ttsService = new TTSService(apiKey, this.configManager.getCacheDir());
let chunks, fileData;
if (additionalData) {
chunks = additionalData.chunks;
fileData = additionalData.fileData;
} else {
// Re-read file data
fileData = await this.fileHandler.readFile(session.filePath);
chunks = this.fileHandler.splitTextIntoChunks(fileData.content);
}
// Determine remaining chunks
const remainingChunks = chunks.slice(session.progress.completedChunks);
if (remainingChunks.length === 0) {
console.log(chalk.green('โ
Session already completed!'));
return;
}
console.log(chalk.yellow(`Resuming from chunk ${session.progress.completedChunks + 1}/${chunks.length}`));
console.log(chalk.gray(`Remaining: ${remainingChunks.length} chunks\n`));
// Continue conversion
const outputDir = session.outputDir || path.join(process.cwd(), 'audiobook_output', `${path.basename(session.filePath, path.extname(session.filePath))}_${session.id}`);
await fs.ensureDir(outputDir);
// Process remaining chunks
const remainingAudioFiles = await this.ttsService.processTextChunks(
remainingChunks,
{
voice: session.options.voice,
model: session.options.model,
speed: session.options.speed,
outputDir
},
(progress) => {
const actualChunkNumber = session.progress.completedChunks + progress.current;
this.progressManager.updateProgress(session.id, {
currentChunk: actualChunkNumber,
filePath: progress.filePath
});
}
);
// Combine all audio files (existing + new)
const allAudioFiles = [];
// Add existing files
for (let i = 1; i <= session.progress.completedChunks; i++) {
const fileName = `chunk_${i.toString().padStart(3, '0')}.mp3`;
allAudioFiles.push(path.join(outputDir, fileName));
}
// Add new files
allAudioFiles.push(...remainingAudioFiles);
// Create final output
if (session.options.outputOptions !== 'separate') {
const finalOutputPath = path.join(outputDir, `${path.basename(session.filePath, path.extname(session.filePath))}_audiobook.mp3`);
await this.ttsService.concatenateAudioFiles(allAudioFiles, finalOutputPath);
await this.progressManager.updateProgress(session.id, {
finalOutputPath,
status: 'completed'
});
console.log(chalk.green('\n๐ Audiobook resumed and completed!'));
console.log(chalk.white(`๐ Final file: ${finalOutputPath}`));
}
await this.displayCompletionSummary(session, fileData, allAudioFiles.length);
} catch (error) {
await this.progressManager.updateProgress(session.id, {
status: 'failed',
error: error.message
});
console.log(chalk.red(`โ Resume failed: ${error.message}`));
}
}
async cleanupChunkFiles(audioFiles) {
try {
for (const file of audioFiles) {
await fs.remove(file);
}
} catch (error) {
console.log(chalk.yellow(`โ ๏ธ Could not clean up chunk files: ${error.message}`));
}
}
async displayCompletionSummary(session, fileData, audioFileCount) {
console.log(chalk.green('\n๐ Conversion Summary:'));
console.log(chalk.white(` ๐ Source: ${session.fileName}`));
console.log(chalk.white(` ๐ค Voice: ${session.options.voice}`));
console.log(chalk.white(` ๐ค Model: ${session.options.model}`));
console.log(chalk.white(` โก Speed: ${session.options.speed}x`));
console.log(chalk.white(` ๐ Chunks processed: ${audioFileCount}`));
console.log(chalk.white(` ๐ฐ Estimated cost: $${this.fileHandler.calculateCost(fileData.content, session.options.model).estimatedCost.toFixed(2)}`));
console.log(chalk.white(` ๐ Output location: ${session.outputDir}`));
if (session.finalOutputPath) {
console.log(chalk.cyan('\n๐ง Your audiobook is ready to enjoy!'));
}
}
async previewVoicesOnly() {
try {
const apiKey = await this.configManager.ensureApiKey();
this.ttsService = new TTSService(apiKey, this.configManager.getCacheDir());
this.voicePreview = new VoicePreview(this.ttsService);
await this.voicePreview.showVoiceSelection();
} catch (error) {
console.log(chalk.red(`โ Error: ${error.message}`));
}
}
async showSessionHistory() {
const stats = await this.progressManager.getSessionStats();
const recentSessions = await this.progressManager.getRecentSessions(10);
console.log(chalk.cyan('\n๐ Session Statistics:'));
console.log(chalk.white(` Total sessions: ${stats.total}`));
console.log(chalk.white(` Completed: ${stats.completed}`));
console.log(chalk.white(` In progress: ${stats.inProgress}`));
console.log(chalk.white(` Failed: ${stats.failed}`));
console.log(chalk.white(` Total chunks processed: ${stats.totalProcessedChunks}`));
if (recentSessions.length > 0) {
console.log(chalk.cyan('\n๐ Recent Sessions:'));
recentSessions.forEach((session, index) => {
const status = this.getStatusEmoji(session.status);
const progress = session.progress.totalChunks > 0
? `${session.progress.completedChunks}/${session.progress.totalChunks}`
: 'Not started';
console.log(chalk.white(` ${index + 1}. ${status} ${session.fileName} - ${progress} - ${this.progressManager.getTimeAgo(session.updatedAt)}`));
});
}
const { action } = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: 'Session management:',
choices: [
{ name: '๐ Back to main menu', value: 'back' },
{ name: '๐งน Clear all sessions', value: 'clear' }
]
}
]);
if (action === 'clear') {
await this.progressManager.clearOldSessions();
}
}
getStatusEmoji(status) {
switch (status) {
case 'completed': return 'โ
';
case 'processing': return '๐';
case 'failed': return 'โ';
default: return 'โณ';
}
}
}
module.exports = AudiobookMaker;