184 lines
5.0 KiB
TypeScript
184 lines
5.0 KiB
TypeScript
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);
|