podcast.service.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. import { Injectable, Logger, NotFoundException } from '@nestjs/common';
  2. import { InjectRepository } from '@nestjs/typeorm';
  3. import { Repository } from 'typeorm';
  4. import { PodcastEpisode, PodcastStatus } from './entities/podcast-episode.entity';
  5. import { ConfigService } from '@nestjs/config';
  6. // import { EdgeTTS } from 'node-edge-tts'; // Deprecated due to 403 errors
  7. import * as fs from 'fs-extra';
  8. import * as path from 'path';
  9. import { v4 as uuidv4 } from 'uuid';
  10. import { KnowledgeGroup } from '../knowledge-group/knowledge-group.entity';
  11. import { ChatService } from '../chat/chat.service';
  12. @Injectable()
  13. export class PodcastService {
  14. private readonly logger = new Logger(PodcastService.name);
  15. private readonly outputDir: string;
  16. private readonly pythonPath = 'python'; // Or from config
  17. private readonly scriptPath = path.join(process.cwd(), 'text_to_speech.py');
  18. constructor(
  19. @InjectRepository(PodcastEpisode)
  20. private podcastRepository: Repository<PodcastEpisode>,
  21. @InjectRepository(KnowledgeGroup)
  22. private groupRepository: Repository<KnowledgeGroup>,
  23. private configService: ConfigService,
  24. private chatService: ChatService, // Reusing ChatService to generate script
  25. ) {
  26. // this.tts = new EdgeTTS();
  27. this.outputDir = path.join(process.cwd(), 'uploads', 'podcasts');
  28. fs.ensureDirSync(this.outputDir);
  29. }
  30. async create(userId: string, createDto: any): Promise<PodcastEpisode> {
  31. this.logger.log(`Creating podcast with DTO: ${JSON.stringify(createDto)}`);
  32. if (!userId) {
  33. throw new Error('User ID is required to create a podcast');
  34. }
  35. const episode = this.podcastRepository.create({
  36. ...createDto,
  37. briefing: createDto.content || createDto.briefing, // Map content to briefing if needed
  38. user: { id: userId },
  39. group: createDto.groupId ? { id: createDto.groupId } : undefined,
  40. status: PodcastStatus.PENDING,
  41. }) as unknown as PodcastEpisode; // Restore cast to fix TS inference issue
  42. const saved = await this.podcastRepository.save(episode);
  43. // Start background processing
  44. this.processPodcast(saved.id, userId, createDto);
  45. return saved;
  46. }
  47. async findAll(userId: string, groupId?: string): Promise<PodcastEpisode[]> {
  48. const query = this.podcastRepository.createQueryBuilder('podcast')
  49. .where('podcast.userId = :userId', { userId })
  50. .orderBy('podcast.createdAt', 'DESC');
  51. if (groupId) {
  52. query.andWhere('podcast.groupId = :groupId', { groupId });
  53. }
  54. return query.getMany();
  55. }
  56. async findOne(userId: string, id: string): Promise<PodcastEpisode> {
  57. const episode = await this.podcastRepository.findOne({ where: { id, userId } });
  58. if (!episode) throw new NotFoundException(`Podcast ${id} not found`);
  59. return episode;
  60. }
  61. async delete(userId: string, id: string): Promise<void> {
  62. const episode = await this.findOne(userId, id);
  63. // Delete audio file if exists
  64. if (episode.audioUrl) {
  65. const filename = path.basename(episode.audioUrl);
  66. const filePath = path.join(this.outputDir, filename);
  67. if (fs.existsSync(filePath)) {
  68. fs.unlinkSync(filePath);
  69. }
  70. }
  71. await this.podcastRepository.remove(episode);
  72. }
  73. async processPodcast(episodeId: string, userId: string, dto: any) {
  74. try {
  75. this.logger.log(`Starting processing for podcast ${episodeId}`);
  76. await this.updateStatus(episodeId, PodcastStatus.PROCESSING);
  77. // 1. Gather Context
  78. let context = dto.content || '';
  79. if (dto.groupId) {
  80. // TODO: Fetch context from group (files) if content is empty
  81. // For now assuming content is passed or we just use what we have
  82. }
  83. // 2. Generate Script using ChatService (LLM)
  84. const fileIds = dto.fileId ? [dto.fileId] : undefined;
  85. const language = dto.language || 'zh';
  86. this.logger.log(`Generating script for language: ${language}`);
  87. const script = await this.generateScript(context, dto.topic || 'General Discussion', userId, language, dto.groupId, fileIds);
  88. await this.podcastRepository.update(episodeId, { transcript: script });
  89. // 3. Generate Audio using Edge TTS
  90. const audioFileName = `${episodeId}.mp3`;
  91. const audioFilePath = path.join(this.outputDir, audioFileName);
  92. await this.generateAudioInternal(script, audioFilePath, language);
  93. // 4. Update Episode
  94. await this.podcastRepository.update(episodeId, {
  95. status: PodcastStatus.COMPLETED,
  96. audioUrl: `/api/podcasts/audio/${audioFileName}`,
  97. });
  98. this.logger.log(`Podcast ${episodeId} completed (Language: ${language})`);
  99. } catch (error) {
  100. this.logger.error(`Failed to process podcast ${episodeId}`, error);
  101. await this.updateStatus(episodeId, PodcastStatus.FAILED);
  102. }
  103. }
  104. private async updateStatus(id: string, status: PodcastStatus) {
  105. await this.podcastRepository.update(id, { status });
  106. }
  107. async generateScript(context: string, topic: string, userId: string, language: string = 'zh', groupId?: string, fileIds?: string[]): Promise<any[]> {
  108. // ... (RAG context logic omitted for brevity, logic remains same)
  109. // If groupId or fileIds are provided, try to enhance context with RAG
  110. if ((groupId || (fileIds && fileIds.length > 0)) && (!context || context.length < 100)) {
  111. try {
  112. // tenantId is optional, we pass undefined here, groupId is string, fileIds is string[]
  113. const ragContext = await this.chatService.getContextForTopic(topic, userId, undefined, groupId, fileIds);
  114. if (ragContext) {
  115. context = `Manual Context: ${context}\n\nSearch Results:\n${ragContext}`;
  116. }
  117. } catch (err) {
  118. this.logger.warn(`Failed to fetch RAG context for podcast: ${err.message}`);
  119. }
  120. }
  121. let targetLang = 'Chinese (Simplified)';
  122. if (language === 'en') targetLang = 'English';
  123. if (language === 'ja') targetLang = 'Japanese';
  124. const prompt = `
  125. You are an expert podcast producer. Create a podcast script about the following topic: "${topic}".
  126. Context information (use this to inform the discussion):
  127. ${context ? context.substring(0, 5000) : 'No specific context provided, use general knowledge.'}
  128. The podcast should be a dialogue between a Host and a Guest.
  129. - Host: Ask insightful questions and guide the conversation.
  130. - Guest: Provide expert answers and insights based on the context.
  131. - Tone: Professional yet conversational.
  132. - Length: Approximately 8-12 exchanges.
  133. - Language: ${targetLang}.
  134. IMPORTANT: The dialogue MUST be spoken in separate ${targetLang} sentences. Even if the context is valid in another language, translate the concepts and discuss them in ${targetLang}.
  135. Output the script as a valid JSON array of objects, where each object has "speaker" (Host/Guest) and "text" (the spoken content).
  136. Example:
  137. [
  138. // Example structure, ensure actual content is in ${targetLang}
  139. {"speaker": "Host", "text": "..."}
  140. ]
  141. Do not include markdown formatting like \`\`\`json. Just the raw JSON.
  142. `;
  143. try {
  144. const rawContent = await this.chatService.generateSimpleChat(
  145. [{ role: 'user', content: prompt }],
  146. userId
  147. );
  148. // Clean up code blocks if present
  149. const jsonString = rawContent.replace(/```json/g, '').replace(/```/g, '').trim();
  150. try {
  151. return JSON.parse(jsonString);
  152. } catch (e) {
  153. this.logger.error('Failed to parse podcast script JSON:', rawContent);
  154. // Fallback parsing if JSON fails? Or just throw.
  155. // Simple fallback: Split by newlines and try to guess speaker?
  156. // For now, throw to see errors.
  157. throw new Error('Script generation failed to produce valid JSON');
  158. }
  159. } catch (error) {
  160. this.logger.error('Failed to generate script:', error);
  161. throw error;
  162. }
  163. }
  164. private async generateAudioInternal(script: any[], outputPath: string, language: string = 'zh') {
  165. const { spawn } = await import('child_process');
  166. const writeStream = fs.createWriteStream(outputPath);
  167. // Voice map
  168. const voices = {
  169. zh: { host: 'zh-CN-YunxiNeural', guest: 'zh-CN-XiaoxiaoNeural' },
  170. en: { host: 'en-US-AndrewNeural', guest: 'en-US-AvaNeural' },
  171. ja: { host: 'ja-JP-KeitaNeural', guest: 'ja-JP-NanamiNeural' }
  172. };
  173. const voiceConfig = voices[language] || voices['zh'];
  174. for (const line of script) {
  175. if (!line.text) continue;
  176. // Voice selection
  177. const voice = line.speaker === 'Host' ? voiceConfig.host : voiceConfig.guest;
  178. const tempPath = path.join(this.outputDir, `temp_${uuidv4()}.mp3`);
  179. try {
  180. await new Promise<void>((resolve, reject) => {
  181. const process = spawn(this.pythonPath, [
  182. this.scriptPath,
  183. '--text', line.text,
  184. '--voice', voice,
  185. '--output', tempPath
  186. ]);
  187. let errorOutput = '';
  188. process.stderr.on('data', (data) => {
  189. errorOutput += data.toString();
  190. });
  191. process.on('close', (code) => {
  192. if (code !== 0) {
  193. reject(new Error(`Python script exited with code ${code}: ${errorOutput}`));
  194. } else {
  195. resolve();
  196. }
  197. });
  198. });
  199. if (fs.existsSync(tempPath)) {
  200. const buffer = fs.readFileSync(tempPath);
  201. writeStream.write(buffer);
  202. fs.unlinkSync(tempPath);
  203. }
  204. } catch (e) {
  205. this.logger.error(`TTS Error for line: ${line.text}`, e);
  206. }
  207. }
  208. writeStream.end();
  209. await new Promise((resolve, reject) => {
  210. writeStream.on('finish', resolve);
  211. writeStream.on('error', reject);
  212. });
  213. }
  214. }