feat: add tts service

This commit is contained in:
mahyargdz
2025-08-16 16:39:24 +03:30
parent 7e65f049fc
commit 687ee80842
14 changed files with 328 additions and 20 deletions
+3
View File
@@ -134,6 +134,9 @@ export const enum CommonMessage {
export const enum UploaderMessage {
UPLOAD_FILE_INVALID = "فایل معتبر نیست",
UPLOAD_FILE_TOO_LARGE = "فایل بزرگتر از حد مجاز است . حداکثر حجم فایل [MAX_FILE_SIZE] مگابایت است",
UPLOAD_FILE_TYPE_NOT_SUPPORTED = "نوع فایل معتبر نیست . مجاز است : [MIME_TYPES]",
UPLOAD_FILE_SUCCESS = "فایل با موفقیت آپلود شد",
}
export const enum EmailMessage {
+1
View File
@@ -239,6 +239,7 @@ export const aiConfigProvider: FactoryProvider<AIConfig> = {
baseUrl: configService.getOrThrow<string>("AI_BASE_URL"),
apiKey: configService.getOrThrow<string>("AI_API_KEY"),
defaultModel: configService.getOrThrow<string>("AI_DEFAULT_MODEL"),
defaultTTSModel: configService.getOrThrow<string>("AI_DEFAULT_TTS_MODEL"),
temperature: configService.get<number>("AI_TEMPERATURE", 0.7),
maxTokens: configService.get<number>("AI_MAX_TOKENS", 5000),
timeout: configService.get<number>("AI_TIMEOUT", 80000),
@@ -0,0 +1,64 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator";
export enum VoiceEnum {
ALLOY = "alloy",
ECHO = "echo",
FABLE = "fable",
ONYX = "onyx",
NOVA = "nova",
SHIMMER = "shimmer",
}
export enum SpeedEnum {
SLOW = 0.5,
NORMAL = 1.0,
FAST = 1.3,
VERY_FAST = 2.0,
}
export enum AudioFormatEnum {
MP3 = "mp3",
OPUS = "opus",
AAC = "aac",
FLAC = "flac",
}
export class TextToSpeechDto {
@IsNotEmpty({ message: "Message ID is required" })
@IsNumber({}, { message: "Message ID must be a number" })
@ApiProperty({ description: "The message ID to fetch and convert to speech", example: 12345 })
messageId: number;
@IsNotEmpty({ message: "Mailbox ID is required" })
@IsString({ message: "Mailbox ID must be a string" })
@ApiProperty({ description: "The mailbox ID where the message is located", example: "507f1f77bcf86cd799439012" })
mailbox: string;
@IsOptional()
@IsEnum(VoiceEnum, { message: "Voice must be one of: alloy, echo, fable, onyx, nova, shimmer" })
@ApiPropertyOptional({
description: "Voice to use for text-to-speech",
enum: VoiceEnum,
default: VoiceEnum.ALLOY,
})
voice?: VoiceEnum;
@IsOptional()
@IsEnum(SpeedEnum, { message: "Speed must be one of: 0.5, 1.0, 1.5, 2.0" })
@ApiPropertyOptional({
description: "Speech speed",
enum: SpeedEnum,
default: SpeedEnum.NORMAL,
})
speed?: SpeedEnum;
@IsOptional()
@IsEnum(AudioFormatEnum, { message: "Format must be one of: mp3, opus, aac, flac" })
@ApiPropertyOptional({
description: "Audio output format",
enum: AudioFormatEnum,
default: AudioFormatEnum.MP3,
})
format?: AudioFormatEnum;
}
@@ -3,6 +3,7 @@ import { ApiOperation, ApiResponse } from "@nestjs/swagger";
import { GenerateTemplateDto } from "./DTO/generate-template.dto";
import { SummarizeEmailDto } from "./DTO/summarize-email.dto";
import { TextToSpeechDto } from "./DTO/text-to-speech.dto";
import { WriteEmailAssistanceDto } from "./DTO/write-email-assistance.dto";
import { AiAssistantService } from "./services/ai-assistant.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
@@ -37,4 +38,14 @@ export class AiAssistantController {
async generateTemplate(@Body() generateTemplateDto: GenerateTemplateDto) {
return this.aiAssistantService.generateTemplate(generateTemplateDto);
}
@Post("text-to-speech")
@ApiOperation({ summary: "Convert email summary to speech audio and save as file" })
@ApiResponse({ status: 201, description: "Audio file generated and saved successfully" })
@ApiResponse({ status: 400, description: "Bad request - invalid message ID or mailbox" })
@ApiResponse({ status: 404, description: "Message not found or access denied" })
@ApiResponse({ status: 401, description: "Unauthorized - invalid authentication" })
async textToSpeech(@Body() textToSpeechDto: TextToSpeechDto, @UserDec("wildduckUserId") wildduckUserId: string) {
return this.aiAssistantService.textToSpeechEmailSummary(textToSpeechDto, wildduckUserId);
}
}
@@ -5,9 +5,10 @@ import { AiAssistantService } from "./services/ai-assistant.service";
import { AiService } from "./services/ai.service";
import { aiConfigProvider } from "../../configs/ai.config";
import { MailServerModule } from "../mail-server/mail-server.module";
import { UploaderModule } from "../uploader/uploader.module";
@Module({
imports: [MailServerModule],
imports: [MailServerModule, UploaderModule],
controllers: [AiAssistantController],
providers: [AiAssistantService, AiService, aiConfigProvider],
exports: [AiAssistantService, AiService],
@@ -2,6 +2,7 @@ import { PriorityEnum } from "../../email/enums/email-header.enum";
import { MessageAttachment } from "../../mail-server/interfaces/messages-response.interface";
import { PersonalityDataType } from "../../templates/interfaces/structure.interface";
import { TemplatePurposeEnum, TemplateStyleEnum, TemplateThemeEnum } from "../DTO/generate-template.dto";
import { AudioFormatEnum, SpeedEnum, VoiceEnum } from "../DTO/text-to-speech.dto";
import { LanguageEnum, SentimentEnum, SummaryLengthEnum, SummaryToneEnum } from "../enums/ai.enum";
export interface IEmailData {
@@ -66,8 +67,31 @@ export interface AIWritingResponse {
improvements: string[];
}
export interface TextToSpeechOptions {
voice: VoiceEnum;
speed: SpeedEnum;
format: AudioFormatEnum;
}
export interface AITextToSpeechResponse {
audioBuffer: Buffer;
format: AudioFormatEnum;
duration?: number;
contentType: string;
}
export interface TextToSpeechFileResponse {
fileUrl: string;
fileName: string;
format: AudioFormatEnum;
contentType: string;
fileSize: number;
s3Key: string;
}
export interface AIServiceInterface {
summarizeEmail(emailData: IEmailData, options: SummarizationOptions): Promise<AISummaryResponse>;
assistEmailWriting(prompt: string): Promise<AIWritingResponse>;
generateTemplate(request: ITemplateGenerationRequest): Promise<AITemplateGenerationResponse>;
textToSpeech(text: string, options: TextToSpeechOptions): Promise<AITextToSpeechResponse>;
}
@@ -2,6 +2,7 @@ export interface AIConfig {
baseUrl: string;
apiKey: string;
defaultModel: string;
defaultTTSModel: string;
temperature: number;
maxTokens: number;
timeout: number;
@@ -5,17 +5,27 @@ import { AiService } from "./ai.service";
import { AiMessage } from "../../../common/enums/message.enum";
import { MessageDetails } from "../../mail-server/interfaces/messages-response.interface";
import { WildDuckMessagesService } from "../../mail-server/services/wildduck-messages.service";
import { S3Service } from "../../uploader/providers/s3.service";
import { GenerateTemplateDto, TemplatePurposeEnum, TemplateStyleEnum, TemplateThemeEnum } from "../DTO/generate-template.dto";
import { SummarizeEmailDto } from "../DTO/summarize-email.dto";
import { AudioFormatEnum, SpeedEnum, TextToSpeechDto, VoiceEnum } from "../DTO/text-to-speech.dto";
import { WriteEmailAssistanceDto } from "../DTO/write-email-assistance.dto";
import { SummaryLengthEnum, SummaryToneEnum } from "../enums/ai.enum";
import { IEmailData, ITemplateGenerationRequest, SummarizationOptions, TemplateGenerationOptions } from "../interfaces/ai-service.interface";
import { AITextToSpeechResponse } from "../interfaces/ai-service.interface";
import {
IEmailData,
ITemplateGenerationRequest,
SummarizationOptions,
TemplateGenerationOptions,
TextToSpeechOptions,
} from "../interfaces/ai-service.interface";
@Injectable()
export class AiAssistantService {
constructor(
private readonly messagesService: WildDuckMessagesService,
private readonly aiService: AiService,
private readonly s3Service: S3Service,
) {}
//************************************************* */
@@ -72,6 +82,35 @@ export class AiAssistantService {
};
}
//************************************************* */
async textToSpeechEmailSummary(textToSpeechDto: TextToSpeechDto, userId: string) {
const message = await firstValueFrom(this.messagesService.getMessage(userId, textToSpeechDto.mailbox, textToSpeechDto.messageId));
if (!message) throw new NotFoundException(AiMessage.MESSAGE_NOT_FOUND_OR_ACCESS_DENIED);
const emailData = this.extractEmailData(message);
const summaryResponse = await this.generateEmailSummary(emailData, SummaryLengthEnum.MEDIUM, SummaryToneEnum.PROFESSIONAL);
// const textForSpeech = this.buildTextForSpeech(summaryResponse, emailData);
const textForSpeech = summaryResponse.summary;
const ttsOptions: TextToSpeechOptions = {
voice: textToSpeechDto.voice || VoiceEnum.ALLOY,
speed: textToSpeechDto.speed || SpeedEnum.NORMAL,
format: textToSpeechDto.format || AudioFormatEnum.MP3,
};
const audioResponse = await this.aiService.textToSpeech(textForSpeech, ttsOptions);
const fileResponse = await this.saveAudioFile(audioResponse, textToSpeechDto, emailData);
return {
file: fileResponse,
summary: summaryResponse,
textUsed: textForSpeech,
};
}
//************************************************* */
private buildTemplateGenerationOptions(dto: GenerateTemplateDto): TemplateGenerationOptions {
return {
@@ -176,6 +215,65 @@ export class AiAssistantService {
};
}
//************************************************* */
private async saveAudioFile(audioResponse: AITextToSpeechResponse, textToSpeechDto: TextToSpeechDto, emailData: IEmailData) {
const timestamp = Date.now();
const sanitizedSubject = emailData.subject?.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 50) || "email_summary";
const fileName = `tts_${sanitizedSubject}_${timestamp}.${audioResponse.format}`;
const s3Key = this.s3Service.generateFileKey(fileName, "audio");
const s3Result = await this.s3Service.uploadFile(audioResponse.audioBuffer, s3Key, audioResponse.contentType, {
originalName: fileName,
messageId: textToSpeechDto.messageId.toString(),
mailbox: textToSpeechDto.mailbox,
voice: textToSpeechDto.voice || VoiceEnum.ALLOY,
speed: (textToSpeechDto.speed || SpeedEnum.NORMAL).toString(),
format: audioResponse.format,
generatedAt: new Date().toISOString(),
});
return {
fileUrl: s3Result.url,
fileName,
format: audioResponse.format,
contentType: audioResponse.contentType,
fileSize: audioResponse.audioBuffer.length,
s3Key: s3Result.key,
};
}
//************************************************* */
// private buildTextForSpeech(summaryResponse: AISummaryResponse, emailData: IEmailData): string {
// let text = `Email Summary: `;
// if (emailData.subject) {
// text += `Subject: ${emailData.subject}. `;
// }
// if (emailData.from) {
// text += `From: ${emailData.from}. `;
// }
// if (summaryResponse.summary) {
// text += `Summary: ${summaryResponse.summary}. `;
// }
// if (summaryResponse.keyPoints && summaryResponse.keyPoints.length > 0) {
// text += `Key Points: ${summaryResponse.keyPoints.join(". ")}. `;
// }
// if (summaryResponse.suggestedActions && summaryResponse.suggestedActions.length > 0) {
// text += `Suggested Actions: ${summaryResponse.suggestedActions.join(". ")}. `;
// }
// if (summaryResponse.priority) {
// text += `Priority: ${summaryResponse.priority}.`;
// }
// return text;
// }
//************************************************* */
private async generateEmailWritingAssistance(writeAssistanceDto: WriteEmailAssistanceDto, _userId: string) {
const aiResponse = await this.aiService.assistEmailWriting(writeAssistanceDto.prompt);
@@ -9,10 +9,12 @@ import {
AIServiceInterface,
AISummaryResponse,
AITemplateGenerationResponse,
AITextToSpeechResponse,
AIWritingResponse,
IEmailData,
ITemplateGenerationRequest,
SummarizationOptions,
TextToSpeechOptions,
} from "../interfaces/ai-service.interface";
import { AIConfig } from "../interfaces/ai.interface";
@@ -118,6 +120,37 @@ export class AiService implements AIServiceInterface {
return parsedResponse;
}
//************************************************* */
async textToSpeech(text: string, options: TextToSpeechOptions): Promise<AITextToSpeechResponse> {
try {
const response = await this.openai.audio.speech.create({
model: this.aiConfig.defaultTTSModel,
voice: options.voice,
input: text,
response_format: options.format,
speed: options.speed,
});
const audioBuffer = Buffer.from(await response.arrayBuffer());
const contentTypeMap = {
mp3: "audio/mpeg",
opus: "audio/opus",
aac: "audio/aac",
flac: "audio/flac",
};
return {
audioBuffer,
format: options.format,
contentType: contentTypeMap[options.format],
};
} catch (error) {
this.logger.error("Error generating text-to-speech", error);
throw new InternalServerErrorException(AiMessage.NO_RESPONSE_FROM_AI_MODEL);
}
}
//************************************************* */
private parseJsonResponse<T>(response: string): T {
@@ -0,0 +1,41 @@
import { File } from "@nest-lab/fastify-multer";
export interface FileUploadResult {
file: File;
url: string;
}
export interface FileStreamResult {
stream: NodeJS.ReadableStream;
file: File;
}
export interface FileMetadata {
width?: number;
height?: number;
duration?: number;
bitrate?: number;
codec?: string;
language?: string;
}
export type FileType = "video" | "audio" | "image" | "document" | "text" | "unknown";
export interface FileValidationOptions {
maxFileSize: number;
allowedMimeTypes: string[];
}
export interface FileUploadOptions {
roomId?: string;
metadata?: Record<string, string>;
}
// File processing status
export type FileProcessingStatus = "pending" | "processing" | "completed" | "failed";
export interface FileProcessingResult {
status: FileProcessingStatus;
metadata?: FileMetadata;
error?: string;
}
+15 -4
View File
@@ -1,10 +1,12 @@
import { Readable } from "stream";
import { randomInt } from "node:crypto";
import { Readable } from "node:stream";
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { FileType } from "../interfaces/file.interface";
import { S3UploadResult } from "../interfaces/s3.interface";
@Injectable()
@@ -118,12 +120,21 @@ export class S3Service {
/**
* Generate S3 key for file
*/
generateFileKey(originalName: string): string {
generateFileKey(originalName: string, fileType: FileType): string {
const timestamp = Date.now();
const randomSuffix = Math.round(Math.random() * 1e9);
const randomSuffix = randomInt(1000000, 9999999);
const extension = originalName.split(".").pop();
const basePath = `images`;
const basePathMap: Record<FileType, string> = {
image: "images",
video: "videos",
audio: "audio",
document: "documents",
text: "texts",
unknown: "unknowns",
};
const basePath = basePathMap[fileType];
return `${basePath}/${timestamp}-${randomSuffix}.${extension}`;
}
}
@@ -4,6 +4,7 @@ import { ConfigService } from "@nestjs/config";
import { S3Service } from "./s3.service";
import { UploaderMessage } from "../../../common/enums/message.enum";
import { FileType } from "../interfaces/file.interface";
@Injectable()
export class UploaderService {
@@ -38,6 +39,27 @@ export class UploaderService {
"image/svg+xml",
"image/tiff",
"image/bmp",
"application/pdf",
"text/plain",
"text/csv",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.presentationml.slideshow",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
"application/vnd.ms-excel.sheet.macroEnabled.12",
"application/vnd.ms-powerpoint.presentation.macroEnabled.12",
"application/vnd.ms-excel.template.macroEnabled.12",
"application/vnd.ms-powerpoint.template.macroEnabled.12",
"application/vnd.ms-excel.addin.macroEnabled.12",
"application/vnd.ms-powerpoint.addin.macroEnabled.12",
"application/vnd.ms-excel.sheet.binary.macroEnabled.12",
"application/vnd.ms-powerpoint.presentation.binary.macroEnabled.12",
"application/vnd.ms-excel.template.binary.macroEnabled.12",
"application/vnd.ms-powerpoint.template.binary.macroEnabled.12",
"application/vnd.ms-excel.addin.binary.macroEnabled.12",
];
}
@@ -54,14 +76,11 @@ export class UploaderService {
}
this.logger.log(`Uploading file: ${file.originalname}`);
// Validate file
this.validateFile(file);
// Determine file type
this.determineFileType(file.mimetype);
const fileType = await this.determineFileType(file.mimetype);
// Generate S3 key
const s3Key = this.s3Service.generateFileKey(file.originalname);
const s3Key = this.s3Service.generateFileKey(file.originalname, fileType);
// Upload to S3
const s3Result = await this.s3Service.uploadFile(file.buffer, s3Key, file.mimetype, {
@@ -83,27 +102,28 @@ export class UploaderService {
private validateFile(file: File): void {
if (!file) throw new BadRequestException(UploaderMessage.UPLOAD_FILE_INVALID);
if (file.size && file.size > this.maxFileSize) throw new BadRequestException(`File too large. Maximum size is ${this.maxFileSize} bytes`);
if (file.size && file.size > this.maxFileSize)
throw new BadRequestException(UploaderMessage.UPLOAD_FILE_TOO_LARGE.replace("[MAX_FILE_SIZE]", this.maxFileSize.toString()));
if (!this.allowedMimeTypes.includes(file.mimetype)) throw new BadRequestException(`File type not supported: ${file.mimetype}`);
if (!this.allowedMimeTypes.includes(file.mimetype))
throw new BadRequestException(UploaderMessage.UPLOAD_FILE_TYPE_NOT_SUPPORTED.replace("[MIME_TYPES]", file.mimetype));
}
/**
* Determine file type based on mimetype
*/
private determineFileType(mimetype: string) {
private async determineFileType(mimetype: string): Promise<FileType> {
if (mimetype.startsWith("video/")) {
return "video";
} else if (mimetype.startsWith("audio/")) {
return "audio";
} else if (mimetype.startsWith("image/")) {
return "image";
} else if (mimetype.startsWith("application/")) {
return "application";
} else if (mimetype.startsWith("text/")) {
return "text";
} else if (mimetype.startsWith("application/")) {
return "document";
}
// Default to video for unknown types
return "video";
return "unknown";
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ export class UploaderController {
@UseInterceptors(FileInterceptor("file"))
@ApiBody({ type: UploadSingleFileDto })
@Post("single-file")
async uploadFile(@UploadedFile() file: File) {
uploadFile(@UploadedFile() file: File) {
return this.uploaderService.uploadFile(file);
}
+1 -1
View File
@@ -7,6 +7,6 @@ import { UploaderController } from "./uploader.controller";
@Module({
controllers: [UploaderController],
providers: [UploaderService, S3Service],
exports: [UploaderService],
exports: [UploaderService, S3Service],
})
export class UploaderModule {}