| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260 |
- import { Injectable, Logger, NotFoundException } from '@nestjs/common';
- import { InjectRepository } from '@nestjs/typeorm';
- import { Repository } from 'typeorm';
- import { PodcastEpisode, PodcastStatus } from './entities/podcast-episode.entity';
- import { ConfigService } from '@nestjs/config';
- // import { EdgeTTS } from 'node-edge-tts'; // Deprecated due to 403 errors
- import * as fs from 'fs-extra';
- import * as path from 'path';
- import { v4 as uuidv4 } from 'uuid';
- import { KnowledgeGroup } from '../knowledge-group/knowledge-group.entity';
- import { ChatService } from '../chat/chat.service';
- @Injectable()
- export class PodcastService {
- private readonly logger = new Logger(PodcastService.name);
- private readonly outputDir: string;
- private readonly pythonPath = 'python'; // Or from config
- private readonly scriptPath = path.join(process.cwd(), 'text_to_speech.py');
- constructor(
- @InjectRepository(PodcastEpisode)
- private podcastRepository: Repository<PodcastEpisode>,
- @InjectRepository(KnowledgeGroup)
- private groupRepository: Repository<KnowledgeGroup>,
- private configService: ConfigService,
- private chatService: ChatService, // Reusing ChatService to generate script
- ) {
- // this.tts = new EdgeTTS();
- this.outputDir = path.join(process.cwd(), 'uploads', 'podcasts');
- fs.ensureDirSync(this.outputDir);
- }
- async create(userId: string, createDto: any): Promise<PodcastEpisode> {
- this.logger.log(`Creating podcast with DTO: ${JSON.stringify(createDto)}`);
- if (!userId) {
- throw new Error('User ID is required to create a podcast');
- }
- const episode = this.podcastRepository.create({
- ...createDto,
- briefing: createDto.content || createDto.briefing, // Map content to briefing if needed
- user: { id: userId },
- group: createDto.groupId ? { id: createDto.groupId } : undefined,
- status: PodcastStatus.PENDING,
- }) as unknown as PodcastEpisode; // Restore cast to fix TS inference issue
- const saved = await this.podcastRepository.save(episode);
- // Start background processing
- this.processPodcast(saved.id, userId, createDto);
- return saved;
- }
- async findAll(userId: string, groupId?: string): Promise<PodcastEpisode[]> {
- const query = this.podcastRepository.createQueryBuilder('podcast')
- .where('podcast.userId = :userId', { userId })
- .orderBy('podcast.createdAt', 'DESC');
- if (groupId) {
- query.andWhere('podcast.groupId = :groupId', { groupId });
- }
- return query.getMany();
- }
- async findOne(userId: string, id: string): Promise<PodcastEpisode> {
- const episode = await this.podcastRepository.findOne({ where: { id, userId } });
- if (!episode) throw new NotFoundException(`Podcast ${id} not found`);
- return episode;
- }
- async delete(userId: string, id: string): Promise<void> {
- const episode = await this.findOne(userId, id);
- // Delete audio file if exists
- if (episode.audioUrl) {
- const filename = path.basename(episode.audioUrl);
- const filePath = path.join(this.outputDir, filename);
- if (fs.existsSync(filePath)) {
- fs.unlinkSync(filePath);
- }
- }
- await this.podcastRepository.remove(episode);
- }
- async processPodcast(episodeId: string, userId: string, dto: any) {
- try {
- this.logger.log(`Starting processing for podcast ${episodeId}`);
- await this.updateStatus(episodeId, PodcastStatus.PROCESSING);
- // 1. Gather Context
- let context = dto.content || '';
- if (dto.groupId) {
- // TODO: Fetch context from group (files) if content is empty
- // For now assuming content is passed or we just use what we have
- }
- // 2. Generate Script using ChatService (LLM)
- const fileIds = dto.fileId ? [dto.fileId] : undefined;
- const language = dto.language || 'zh';
- this.logger.log(`Generating script for language: ${language}`);
- const script = await this.generateScript(context, dto.topic || 'General Discussion', userId, language, dto.groupId, fileIds);
- await this.podcastRepository.update(episodeId, { transcript: script });
- // 3. Generate Audio using Edge TTS
- const audioFileName = `${episodeId}.mp3`;
- const audioFilePath = path.join(this.outputDir, audioFileName);
- await this.generateAudioInternal(script, audioFilePath, language);
- // 4. Update Episode
- await this.podcastRepository.update(episodeId, {
- status: PodcastStatus.COMPLETED,
- audioUrl: `/api/podcasts/audio/${audioFileName}`,
- });
- this.logger.log(`Podcast ${episodeId} completed (Language: ${language})`);
- } catch (error) {
- this.logger.error(`Failed to process podcast ${episodeId}`, error);
- await this.updateStatus(episodeId, PodcastStatus.FAILED);
- }
- }
- private async updateStatus(id: string, status: PodcastStatus) {
- await this.podcastRepository.update(id, { status });
- }
- async generateScript(context: string, topic: string, userId: string, language: string = 'zh', groupId?: string, fileIds?: string[]): Promise<any[]> {
- // ... (RAG context logic omitted for brevity, logic remains same)
- // If groupId or fileIds are provided, try to enhance context with RAG
- if ((groupId || (fileIds && fileIds.length > 0)) && (!context || context.length < 100)) {
- try {
- // tenantId is optional, we pass undefined here, groupId is string, fileIds is string[]
- const ragContext = await this.chatService.getContextForTopic(topic, userId, undefined, groupId, fileIds);
- if (ragContext) {
- context = `Manual Context: ${context}\n\nSearch Results:\n${ragContext}`;
- }
- } catch (err) {
- this.logger.warn(`Failed to fetch RAG context for podcast: ${err.message}`);
- }
- }
- let targetLang = 'Chinese (Simplified)';
- if (language === 'en') targetLang = 'English';
- if (language === 'ja') targetLang = 'Japanese';
- const prompt = `
- You are an expert podcast producer. Create a podcast script about the following topic: "${topic}".
-
- Context information (use this to inform the discussion):
- ${context ? context.substring(0, 5000) : 'No specific context provided, use general knowledge.'}
- The podcast should be a dialogue between a Host and a Guest.
- - Host: Ask insightful questions and guide the conversation.
- - Guest: Provide expert answers and insights based on the context.
- - Tone: Professional yet conversational.
- - Length: Approximately 8-12 exchanges.
- - Language: ${targetLang}.
-
- 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}.
- Output the script as a valid JSON array of objects, where each object has "speaker" (Host/Guest) and "text" (the spoken content).
- Example:
- [
- // Example structure, ensure actual content is in ${targetLang}
- {"speaker": "Host", "text": "..."}
- ]
-
- Do not include markdown formatting like \`\`\`json. Just the raw JSON.
- `;
- try {
- const rawContent = await this.chatService.generateSimpleChat(
- [{ role: 'user', content: prompt }],
- userId
- );
- // Clean up code blocks if present
- const jsonString = rawContent.replace(/```json/g, '').replace(/```/g, '').trim();
- try {
- return JSON.parse(jsonString);
- } catch (e) {
- this.logger.error('Failed to parse podcast script JSON:', rawContent);
- // Fallback parsing if JSON fails? Or just throw.
- // Simple fallback: Split by newlines and try to guess speaker?
- // For now, throw to see errors.
- throw new Error('Script generation failed to produce valid JSON');
- }
- } catch (error) {
- this.logger.error('Failed to generate script:', error);
- throw error;
- }
- }
- private async generateAudioInternal(script: any[], outputPath: string, language: string = 'zh') {
- const { spawn } = await import('child_process');
- const writeStream = fs.createWriteStream(outputPath);
- // Voice map
- const voices = {
- zh: { host: 'zh-CN-YunxiNeural', guest: 'zh-CN-XiaoxiaoNeural' },
- en: { host: 'en-US-AndrewNeural', guest: 'en-US-AvaNeural' },
- ja: { host: 'ja-JP-KeitaNeural', guest: 'ja-JP-NanamiNeural' }
- };
- const voiceConfig = voices[language] || voices['zh'];
- for (const line of script) {
- if (!line.text) continue;
- // Voice selection
- const voice = line.speaker === 'Host' ? voiceConfig.host : voiceConfig.guest;
- const tempPath = path.join(this.outputDir, `temp_${uuidv4()}.mp3`);
- try {
- await new Promise<void>((resolve, reject) => {
- const process = spawn(this.pythonPath, [
- this.scriptPath,
- '--text', line.text,
- '--voice', voice,
- '--output', tempPath
- ]);
- let errorOutput = '';
- process.stderr.on('data', (data) => {
- errorOutput += data.toString();
- });
- process.on('close', (code) => {
- if (code !== 0) {
- reject(new Error(`Python script exited with code ${code}: ${errorOutput}`));
- } else {
- resolve();
- }
- });
- });
- if (fs.existsSync(tempPath)) {
- const buffer = fs.readFileSync(tempPath);
- writeStream.write(buffer);
- fs.unlinkSync(tempPath);
- }
- } catch (e) {
- this.logger.error(`TTS Error for line: ${line.text}`, e);
- }
- }
- writeStream.end();
- await new Promise((resolve, reject) => {
- writeStream.on('finish', resolve);
- writeStream.on('error', reject);
- });
- }
- }
|