Update
This commit is contained in:
@ -60,11 +60,23 @@ function initializeDatabase(): Database {
|
||||
PRIMARY KEY(feed_url, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tts_queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_id TEXT NOT NULL,
|
||||
script_text TEXT NOT NULL,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
last_attempted_at TEXT,
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'failed'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_processed ON articles(processed);
|
||||
CREATE INDEX IF NOT EXISTS idx_episodes_article_id ON episodes(article_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_feeds_active ON feeds(active);`);
|
||||
CREATE INDEX IF NOT EXISTS idx_feeds_active ON feeds(active);
|
||||
CREATE INDEX IF NOT EXISTS idx_tts_queue_status ON tts_queue(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tts_queue_created_at ON tts_queue(created_at);`);
|
||||
|
||||
return db;
|
||||
}
|
||||
@ -506,6 +518,89 @@ export async function fetchEpisodesWithArticles(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
// TTS Queue management functions
|
||||
export interface TTSQueueItem {
|
||||
id: string;
|
||||
itemId: string;
|
||||
scriptText: string;
|
||||
retryCount: number;
|
||||
createdAt: string;
|
||||
lastAttemptedAt?: string;
|
||||
status: 'pending' | 'processing' | 'failed';
|
||||
}
|
||||
|
||||
export async function addToQueue(
|
||||
itemId: string,
|
||||
scriptText: string,
|
||||
retryCount: number = 0,
|
||||
): Promise<string> {
|
||||
const id = crypto.randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const stmt = db.prepare(
|
||||
"INSERT INTO tts_queue (id, item_id, script_text, retry_count, created_at, status) VALUES (?, ?, ?, ?, ?, 'pending')",
|
||||
);
|
||||
stmt.run(id, itemId, scriptText, retryCount, createdAt);
|
||||
console.log(`TTS queue に追加: ${itemId} (試行回数: ${retryCount})`);
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error("Error adding to TTS queue:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getQueueItems(limit: number = 10): Promise<TTSQueueItem[]> {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM tts_queue
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?
|
||||
`);
|
||||
const rows = stmt.all(limit) as any[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
itemId: row.item_id,
|
||||
scriptText: row.script_text,
|
||||
retryCount: row.retry_count,
|
||||
createdAt: row.created_at,
|
||||
lastAttemptedAt: row.last_attempted_at,
|
||||
status: row.status,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error getting queue items:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateQueueItemStatus(
|
||||
queueId: string,
|
||||
status: 'pending' | 'processing' | 'failed',
|
||||
lastAttemptedAt?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const stmt = db.prepare(
|
||||
"UPDATE tts_queue SET status = ?, last_attempted_at = ? WHERE id = ?",
|
||||
);
|
||||
stmt.run(status, lastAttemptedAt || new Date().toISOString(), queueId);
|
||||
} catch (error) {
|
||||
console.error("Error updating queue item status:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeFromQueue(queueId: string): Promise<void> {
|
||||
try {
|
||||
const stmt = db.prepare("DELETE FROM tts_queue WHERE id = ?");
|
||||
stmt.run(queueId);
|
||||
} catch (error) {
|
||||
console.error("Error removing from queue:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
db.close();
|
||||
}
|
||||
|
@ -45,9 +45,22 @@ function createItemXml(episode: Episode): string {
|
||||
export async function updatePodcastRSS(): Promise<void> {
|
||||
try {
|
||||
const episodes: Episode[] = await fetchAllEpisodes();
|
||||
const lastBuildDate = new Date().toUTCString();
|
||||
|
||||
// Filter episodes to only include those with valid audio files
|
||||
const validEpisodes = episodes.filter(episode => {
|
||||
try {
|
||||
const audioPath = path.join(config.paths.podcastAudioDir, episode.audioPath);
|
||||
return fsSync.existsSync(audioPath);
|
||||
} catch (error) {
|
||||
console.warn(`Audio file not found for episode: ${episode.title}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const itemsXml = episodes.map(createItemXml).join("\n");
|
||||
console.log(`Found ${episodes.length} episodes, ${validEpisodes.length} with valid audio files`);
|
||||
|
||||
const lastBuildDate = new Date().toUTCString();
|
||||
const itemsXml = validEpisodes.map(createItemXml).join("\n");
|
||||
const outputPath = path.join(config.paths.publicDir, "podcast.xml");
|
||||
|
||||
// Create RSS XML content
|
||||
@ -69,7 +82,7 @@ export async function updatePodcastRSS(): Promise<void> {
|
||||
await fs.mkdir(dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, rssXml);
|
||||
|
||||
console.log(`RSS feed updated with ${episodes.length} episodes`);
|
||||
console.log(`RSS feed updated with ${validEpisodes.length} episodes (audio files verified)`);
|
||||
} catch (error) {
|
||||
console.error("Error updating podcast RSS:", error);
|
||||
throw error;
|
||||
|
185
services/tts.ts
185
services/tts.ts
@ -15,6 +15,7 @@ const defaultVoiceStyle: VoiceStyle = {
|
||||
export async function generateTTS(
|
||||
itemId: string,
|
||||
scriptText: string,
|
||||
retryCount: number = 0,
|
||||
): Promise<string> {
|
||||
if (!itemId || itemId.trim() === "") {
|
||||
throw new Error("Item ID is required for TTS generation");
|
||||
@ -24,93 +25,107 @@ export async function generateTTS(
|
||||
throw new Error("Script text is required for TTS generation");
|
||||
}
|
||||
|
||||
console.log(`TTS生成開始: ${itemId}`);
|
||||
const encodedText = encodeURIComponent(scriptText);
|
||||
const maxRetries = 2;
|
||||
|
||||
try {
|
||||
console.log(`TTS生成開始: ${itemId} (試行回数: ${retryCount + 1}/${maxRetries + 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 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",
|
||||
},
|
||||
});
|
||||
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}`,
|
||||
);
|
||||
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) {
|
||||
console.error(`TTS生成エラー: ${itemId} (試行回数: ${retryCount + 1})`, error);
|
||||
|
||||
if (retryCount < maxRetries) {
|
||||
const { addToQueue } = await import("../services/database.js");
|
||||
await addToQueue(itemId, scriptText, retryCount + 1);
|
||||
throw new Error(`TTS generation failed, added to retry queue: ${error}`);
|
||||
} else {
|
||||
throw new Error(`TTS generation failed after ${maxRetries + 1} attempts: ${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),
|
||||
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);
|
||||
}
|
||||
|
Reference in New Issue
Block a user