AI-powered speech-to-text transformation

This commit is contained in:
2026-09-23 13:58:43 +03:30
parent 58c3e25db6
commit 230d7b3bf6
4 changed files with 120 additions and 420 deletions
-89
View File
@@ -1,89 +0,0 @@
import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
@Injectable()
export class AiSttService {
constructor(private readonly config: ConfigService) {}
isEnabled(): boolean {
return (
this.config.get<string>('STT_ENABLED') === 'true' &&
Boolean(this.sttUrl())
);
}
async transcribe(file: Express.Multer.File | undefined) {
if (!this.isEnabled()) {
throw new ServiceUnavailableException('تبدیل صوت به متن فعال نیست');
}
if (!file?.buffer?.length) {
throw new BadRequestException('فایل صوتی ارسال نشده است');
}
if (file.size > MAX_AUDIO_BYTES) {
throw new BadRequestException('حجم فایل صوتی بیش از حد مجاز است');
}
const form = new FormData();
form.append(
'audio',
new Blob([new Uint8Array(file.buffer)], {
type: file.mimetype || 'audio/wav',
}),
file.originalname || 'audio.wav',
);
const timeoutMs = Number(this.config.get<string>('STT_TIMEOUT_MS') || 180000);
const headers: Record<string, string> = {};
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim();
if (token) headers['X-STT-Token'] = token;
let res: Response;
try {
res = await fetch(`${this.sttUrl()}/transcribe`, {
method: 'POST',
headers,
body: form,
signal: AbortSignal.timeout(timeoutMs),
});
} catch (e) {
const name = e instanceof Error ? e.name : '';
if (name === 'TimeoutError' || name === 'AbortError') {
throw new ServiceUnavailableException(
'زمان تبدیل صوت تمام شد. اگر تازه مدل عوض شده، چند دقیقه صبر کنید تا دانلود شود و دوباره امتحان کنید.',
);
}
throw new ServiceUnavailableException(
'سرویس تبدیل صوت خاموش است. مدل medium معمولاً حافظهٔ سرور را پر می‌کند و پروسه را می‌کشد. در apps/stt/.env مقدار STT_MODEL=small بگذارید و pm2 restart mento-stt بزنید.',
);
}
const payload = (await res.json().catch(() => ({}))) as {
text?: string;
detail?: string | Array<{ msg?: string }>;
};
if (!res.ok) {
const detail = Array.isArray(payload.detail)
? payload.detail.map((item) => item.msg).filter(Boolean).join('، ')
: payload.detail;
const raw = String(detail || '').trim();
let message = raw || 'تبدیل صوت به متن ناموفق بود';
if (raw.length > 220) {
message = 'تبدیل صوت به متن ناموفق بود. سرویس STT محلی را بررسی کنید.';
}
throw new ServiceUnavailableException(message);
}
return { text: (payload.text ?? '').trim() };
}
private sttUrl(): string {
return (this.config.get<string>('STT_URL') ?? '').trim().replace(/\/$/, '');
}
}
+2 -26
View File
@@ -1,22 +1,10 @@
import {
Body,
Controller,
Get,
Param,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { AiAskService } from './ai-ask.service';
import { AskMentoDto } from './dto';
import { AiService } from './ai.service';
import { AiSttService } from './ai-stt.service';
@Controller('ai')
@UseGuards(JwtAuthGuard)
@@ -24,23 +12,11 @@ export class AiController {
constructor(
private readonly ai: AiService,
private readonly ask: AiAskService,
private readonly stt: AiSttService,
) {}
@Get('status')
status() {
return { enabled: this.ai.isEnabled(), stt: this.stt.isEnabled() };
}
@Post('transcribe')
@UseInterceptors(
FileInterceptor('audio', {
storage: memoryStorage(),
limits: { fileSize: 8 * 1024 * 1024 },
}),
)
transcribe(@UploadedFile() file: Express.Multer.File) {
return this.stt.transcribe(file);
return { enabled: this.ai.isEnabled() };
}
@Post('ask')
+2 -3
View File
@@ -2,12 +2,11 @@ import { Global, Module } from '@nestjs/common';
import { AiAskService } from './ai-ask.service';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
import { AiSttService } from './ai-stt.service';
@Global()
@Module({
controllers: [AiController],
providers: [AiService, AiAskService, AiSttService],
exports: [AiService, AiSttService],
providers: [AiService, AiAskService],
exports: [AiService],
})
export class AiModule {}