podcast.service.ts 11 KB

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