Skip to content

Commit 6faa7fc

Browse files
author
minhnq
committed
feat: add speech to text
1 parent eba5d5c commit 6faa7fc

17 files changed

Lines changed: 2052 additions & 1 deletion

backend/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,8 @@ S3_ACCESS_KEY=
99
S3_SECRET_KEY=
1010
S3_BUCKET=
1111

12+
# Deepgram Speech-to-Text
13+
DEEPGRAM_API_KEY=
14+
1215
# Server Port
1316
PORT=3001

backend/bun.lock

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
},
1616
"dependencies": {
1717
"@aws-sdk/client-s3": "^3.700.0",
18+
"@deepgram/sdk": "^4.11.3",
1819
"@nestjs/common": "^10.0.0",
1920
"@nestjs/config": "^3.0.0",
2021
"@nestjs/core": "^10.0.0",
@@ -30,6 +31,7 @@
3031
"devDependencies": {
3132
"@nestjs/cli": "^10.0.0",
3233
"@nestjs/schematics": "^10.0.0",
34+
"@types/express": "^5.0.6",
3335
"@types/node": "^20.3.1",
3436
"prettier": "^3.0.0",
3537
"typescript": "^5.1.3"

backend/src/app.module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { DatabaseModule } from './database/database.module';
55
import { StorageModule } from './storage/storage.module';
66
import { CleanupModule } from './cleanup/cleanup.module';
77
import { ChatModule } from './chat/chat.module';
8+
import { SpeechModule } from './speech/speech.module';
89

910
@Module({
1011
imports: [
@@ -17,6 +18,8 @@ import { ChatModule } from './chat/chat.module';
1718
StorageModule,
1819
CleanupModule,
1920
ChatModule,
21+
SpeechModule,
2022
],
2123
})
2224
export class AppModule { }
25+

backend/src/cleanup/cleanup.service.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,40 @@ export class CleanupService {
8282
this.logger.error('❌ JSON bin cleanup failed:', error);
8383
}
8484
}
85+
86+
/**
87+
* Cleanup expired speech transcriptions - runs every hour
88+
*/
89+
@Cron(CronExpression.EVERY_HOUR)
90+
async handleSpeechCleanup() {
91+
this.logger.log('🧹 Starting speech transcription cleanup...');
92+
93+
try {
94+
const expiredSpeech = await this.databaseService.getExpiredSpeechTranscriptions();
95+
96+
if (expiredSpeech.length === 0) {
97+
this.logger.log('✅ No expired speech transcriptions found');
98+
return;
99+
}
100+
101+
let deleted = 0;
102+
let failed = 0;
103+
104+
for (const speech of expiredSpeech) {
105+
try {
106+
await this.r2Service.deleteObject(speech.object_key);
107+
await this.databaseService.deleteSpeechTranscription(speech.id);
108+
deleted++;
109+
} catch (error) {
110+
this.logger.error(`Failed to delete speech ${speech.id}:`, error);
111+
failed++;
112+
}
113+
}
114+
115+
this.logger.log(`✅ Speech cleanup complete: ${deleted} deleted, ${failed} failed`);
116+
} catch (error) {
117+
this.logger.error('❌ Speech cleanup failed:', error);
118+
}
119+
}
85120
}
121+

backend/src/database/database.service.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,61 @@ export class DatabaseService implements OnModuleInit {
146146
`;
147147
return result.length;
148148
}
149+
150+
// ============ Speech Transcriptions ============
151+
152+
/**
153+
* Initialize speech_transcriptions table
154+
*/
155+
async initSpeechTranscriptionsTable() {
156+
await this.sql`
157+
CREATE TABLE IF NOT EXISTS speech_transcriptions (
158+
id VARCHAR(32) PRIMARY KEY,
159+
object_key VARCHAR(255) NOT NULL,
160+
filename VARCHAR(255) NOT NULL,
161+
file_size BIGINT NOT NULL,
162+
duration_seconds FLOAT,
163+
status VARCHAR(20) DEFAULT 'pending',
164+
transcript TEXT,
165+
words JSONB,
166+
language VARCHAR(10),
167+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
168+
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
169+
)
170+
`;
171+
// Add words column if it doesn't exist (for existing tables)
172+
await this.sql`
173+
ALTER TABLE speech_transcriptions
174+
ADD COLUMN IF NOT EXISTS words JSONB
175+
`;
176+
// Add paragraphs column if it doesn't exist
177+
await this.sql`
178+
ALTER TABLE speech_transcriptions
179+
ADD COLUMN IF NOT EXISTS paragraphs JSONB
180+
`;
181+
await this.sql`
182+
CREATE INDEX IF NOT EXISTS idx_speech_expires
183+
ON speech_transcriptions(expires_at)
184+
`;
185+
this.logger.log('✅ Speech transcriptions table initialized');
186+
}
187+
188+
/**
189+
* Get expired speech transcriptions
190+
*/
191+
async getExpiredSpeechTranscriptions() {
192+
return this.sql`
193+
SELECT id, object_key, filename
194+
FROM speech_transcriptions
195+
WHERE expires_at < NOW()
196+
`;
197+
}
198+
199+
/**
200+
* Delete a speech transcription by ID
201+
*/
202+
async deleteSpeechTranscription(id: string) {
203+
return this.sql`DELETE FROM speech_transcriptions WHERE id = ${id}`;
204+
}
149205
}
206+

backend/src/main.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ async function bootstrap() {
66
const logger = new Logger('Bootstrap');
77
const app = await NestFactory.create(AppModule);
88

9+
// Enable CORS
10+
app.enableCors({
11+
origin: '*',
12+
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
13+
allowedHeaders: ['Content-Type', 'Authorization'],
14+
});
15+
916
const port = process.env.PORT || 3001;
1017
await app.listen(port);
1118

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {
2+
Controller,
3+
Post,
4+
Get,
5+
Param,
6+
Query,
7+
UseInterceptors,
8+
UploadedFile,
9+
BadRequestException,
10+
Res,
11+
Headers,
12+
} from '@nestjs/common';
13+
import { FileInterceptor } from '@nestjs/platform-express';
14+
import { Response } from 'express';
15+
import { SpeechService, SpeechTranscription } from './speech.service';
16+
17+
@Controller('speech')
18+
export class SpeechController {
19+
constructor(private readonly speechService: SpeechService) { }
20+
21+
/**
22+
* Upload audio file
23+
*/
24+
@Post('upload')
25+
@UseInterceptors(FileInterceptor('file', {
26+
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB limit
27+
fileFilter: (req, file, cb) => {
28+
const allowedMimes = [
29+
'audio/mpeg',
30+
'audio/mp3',
31+
'audio/wav',
32+
'audio/wave',
33+
'audio/ogg',
34+
'audio/webm',
35+
'audio/mp4',
36+
'audio/m4a',
37+
'audio/flac',
38+
'audio/x-m4a',
39+
];
40+
if (allowedMimes.includes(file.mimetype) || file.originalname.match(/\.(mp3|wav|ogg|webm|m4a|flac)$/i)) {
41+
cb(null, true);
42+
} else {
43+
cb(new BadRequestException('Only audio files are allowed'), false);
44+
}
45+
},
46+
}))
47+
async uploadAudio(@UploadedFile() file: { originalname: string; buffer: Buffer; mimetype: string; size: number }): Promise<SpeechTranscription> {
48+
if (!file) {
49+
throw new BadRequestException('No file uploaded');
50+
}
51+
return this.speechService.uploadAudio(file);
52+
}
53+
54+
/**
55+
* Start transcription
56+
*/
57+
@Post(':id/transcribe')
58+
async transcribe(@Param('id') id: string, @Query('language') language?: string): Promise<SpeechTranscription> {
59+
return this.speechService.transcribe(id, language || 'vi');
60+
}
61+
62+
/**
63+
* Get transcription result
64+
*/
65+
@Get(':id')
66+
async getTranscription(@Param('id') id: string): Promise<SpeechTranscription> {
67+
return this.speechService.getTranscription(id);
68+
}
69+
70+
/**
71+
* Stream audio file with Range request support for seeking
72+
*/
73+
@Get(':id/audio')
74+
async streamAudio(
75+
@Param('id') id: string,
76+
@Headers('range') range: string | undefined,
77+
@Res() res: Response,
78+
): Promise<void> {
79+
const { buffer, contentType } = await this.speechService.getAudioFile(id);
80+
const fileSize = buffer.length;
81+
82+
// Handle Range requests for seeking
83+
if (range) {
84+
const parts = range.replace(/bytes=/, '').split('-');
85+
const start = parseInt(parts[0], 10);
86+
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
87+
const chunkSize = end - start + 1;
88+
89+
res.status(206);
90+
res.set({
91+
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
92+
'Accept-Ranges': 'bytes',
93+
'Content-Length': chunkSize,
94+
'Content-Type': contentType,
95+
});
96+
res.end(buffer.subarray(start, end + 1));
97+
} else {
98+
// No range - return full file
99+
res.set({
100+
'Accept-Ranges': 'bytes',
101+
'Content-Length': fileSize,
102+
'Content-Type': contentType,
103+
'Content-Disposition': 'inline',
104+
});
105+
res.end(buffer);
106+
}
107+
}
108+
}
109+
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Module } from '@nestjs/common';
2+
import { DatabaseModule } from '../database/database.module';
3+
import { StorageModule } from '../storage/storage.module';
4+
import { SpeechController } from './speech.controller';
5+
import { SpeechService } from './speech.service';
6+
7+
@Module({
8+
imports: [DatabaseModule, StorageModule],
9+
controllers: [SpeechController],
10+
providers: [SpeechService],
11+
})
12+
export class SpeechModule { }

0 commit comments

Comments
 (0)