feat: 記事ごとのポッドキャスト生成と新規記事検出システム、モダンUIの実装
- 新規記事検出システム: 記事の重複チェックと新規記事のみ処理 - 記事単位ポッドキャスト: フィード統合から記事個別エピソードに変更 - 6時間間隔バッチ処理: 自動定期実行スケジュールの改善 - 完全UIリニューアル: ダッシュボード・フィード管理・エピソード管理の3画面構成 - アクセシビリティ強化: ARIA属性、キーボードナビ、高コントラスト対応 - データベース刷新: feeds/articles/episodes階層構造への移行 - 中央集権設定管理: services/config.ts による設定統一 - エラーハンドリング改善: 全モジュールでの堅牢なエラー処理 - TypeScript型安全性向上: null安全性とインターフェース改善 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
159
services/tts.ts
159
services/tts.ts
@ -1,10 +1,7 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import ffmpegPath from "ffmpeg-static";
|
||||
|
||||
// VOICEVOX APIの設定
|
||||
const VOICEVOX_HOST = import.meta.env["VOICEVOX_HOST"];
|
||||
const VOICEVOX_STYLE_ID = parseInt(import.meta.env["VOICEVOX_STYLE_ID"] ?? "0");
|
||||
import { config } from "./config.js";
|
||||
|
||||
interface VoiceStyle {
|
||||
styleId: number;
|
||||
@ -12,80 +9,106 @@ interface VoiceStyle {
|
||||
|
||||
// 環境変数からデフォルトの声設定を取得
|
||||
const defaultVoiceStyle: VoiceStyle = {
|
||||
styleId: VOICEVOX_STYLE_ID,
|
||||
styleId: config.voicevox.styleId,
|
||||
};
|
||||
|
||||
export async function generateTTS(
|
||||
itemId: string,
|
||||
scriptText: string,
|
||||
): Promise<string> {
|
||||
if (!itemId || itemId.trim() === "") {
|
||||
throw new Error("Item ID is required for TTS generation");
|
||||
}
|
||||
|
||||
if (!scriptText || scriptText.trim() === "") {
|
||||
throw new Error("Script text is required for TTS generation");
|
||||
}
|
||||
|
||||
console.log(`TTS生成開始: ${itemId}`);
|
||||
const encodedText = encodeURIComponent(scriptText);
|
||||
|
||||
const queryUrl = `${VOICEVOX_HOST}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
|
||||
const synthesisUrl = `${VOICEVOX_HOST}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
|
||||
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",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const queryResponse = await fetch(queryUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!queryResponse.ok) {
|
||||
throw new Error("VOICEVOX 音声合成クエリ生成に失敗しました");
|
||||
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),
|
||||
});
|
||||
|
||||
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) {
|
||||
console.error("Error generating TTS:", error);
|
||||
throw new Error(`Failed to generate TTS: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
const audioQuery = await queryResponse.json();
|
||||
|
||||
console.log(`音声合成開始: ${itemId}`);
|
||||
const audioResponse = await fetch(synthesisUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(audioQuery),
|
||||
});
|
||||
|
||||
if (!audioResponse.ok) {
|
||||
console.error(`音声合成失敗: ${itemId}`);
|
||||
throw new Error("VOICEVOX 音声合成に失敗しました");
|
||||
}
|
||||
|
||||
const audioArrayBuffer = await audioResponse.arrayBuffer();
|
||||
const audioBuffer = Buffer.from(audioArrayBuffer);
|
||||
|
||||
// 出力ディレクトリの準備
|
||||
const outputDir = path.join(__dirname, "../public/podcast_audio");
|
||||
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}`);
|
||||
Bun.spawnSync({
|
||||
cmd: [
|
||||
ffmpegPath || "ffmpeg",
|
||||
"-i",
|
||||
wavFilePath,
|
||||
"-codec:a",
|
||||
"libmp3lame",
|
||||
"-qscale:a",
|
||||
"2",
|
||||
mp3FilePath,
|
||||
],
|
||||
});
|
||||
|
||||
// Wavファイルを削除
|
||||
fs.unlinkSync(wavFilePath);
|
||||
console.log(`TTS生成完了: ${itemId}`);
|
||||
|
||||
return path.basename(mp3FilePath);
|
||||
}
|
||||
|
Reference in New Issue
Block a user