import 'dotenv/config'; import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import http from 'node:http'; import https, { type ServerOptions as HttpsServerOptions } from 'node:https'; import os from 'node:os'; import path from 'node:path'; import cors from 'cors'; import express from 'express'; import multer from 'multer'; import { transcribeOnWhisperVm } from './whisper.js'; const app = express(); const host = process.env.HOST ?? '0.0.0.0'; const port = Number(process.env.PORT ?? 5000); const maxAudioMb = Number(process.env.MAX_AUDIO_MB ?? 250); const tempUploadDir = path.resolve(process.cwd(), 'tmp', 'uploads'); const getConfiguredPath = (value: string | undefined) => value ? path.resolve(process.cwd(), value) : undefined; const getHttpsOptions = (): HttpsServerOptions | undefined => { const keyPath = getConfiguredPath(process.env.HTTPS_KEY_PATH); const certPath = getConfiguredPath(process.env.HTTPS_CERT_PATH); if (!keyPath && !certPath) return undefined; if (!keyPath || !certPath) { throw new Error('HTTPS_KEY_PATH and HTTPS_CERT_PATH must both be set.'); } return { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) }; }; const httpsOptions = getHttpsOptions(); const protocol = httpsOptions ? 'https' : 'http'; const getClientErrorMessage = (message: string) => /whisper/i.test(message) || /WHISPER_/.test(message) ? 'The transcription service is unavailable. Check the processing machine and try again.' : message; const allowedOrigins = (process.env.CLIENT_ORIGIN ?? '') .split(',') .map((origin) => origin.trim()) .filter(Boolean); fs.mkdirSync(tempUploadDir, { recursive: true }); app.use( cors({ origin: allowedOrigins.length > 0 ? allowedOrigins : true }) ); app.use(express.json()); const storage = multer.diskStorage({ destination: tempUploadDir, filename: (_request, file, callback) => { const extension = path.extname(file.originalname).toLowerCase() || '.webm'; callback(null, `${Date.now()}-${randomUUID()}${extension}`); } }); const upload = multer({ fileFilter: (_request, file, callback) => { const isAudio = file.mimetype.startsWith('audio/') || file.mimetype === 'video/webm' || file.mimetype === 'application/octet-stream'; if (!isAudio) { callback(new Error('Only audio uploads are supported.')); return; } callback(null, true); }, limits: { fileSize: maxAudioMb * 1024 * 1024 }, storage }); app.get('/api/health', (_request, response) => { response.json({ success: true, status: 'ready' }); }); app.post('/api/transcribe', upload.single('audio'), async (request, response, next) => { const file = request.file; if (!file) { response.status(400).json({ success: false, error: 'Audio file is required.' }); return; } try { const result = await transcribeOnWhisperVm({ localFilePath: file.path, originalName: file.originalname }); response.json({ success: true, transcript: result.transcript }); } catch (error) { next(error); } finally { await fs.promises.unlink(file.path).catch(() => undefined); } }); const distDir = path.resolve(process.cwd(), 'dist'); if (process.env.NODE_ENV === 'production' && fs.existsSync(distDir)) { app.use(express.static(distDir)); app.get('*', (_request, response) => { response.sendFile(path.join(distDir, 'index.html')); }); } app.use( ( error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction ) => { const message = error instanceof multer.MulterError ? error.code === 'LIMIT_FILE_SIZE' ? `Audio file is too large. Maximum size is ${maxAudioMb} MB.` : error.message : error instanceof Error ? getClientErrorMessage(error.message) : 'Transcription failed.'; console.error('[transcribe]', error); response.status(500).json({ success: false, error: message }); } ); const getNetworkUrls = () => Object.values(os.networkInterfaces()) .flatMap((networkInterfaces) => networkInterfaces ?? []) .filter( (networkInterface) => networkInterface?.family === 'IPv4' && !networkInterface.internal ) .map((networkInterface) => `${protocol}://${networkInterface.address}:${port}`); const server = httpsOptions ? https.createServer(httpsOptions, app) : http.createServer(app); server.on('error', (error: NodeJS.ErrnoException) => { if (error.code === 'EADDRINUSE') { console.error( `Port ${port} is already in use. Stop the existing dev server or change PORT in .env.` ); process.exit(1); } throw error; }); server.listen(port, host, () => { const networkUrls = getNetworkUrls(); console.log(`MoM transcription API running on ${protocol}://localhost:${port}`); if (networkUrls.length > 0) { console.log(`Network API URL: ${networkUrls.join(', ')}`); } }); server.requestTimeout = Number(process.env.REQUEST_TIMEOUT_MS ?? 1_860_000);