Fix
This commit is contained in:
		@@ -3,7 +3,7 @@ import {
 | 
				
			|||||||
  openAI_ClassifyFeed,
 | 
					  openAI_ClassifyFeed,
 | 
				
			||||||
  openAI_GeneratePodcastContent,
 | 
					  openAI_GeneratePodcastContent,
 | 
				
			||||||
} from "../services/llm.js";
 | 
					} from "../services/llm.js";
 | 
				
			||||||
import { generateTTS } from "../services/tts.js";
 | 
					import { generateTTS, generateTTSWithoutQueue } from "../services/tts.js";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  saveFeed,
 | 
					  saveFeed,
 | 
				
			||||||
  getFeedByUrl,
 | 
					  getFeedByUrl,
 | 
				
			||||||
@@ -208,15 +208,30 @@ async function processUnprocessedArticles(): Promise<void> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    console.log(`🎯 Found ${unprocessedArticles.length} unprocessed articles`);
 | 
					    console.log(`🎯 Found ${unprocessedArticles.length} unprocessed articles`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Track articles that successfully generated audio
 | 
					    // Track articles that successfully generated audio AND episodes
 | 
				
			||||||
    const successfullyGeneratedArticles: string[] = [];
 | 
					    let successfullyGeneratedCount = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for (const article of unprocessedArticles) {
 | 
					    for (const article of unprocessedArticles) {
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        await generatePodcastForArticle(article);
 | 
					        const episodeCreated = await generatePodcastForArticle(article);
 | 
				
			||||||
        await markArticleAsProcessed(article.id);
 | 
					        
 | 
				
			||||||
        console.log(`✅ Podcast generated for: ${article.title}`);
 | 
					        // Only mark as processed and update RSS if episode was actually created
 | 
				
			||||||
        successfullyGeneratedArticles.push(article.id);
 | 
					        if (episodeCreated) {
 | 
				
			||||||
 | 
					          await markArticleAsProcessed(article.id);
 | 
				
			||||||
 | 
					          console.log(`✅ Podcast generated for: ${article.title}`);
 | 
				
			||||||
 | 
					          successfullyGeneratedCount++;
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          // Update RSS immediately after each successful episode creation
 | 
				
			||||||
 | 
					          console.log(`📻 Updating podcast RSS after successful episode creation...`);
 | 
				
			||||||
 | 
					          try {
 | 
				
			||||||
 | 
					            await updatePodcastRSS();
 | 
				
			||||||
 | 
					            console.log(`📻 RSS updated successfully for: ${article.title}`);
 | 
				
			||||||
 | 
					          } catch (rssError) {
 | 
				
			||||||
 | 
					            console.error(`❌ Failed to update RSS after episode creation for: ${article.title}`, rssError);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          console.warn(`⚠️  Episode creation failed for: ${article.title} - not marking as processed`);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
      } catch (error) {
 | 
					      } catch (error) {
 | 
				
			||||||
        console.error(
 | 
					        console.error(
 | 
				
			||||||
          `❌ Failed to generate podcast for article: ${article.title}`,
 | 
					          `❌ Failed to generate podcast for article: ${article.title}`,
 | 
				
			||||||
@@ -226,10 +241,9 @@ async function processUnprocessedArticles(): Promise<void> {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Only update RSS if at least one article was successfully processed
 | 
					    console.log(`🎯 Batch processing completed: ${successfullyGeneratedCount} episodes successfully created`);
 | 
				
			||||||
    if (successfullyGeneratedArticles.length > 0) {
 | 
					    if (successfullyGeneratedCount === 0) {
 | 
				
			||||||
      console.log(`📻 Updating podcast RSS for ${successfullyGeneratedArticles.length} new episodes...`);
 | 
					      console.log(`ℹ️  No episodes were successfully created in this batch`);
 | 
				
			||||||
      await updatePodcastRSS();
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    console.error("💥 Error processing unprocessed articles:", error);
 | 
					    console.error("💥 Error processing unprocessed articles:", error);
 | 
				
			||||||
@@ -242,6 +256,8 @@ async function processUnprocessedArticles(): Promise<void> {
 | 
				
			|||||||
 */
 | 
					 */
 | 
				
			||||||
async function processRetryQueue(): Promise<void> {
 | 
					async function processRetryQueue(): Promise<void> {
 | 
				
			||||||
  const { getQueueItems, updateQueueItemStatus, removeFromQueue } = await import("../services/database.js");
 | 
					  const { getQueueItems, updateQueueItemStatus, removeFromQueue } = await import("../services/database.js");
 | 
				
			||||||
 | 
					  const { Database } = await import("bun:sqlite");
 | 
				
			||||||
 | 
					  const db = new Database(config.paths.dbPath);
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
  console.log("🔄 Processing TTS retry queue...");
 | 
					  console.log("🔄 Processing TTS retry queue...");
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
@@ -256,41 +272,65 @@ async function processRetryQueue(): Promise<void> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    for (const item of queueItems) {
 | 
					    for (const item of queueItems) {
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        console.log(`🔁 Retrying TTS generation for: ${item.itemId} (attempt ${item.retryCount + 1})`);
 | 
					        console.log(`🔁 Retrying TTS generation for: ${item.itemId} (attempt ${item.retryCount + 1}/3)`);
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        // Mark as processing
 | 
					        // Mark as processing
 | 
				
			||||||
        await updateQueueItemStatus(item.id, 'processing');
 | 
					        await updateQueueItemStatus(item.id, 'processing');
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        // Attempt TTS generation
 | 
					        // Attempt TTS generation without re-queuing on failure
 | 
				
			||||||
        await generateTTS(item.itemId, item.scriptText, item.retryCount);
 | 
					        const audioFilePath = await generateTTSWithoutQueue(item.itemId, item.scriptText, item.retryCount);
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        // Success - remove from queue
 | 
					        // Success - remove from queue and update RSS
 | 
				
			||||||
        await removeFromQueue(item.id);
 | 
					        await removeFromQueue(item.id);
 | 
				
			||||||
        console.log(`✅ TTS retry successful for: ${item.itemId}`);
 | 
					        console.log(`✅ TTS retry successful for: ${item.itemId}`);
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
 | 
					        // Update RSS immediately after successful retry
 | 
				
			||||||
 | 
					        console.log(`📻 Updating podcast RSS after successful retry...`);
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					          await updatePodcastRSS();
 | 
				
			||||||
 | 
					          console.log(`📻 RSS updated successfully after retry for: ${item.itemId}`);
 | 
				
			||||||
 | 
					        } catch (rssError) {
 | 
				
			||||||
 | 
					          console.error(`❌ Failed to update RSS after retry for: ${item.itemId}`, rssError);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
      } catch (error) {
 | 
					      } catch (error) {
 | 
				
			||||||
        console.error(`❌ TTS retry failed for: ${item.itemId}`, error);
 | 
					        console.error(`❌ TTS retry failed for: ${item.itemId}`, error);
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        if (item.retryCount >= 2) {
 | 
					        try {
 | 
				
			||||||
          // Max retries reached, mark as failed
 | 
					          if (item.retryCount >= 2) {
 | 
				
			||||||
          await updateQueueItemStatus(item.id, 'failed');
 | 
					            // Max retries reached, mark as failed
 | 
				
			||||||
          console.log(`💀 Max retries reached for: ${item.itemId}, marking as failed`);
 | 
					            await updateQueueItemStatus(item.id, 'failed');
 | 
				
			||||||
        } else {
 | 
					            console.log(`💀 Max retries reached for: ${item.itemId}, marking as failed`);
 | 
				
			||||||
          // Reset to pending for next retry
 | 
					          } else {
 | 
				
			||||||
          await updateQueueItemStatus(item.id, 'pending');
 | 
					            // Increment retry count and reset to pending for next retry
 | 
				
			||||||
 | 
					            const updatedRetryCount = item.retryCount + 1;
 | 
				
			||||||
 | 
					            const stmt = db.prepare("UPDATE tts_queue SET retry_count = ?, status = 'pending' WHERE id = ?");
 | 
				
			||||||
 | 
					            stmt.run(updatedRetryCount, item.id);
 | 
				
			||||||
 | 
					            console.log(`🔄 Updated retry count to ${updatedRetryCount} for: ${item.itemId}`);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        } catch (dbError) {
 | 
				
			||||||
 | 
					          console.error(`❌ Failed to update queue status for: ${item.itemId}`, dbError);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    console.error("💥 Error processing retry queue:", error);
 | 
					    console.error("💥 Error processing retry queue:", error);
 | 
				
			||||||
    throw error;
 | 
					    throw error;
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    // Clean up database connection
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      db.close();
 | 
				
			||||||
 | 
					    } catch (closeError) {
 | 
				
			||||||
 | 
					      console.warn("⚠️ Warning: Failed to close database connection:", closeError);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Generate podcast for a single article
 | 
					 * Generate podcast for a single article
 | 
				
			||||||
 | 
					 * Returns true if episode was successfully created, false otherwise
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
async function generatePodcastForArticle(article: any): Promise<void> {
 | 
					async function generatePodcastForArticle(article: any): Promise<boolean> {
 | 
				
			||||||
  console.log(`🎤 Generating podcast for: ${article.title}`);
 | 
					  console.log(`🎤 Generating podcast for: ${article.title}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
@@ -315,31 +355,66 @@ async function generatePodcastForArticle(article: any): Promise<void> {
 | 
				
			|||||||
    // Generate unique ID for the episode
 | 
					    // Generate unique ID for the episode
 | 
				
			||||||
    const episodeId = crypto.randomUUID();
 | 
					    const episodeId = crypto.randomUUID();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Generate TTS audio
 | 
					    // Generate TTS audio - this is the critical step that can fail
 | 
				
			||||||
    const audioFilePath = await generateTTS(episodeId, podcastContent);
 | 
					    let audioFilePath: string;
 | 
				
			||||||
    console.log(`🔊 Audio generated: ${audioFilePath}`);
 | 
					    try {
 | 
				
			||||||
 | 
					      audioFilePath = await generateTTS(episodeId, podcastContent);
 | 
				
			||||||
 | 
					      console.log(`🔊 Audio generated: ${audioFilePath}`);
 | 
				
			||||||
 | 
					    } catch (ttsError) {
 | 
				
			||||||
 | 
					      console.error(`❌ TTS generation failed for ${article.title}:`, ttsError);
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Check if error indicates item was added to retry queue
 | 
				
			||||||
 | 
					      const errorMessage = ttsError instanceof Error ? ttsError.message : String(ttsError);
 | 
				
			||||||
 | 
					      if (errorMessage.includes('added to retry queue')) {
 | 
				
			||||||
 | 
					        console.log(`📋 Article will be retried later via TTS queue: ${article.title}`);
 | 
				
			||||||
 | 
					        // Don't mark as processed - leave it for retry
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        console.error(`💀 TTS generation permanently failed for ${article.title} - max retries exceeded`);
 | 
				
			||||||
 | 
					        // Max retries exceeded, don't create episode but mark as processed to avoid infinite retry
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Verify audio file was actually created and is valid
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const audioStats = await getAudioFileStats(audioFilePath);
 | 
				
			||||||
 | 
					      if (audioStats.size === 0) {
 | 
				
			||||||
 | 
					        console.error(`❌ Audio file is empty for ${article.title}: ${audioFilePath}`);
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (statsError) {
 | 
				
			||||||
 | 
					      console.error(`❌ Cannot access audio file for ${article.title}: ${audioFilePath}`, statsError);
 | 
				
			||||||
 | 
					      return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Get audio file stats
 | 
					    // Get audio file stats
 | 
				
			||||||
    const audioStats = await getAudioFileStats(audioFilePath);
 | 
					    const audioStats = await getAudioFileStats(audioFilePath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Save episode
 | 
					    // Save episode - only if we have valid audio
 | 
				
			||||||
    await saveEpisode({
 | 
					    try {
 | 
				
			||||||
      articleId: article.id,
 | 
					      await saveEpisode({
 | 
				
			||||||
      title: `${category}: ${article.title}`,
 | 
					        articleId: article.id,
 | 
				
			||||||
      description:
 | 
					        title: `${category}: ${article.title}`,
 | 
				
			||||||
        article.description || `Podcast episode for: ${article.title}`,
 | 
					        description:
 | 
				
			||||||
      audioPath: audioFilePath,
 | 
					          article.description || `Podcast episode for: ${article.title}`,
 | 
				
			||||||
      duration: audioStats.duration,
 | 
					        audioPath: audioFilePath,
 | 
				
			||||||
      fileSize: audioStats.size,
 | 
					        duration: audioStats.duration,
 | 
				
			||||||
    });
 | 
					        fileSize: audioStats.size,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    console.log(`💾 Episode saved for article: ${article.title}`);
 | 
					      console.log(`💾 Episode saved for article: ${article.title}`);
 | 
				
			||||||
 | 
					      return true;
 | 
				
			||||||
 | 
					    } catch (saveError) {
 | 
				
			||||||
 | 
					      console.error(`❌ Failed to save episode for ${article.title}:`, saveError);
 | 
				
			||||||
 | 
					      return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    console.error(
 | 
					    console.error(
 | 
				
			||||||
      `💥 Error generating podcast for article: ${article.title}`,
 | 
					      `💥 Error generating podcast for article: ${article.title}`,
 | 
				
			||||||
      error,
 | 
					      error,
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    throw error;
 | 
					    return false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										195
									
								
								services/tts.ts
									
									
									
									
									
								
							
							
						
						
									
										195
									
								
								services/tts.ts
									
									
									
									
									
								
							@@ -12,7 +12,11 @@ const defaultVoiceStyle: VoiceStyle = {
 | 
				
			|||||||
  styleId: config.voicevox.styleId,
 | 
					  styleId: config.voicevox.styleId,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function generateTTS(
 | 
					/**
 | 
				
			||||||
 | 
					 * Generate TTS without adding to retry queue on failure
 | 
				
			||||||
 | 
					 * Used for retry queue processing to avoid infinite loops
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export async function generateTTSWithoutQueue(
 | 
				
			||||||
  itemId: string,
 | 
					  itemId: string,
 | 
				
			||||||
  scriptText: string,
 | 
					  scriptText: string,
 | 
				
			||||||
  retryCount: number = 0,
 | 
					  retryCount: number = 0,
 | 
				
			||||||
@@ -25,104 +29,113 @@ export async function generateTTS(
 | 
				
			|||||||
    throw new Error("Script text is required for TTS generation");
 | 
					    throw new Error("Script text is required for TTS generation");
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  console.log(`TTS生成開始: ${itemId} (試行回数: ${retryCount + 1})`);
 | 
				
			||||||
 | 
					  const encodedText = encodeURIComponent(scriptText);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
 | 
				
			||||||
 | 
					  const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const queryResponse = await fetch(queryUrl, {
 | 
				
			||||||
 | 
					    method: "POST",
 | 
				
			||||||
 | 
					    headers: {
 | 
				
			||||||
 | 
					      "Content-Type": "application/json",
 | 
				
			||||||
 | 
					      Accept: "application/json",
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!queryResponse.ok) {
 | 
				
			||||||
 | 
					    const errorText = await queryResponse.text();
 | 
				
			||||||
 | 
					    throw new Error(
 | 
				
			||||||
 | 
					      `VOICEVOX audio query failed (${queryResponse.status}): ${errorText}`,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const audioQuery = await queryResponse.json();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  console.log(`音声合成開始: ${itemId}`);
 | 
				
			||||||
 | 
					  const audioResponse = await fetch(synthesisUrl, {
 | 
				
			||||||
 | 
					    method: "POST",
 | 
				
			||||||
 | 
					    headers: {
 | 
				
			||||||
 | 
					      "Content-Type": "application/json",
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    body: JSON.stringify(audioQuery),
 | 
				
			||||||
 | 
					    signal: AbortSignal.timeout(600000), // 10分のタイムアウト
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!audioResponse.ok) {
 | 
				
			||||||
 | 
					    const errorText = await audioResponse.text();
 | 
				
			||||||
 | 
					    console.error(`音声合成失敗: ${itemId}`);
 | 
				
			||||||
 | 
					    throw new Error(
 | 
				
			||||||
 | 
					      `VOICEVOX synthesis failed (${audioResponse.status}): ${errorText}`,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const audioArrayBuffer = await audioResponse.arrayBuffer();
 | 
				
			||||||
 | 
					  const audioBuffer = Buffer.from(audioArrayBuffer);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 出力ディレクトリの準備
 | 
				
			||||||
 | 
					  const outputDir = config.paths.podcastAudioDir;
 | 
				
			||||||
 | 
					  if (!fs.existsSync(outputDir)) {
 | 
				
			||||||
 | 
					    fs.mkdirSync(outputDir, { recursive: true });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const wavFilePath = path.resolve(outputDir, `${itemId}.wav`);
 | 
				
			||||||
 | 
					  const mp3FilePath = path.resolve(outputDir, `${itemId}.mp3`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  console.log(`WAVファイル保存開始: ${wavFilePath}`);
 | 
				
			||||||
 | 
					  fs.writeFileSync(wavFilePath, audioBuffer);
 | 
				
			||||||
 | 
					  console.log(`WAVファイル保存完了: ${wavFilePath}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  console.log(`MP3変換開始: ${wavFilePath} -> ${mp3FilePath}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const ffmpegCmd = ffmpegPath || "ffmpeg";
 | 
				
			||||||
 | 
					  const result = Bun.spawnSync({
 | 
				
			||||||
 | 
					    cmd: [
 | 
				
			||||||
 | 
					      ffmpegCmd,
 | 
				
			||||||
 | 
					      "-i",
 | 
				
			||||||
 | 
					      wavFilePath,
 | 
				
			||||||
 | 
					      "-codec:a",
 | 
				
			||||||
 | 
					      "libmp3lame",
 | 
				
			||||||
 | 
					      "-qscale:a",
 | 
				
			||||||
 | 
					      "2",
 | 
				
			||||||
 | 
					      "-y", // Overwrite output file
 | 
				
			||||||
 | 
					      mp3FilePath,
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (result.exitCode !== 0) {
 | 
				
			||||||
 | 
					    const stderr = result.stderr
 | 
				
			||||||
 | 
					      ? new TextDecoder().decode(result.stderr)
 | 
				
			||||||
 | 
					      : "Unknown error";
 | 
				
			||||||
 | 
					    throw new Error(`FFmpeg conversion failed: ${stderr}`);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Wavファイルを削除
 | 
				
			||||||
 | 
					  if (fs.existsSync(wavFilePath)) {
 | 
				
			||||||
 | 
					    fs.unlinkSync(wavFilePath);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  console.log(`TTS生成完了: ${itemId}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return path.basename(mp3FilePath);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function generateTTS(
 | 
				
			||||||
 | 
					  itemId: string,
 | 
				
			||||||
 | 
					  scriptText: string,
 | 
				
			||||||
 | 
					  retryCount: number = 0,
 | 
				
			||||||
 | 
					): Promise<string> {
 | 
				
			||||||
  const maxRetries = 2;
 | 
					  const maxRetries = 2;
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    console.log(`TTS生成開始: ${itemId} (試行回数: ${retryCount + 1}/${maxRetries + 1})`);
 | 
					    return await generateTTSWithoutQueue(itemId, scriptText, retryCount);
 | 
				
			||||||
    const encodedText = encodeURIComponent(scriptText);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
 | 
					 | 
				
			||||||
    const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const queryResponse = await fetch(queryUrl, {
 | 
					 | 
				
			||||||
      method: "POST",
 | 
					 | 
				
			||||||
      headers: {
 | 
					 | 
				
			||||||
        "Content-Type": "application/json",
 | 
					 | 
				
			||||||
        Accept: "application/json",
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!queryResponse.ok) {
 | 
					 | 
				
			||||||
      const errorText = await queryResponse.text();
 | 
					 | 
				
			||||||
      throw new Error(
 | 
					 | 
				
			||||||
        `VOICEVOX audio query failed (${queryResponse.status}): ${errorText}`,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const audioQuery = await queryResponse.json();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    console.log(`音声合成開始: ${itemId}`);
 | 
					 | 
				
			||||||
    const audioResponse = await fetch(synthesisUrl, {
 | 
					 | 
				
			||||||
      method: "POST",
 | 
					 | 
				
			||||||
      headers: {
 | 
					 | 
				
			||||||
        "Content-Type": "application/json",
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      body: JSON.stringify(audioQuery),
 | 
					 | 
				
			||||||
      signal: AbortSignal.timeout(600000), // 10分のタイムアウト
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!audioResponse.ok) {
 | 
					 | 
				
			||||||
      const errorText = await audioResponse.text();
 | 
					 | 
				
			||||||
      console.error(`音声合成失敗: ${itemId}`);
 | 
					 | 
				
			||||||
      throw new Error(
 | 
					 | 
				
			||||||
        `VOICEVOX synthesis failed (${audioResponse.status}): ${errorText}`,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const audioArrayBuffer = await audioResponse.arrayBuffer();
 | 
					 | 
				
			||||||
    const audioBuffer = Buffer.from(audioArrayBuffer);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // 出力ディレクトリの準備
 | 
					 | 
				
			||||||
    const outputDir = config.paths.podcastAudioDir;
 | 
					 | 
				
			||||||
    if (!fs.existsSync(outputDir)) {
 | 
					 | 
				
			||||||
      fs.mkdirSync(outputDir, { recursive: true });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const wavFilePath = path.resolve(outputDir, `${itemId}.wav`);
 | 
					 | 
				
			||||||
    const mp3FilePath = path.resolve(outputDir, `${itemId}.mp3`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    console.log(`WAVファイル保存開始: ${wavFilePath}`);
 | 
					 | 
				
			||||||
    fs.writeFileSync(wavFilePath, audioBuffer);
 | 
					 | 
				
			||||||
    console.log(`WAVファイル保存完了: ${wavFilePath}`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    console.log(`MP3変換開始: ${wavFilePath} -> ${mp3FilePath}`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const ffmpegCmd = ffmpegPath || "ffmpeg";
 | 
					 | 
				
			||||||
    const result = Bun.spawnSync({
 | 
					 | 
				
			||||||
      cmd: [
 | 
					 | 
				
			||||||
        ffmpegCmd,
 | 
					 | 
				
			||||||
        "-i",
 | 
					 | 
				
			||||||
        wavFilePath,
 | 
					 | 
				
			||||||
        "-codec:a",
 | 
					 | 
				
			||||||
        "libmp3lame",
 | 
					 | 
				
			||||||
        "-qscale:a",
 | 
					 | 
				
			||||||
        "2",
 | 
					 | 
				
			||||||
        "-y", // Overwrite output file
 | 
					 | 
				
			||||||
        mp3FilePath,
 | 
					 | 
				
			||||||
      ],
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (result.exitCode !== 0) {
 | 
					 | 
				
			||||||
      const stderr = result.stderr
 | 
					 | 
				
			||||||
        ? new TextDecoder().decode(result.stderr)
 | 
					 | 
				
			||||||
        : "Unknown error";
 | 
					 | 
				
			||||||
      throw new Error(`FFmpeg conversion failed: ${stderr}`);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Wavファイルを削除
 | 
					 | 
				
			||||||
    if (fs.existsSync(wavFilePath)) {
 | 
					 | 
				
			||||||
      fs.unlinkSync(wavFilePath);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    console.log(`TTS生成完了: ${itemId}`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return path.basename(mp3FilePath);
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    console.error(`TTS生成エラー: ${itemId} (試行回数: ${retryCount + 1})`, error);
 | 
					    console.error(`TTS生成エラー: ${itemId} (試行回数: ${retryCount + 1})`, error);
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    if (retryCount < maxRetries) {
 | 
					    if (retryCount < maxRetries) {
 | 
				
			||||||
 | 
					      // Add to queue for retry only on initial failure
 | 
				
			||||||
      const { addToQueue } = await import("../services/database.js");
 | 
					      const { addToQueue } = await import("../services/database.js");
 | 
				
			||||||
      await addToQueue(itemId, scriptText, retryCount + 1);
 | 
					      await addToQueue(itemId, scriptText, retryCount);
 | 
				
			||||||
      throw new Error(`TTS generation failed, added to retry queue: ${error}`);
 | 
					      throw new Error(`TTS generation failed, added to retry queue: ${error}`);
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      throw new Error(`TTS generation failed after ${maxRetries + 1} attempts: ${error}`);
 | 
					      throw new Error(`TTS generation failed after ${maxRetries + 1} attempts: ${error}`);
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user