UNPKG

aiabm

Version:

AI Audiobook Maker - Convert PDFs and text files to audiobooks using OpenAI TTS or Thorsten-Voice (native German)

450 lines (394 loc) • 13.9 kB
const inquirer = require('inquirer'); const chalk = require('chalk'); const { exec } = require('child_process'); const { promisify } = require('util'); const os = require('os'); const path = require('path'); const fs = require('fs-extra'); const execAsync = promisify(exec); class VoicePreview { constructor(ttsService) { this.ttsService = ttsService; this.platform = os.platform(); } async showVoiceSelection(provider = 'openai') { const providerName = provider || 'openai'; console.log(chalk.cyan(`\nšŸŽ¤ Voice Selection & Preview (${providerName.toUpperCase()})`)); console.log(chalk.gray('Listen to each voice before making your choice\n')); const { action } = await inquirer.prompt([ { type: 'list', name: 'action', message: 'What would you like to do?', choices: [ { name: 'šŸŽ§ Preview all voices', value: 'preview_all' }, { name: 'šŸŽÆ Select voice directly', value: 'select_direct' }, { name: 'šŸ”™ Back to main menu', value: 'back' }, ], }, ]); switch (action) { case 'preview_all': return await this.previewAllVoices(provider); case 'select_direct': return await this.selectVoiceDirect(provider); case 'back': return null; } } async previewAllVoices(_provider = 'openai') { console.log(chalk.cyan('\nšŸŽµ Generating previews for all voices...')); try { const { previews, errors } = await this.ttsService.generateAllPreviews(); if (Object.keys(previews).length === 0) { console.log(chalk.red('āŒ Failed to generate any previews')); return null; } return await this.playPreviewsAndSelect(previews, errors); } catch (error) { console.log(chalk.red(`āŒ Error generating previews: ${error.message}`)); return null; } } async playPreviewsAndSelect(previews, errors = []) { const availableVoices = Object.keys(previews); if (availableVoices.length === 0) { console.log(chalk.red('āŒ No voice previews available')); return null; } console.log(chalk.green('\nāœ… Voice previews ready!')); console.log(chalk.gray('Preview text: "' + this.ttsService.previewText + '"\n')); const selectedVoice = null; while (!selectedVoice) { const choices = availableVoices.map((voice) => { const displayName = typeof voice === 'string' ? `${voice.charAt(0).toUpperCase() + voice.slice(1)}` : voice.name || voice; return { name: `šŸŽµ ${displayName}`, value: voice, }; }); // Add error voices as disabled options if (errors.length > 0) { choices.push(new inquirer.Separator(chalk.red('── Failed to generate ──'))); errors.forEach(({ voice, error }) => { choices.push({ name: chalk.red(`āŒ ${voice} (${error})`), disabled: true, }); }); } choices.push(new inquirer.Separator('── Actions ──')); choices.push({ name: 'āœ… Select this voice and continue', value: 'select' }); choices.push({ name: 'šŸ”„ Regenerate failed previews', value: 'retry' }); choices.push({ name: 'šŸ”™ Back to main menu', value: 'back' }); const { choice } = await inquirer.prompt([ { type: 'list', name: 'choice', message: 'Choose a voice to preview or take action:', choices, pageSize: 15, }, ]); if (choice === 'back') { return null; } else if (choice === 'select') { return await this.selectFromPreviewed(availableVoices); } else if (choice === 'retry') { return await this.retryFailedPreviews(errors); } else if (availableVoices.includes(choice)) { await this.playPreview(previews[choice], choice); } } } async selectFromPreviewed(availableVoices) { const choices = availableVoices.map((voice) => { const displayName = typeof voice === 'string' ? `${voice.charAt(0).toUpperCase() + voice.slice(1)}` : voice.name || voice; return { name: displayName, value: voice, }; }); const { voice } = await inquirer.prompt([ { type: 'list', name: 'voice', message: 'Select your preferred voice:', choices: choices, }, ]); return voice; } async retryFailedPreviews(errors) { console.log(chalk.yellow('\nšŸ”„ Retrying failed previews...')); const retryResults = { previews: {}, errors: [] }; for (const { voice } of errors) { try { const previewFile = await this.ttsService.generateVoicePreview(voice); retryResults.previews[voice] = previewFile; } catch (error) { retryResults.errors.push({ voice, error: error.message }); } } return await this.playPreviewsAndSelect(retryResults.previews, retryResults.errors); } async selectVoiceDirect(_provider = 'openai') { const voices = this.ttsService.getVoices(); // Handle both string voices (OpenAI) and object voices (Kyutai) const choices = voices.map((voice) => { if (typeof voice === 'string') { // OpenAI voices are strings return { name: `${voice.charAt(0).toUpperCase() + voice.slice(1)}`, value: voice, }; } else { // Kyutai voices are objects with name and value return { name: voice.name, value: voice.value, }; } }); const { voice } = await inquirer.prompt([ { type: 'list', name: 'voice', message: 'Select a voice:', choices: choices, }, ]); const { wantPreview } = await inquirer.prompt([ { type: 'confirm', name: 'wantPreview', message: `Would you like to preview the ${voice} voice before continuing?`, default: true, }, ]); if (wantPreview) { try { const previewFile = await this.ttsService.generateVoicePreview(voice); await this.playPreview(previewFile, voice); const { confirmed } = await inquirer.prompt([ { type: 'confirm', name: 'confirmed', message: `Use ${voice} voice for your audiobook?`, default: true, }, ]); if (!confirmed) { return await this.selectVoiceDirect(); } } catch (error) { console.log(chalk.red(`āŒ Failed to generate preview: ${error.message}`)); const { proceed } = await inquirer.prompt([ { type: 'confirm', name: 'proceed', message: `Continue with ${voice} voice anyway?`, default: false, }, ]); if (!proceed) { return await this.selectVoiceDirect(); } } } return voice; } async playPreview(filePath, voiceName) { if (!(await fs.pathExists(filePath))) { console.log(chalk.red(`āŒ Preview file not found: ${filePath}`)); return; } console.log(chalk.cyan(`\nšŸŽµ Playing preview for ${voiceName}...`)); console.log(chalk.gray('Press Ctrl+C to stop playback\n')); try { await this.playAudioFile(filePath); console.log(chalk.green(`āœ… Preview completed for ${voiceName}`)); } catch (error) { console.log(chalk.red(`āŒ Failed to play preview: ${error.message}`)); console.log(chalk.yellow(`šŸ’” You can manually play: ${filePath}`)); } } async playAudioFile(filePath) { let command; switch (this.platform) { case 'darwin': // macOS command = `afplay "${filePath}"`; break; case 'win32': // Windows command = `powershell -c "Add-Type -AssemblyName presentationCore; $mediaPlayer = New-Object system.windows.media.mediaplayer; $mediaPlayer.open('${filePath}'); $mediaPlayer.Play(); Start-Sleep -s 12; $mediaPlayer.Stop()"`; break; case 'linux': { // Linux // Try different audio players const players = ['ffplay', 'mpv', 'vlc', 'mplayer']; let playerFound = false; for (const player of players) { try { await execAsync(`which ${player}`); command = player === 'ffplay' ? `ffplay -nodisp -autoexit "${filePath}"` : `${player} "${filePath}"`; playerFound = true; break; } catch (error) { // Player not found, try next } } if (!playerFound) { throw new Error('No audio player found. Please install ffplay, mpv, vlc, or mplayer'); } break; } default: throw new Error(`Unsupported platform: ${this.platform}`); } try { await execAsync(command, { timeout: 15000 }); // 15 second timeout } catch (error) { if (error.signal === 'SIGTERM') { // Normal timeout, preview completed return; } throw error; } } async getAdvancedSettings(_provider = 'openai') { const providerName = _provider || 'openai'; console.log(chalk.cyan(`\nāš™ļø Advanced Settings (${providerName.toUpperCase()})`)); const promptFields = [ { type: 'input', name: 'speed', message: 'Speech speed (0.25 - 4.0):', default: '1.0', validate: (input) => { const speed = parseFloat(input); if (isNaN(speed) || speed < 0.25 || speed > 4.0) { return 'Speed must be a number between 0.25 and 4.0'; } return true; }, }, ]; // Add provider-specific settings if (providerName === 'openai') { promptFields.push({ type: 'list', name: 'model', message: 'TTS Model:', choices: [ { name: 'tts-1 (Faster, Standard Quality)', value: 'tts-1' }, { name: 'tts-1-hd (Slower, Higher Quality)', value: 'tts-1-hd' }, ], default: 'tts-1', }); } else if (providerName === 'kyutai') { promptFields.push({ type: 'list', name: 'model', message: 'Quality Setting:', choices: [ { name: 'Standard (Faster)', value: 'standard' }, { name: 'High Quality (Slower)', value: 'high' }, ], default: 'standard', }); } promptFields.push({ type: 'list', name: 'outputOptions', message: 'Output format:', choices: [ { name: 'Single MP3 file (recommended)', value: 'single' }, { name: 'Separate files per chunk', value: 'separate' }, { name: 'Both single and separate files', value: 'both' }, ], default: 'single', }); promptFields.push({ type: 'list', name: 'outputDirectory', message: 'Output location:', choices: [ { name: 'šŸ“ Desktop', value: path.join(os.homedir(), 'Desktop') }, { name: 'ā¬‡ļø Downloads', value: path.join(os.homedir(), 'Downloads') }, { name: 'šŸ“‚ Documents', value: path.join(os.homedir(), 'Documents') }, { name: 'šŸŽµ Music folder', value: path.join(os.homedir(), 'Music') }, { name: 'šŸ“‹ Current directory', value: process.cwd() }, { name: 'āœļø Custom path...', value: 'custom' }, ], default: path.join(os.homedir(), 'Downloads'), }); promptFields.push({ type: 'confirm', name: 'createSubfolder', message: 'Create subfolder for this audiobook?', default: true, }); const { speed, model, outputOptions, outputDirectory, createSubfolder } = await inquirer.prompt(promptFields); // Handle custom path input let finalOutputDirectory = outputDirectory; if (outputDirectory === 'custom') { const { customPath } = await inquirer.prompt([ { type: 'input', name: 'customPath', message: 'Enter custom output path:', default: path.join(os.homedir(), 'Downloads'), validate: async (input) => { try { // Handle relative paths better let expandedPath = input; if (input.startsWith('~')) { expandedPath = path.join(os.homedir(), input.slice(1)); } else if (input === 'Downloads' || input === 'downloads') { expandedPath = path.join(os.homedir(), 'Downloads'); } else if (input === 'Desktop' || input === 'desktop') { expandedPath = path.join(os.homedir(), 'Desktop'); } else if (!path.isAbsolute(input)) { expandedPath = path.join(os.homedir(), input); } else { expandedPath = path.resolve(input); } await fs.ensureDir(expandedPath); return true; } catch (error) { return `Invalid path or unable to create directory: ${error.message}`; } }, }, ]); // Handle special folder names if (customPath === 'Downloads' || customPath === 'downloads') { finalOutputDirectory = path.join(os.homedir(), 'Downloads'); } else if (customPath === 'Desktop' || customPath === 'desktop') { finalOutputDirectory = path.join(os.homedir(), 'Desktop'); } else if (customPath.startsWith('~')) { finalOutputDirectory = path.join(os.homedir(), customPath.slice(1)); } else if (!path.isAbsolute(customPath)) { finalOutputDirectory = path.join(os.homedir(), customPath); } else { finalOutputDirectory = path.resolve(customPath); } } return { speed: parseFloat(speed), model, outputOptions, outputDirectory: finalOutputDirectory, createSubfolder, }; } } module.exports = VoicePreview;