TTs whisper

This commit is contained in:
2026-06-18 13:45:32 +02:00
parent 0e7fbbfdca
commit 9a23863320
57 changed files with 10430 additions and 253 deletions

34
backend/src/config.ts Normal file
View File

@@ -0,0 +1,34 @@
import dotenv from 'dotenv';
dotenv.config();
let _raspberryHost = process.env.RASPBERRY_PI_HOST ?? 'http://raspberrypi.local';
let _raspberryPort = Number(process.env.RASPBERRY_PI_PORT) || 8000;
let _token = process.env.QUIBOT_TOKEN ?? 'MY_SECRET_TOKEN';
const APP_PORT = Number(process.env.PORT) || 5000;
export const getRaspberryHost = () => _raspberryHost;
export const getRaspberryPort = () => _raspberryPort;
export const getToken = () => _token;
export function setRaspberryHost(host: string) {
_raspberryHost = host;
}
export function setRaspberryPort(port: number) {
_raspberryPort = port;
}
export function setToken(token: string) {
_token = token;
}
export const getConfig = () => ({
raspberryPi: {
host: getRaspberryHost(),
port: getRaspberryPort(),
},
token: getToken(),
});
export const getAppPort = () => APP_PORT;

View File

@@ -0,0 +1,119 @@
import { Router } from 'express';
import multer from 'multer';
import { execFile } from 'child_process';
import { tmpdir } from 'os';
import { join } from 'path';
import { promisify } from 'util';
import { writeFile, unlink } from 'fs';
import { raspiService } from '../services/raspi.service.js';
const execFileAsync = promisify(execFile);
const writeFileAsync = promisify(writeFile);
const unlinkAsync = promisify(unlink);
const router = Router();
const upload = multer({ storage: multer.memoryStorage() });
router.get('/incoming', async (_req, res) => {
try {
const result = await raspiService.listIncomingAudio();
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `List incoming failed: ${message}` });
}
});
router.post('/lock/:filename', async (req, res) => {
try {
const { filename } = req.params;
const result = await raspiService.lockAudio({ filename });
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Lock audio failed: ${message}` });
}
});
router.post('/unlock/:filename', async (req, res) => {
try {
const { filename } = req.params;
const result = await raspiService.unlockAudio({ filename });
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Unlock audio failed: ${message}` });
}
});
router.post('/cancel/:filename', async (req, res) => {
try {
const { filename } = req.params;
const result = await raspiService.cancelAudio({ filename });
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Cancel audio failed: ${message}` });
}
});
router.post('/process/:filename', async (req, res) => {
try {
const { filename } = req.params;
const result = await raspiService.processAudio({ filename });
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Process audio failed: ${message}` });
}
});
const whisperModel = process.env.WHISPER_MODEL ?? 'base';
const whisperLanguage = process.env.WHISPER_LANGUAGE ?? 'ca';
router.post('/upload', upload.single('file'), async (req, res) => {
let tmpFile: string | undefined;
try {
if (!req.file) {
return res.status(400).json({ error: 'No audio file provided' });
}
const ext = req.file.originalname.split('.').pop()?.toLowerCase() || 'wav';
tmpFile = join(tmpdir(), `quibot-audio-${Date.now()}.${ext}`);
await writeFileAsync(tmpFile, req.file.buffer);
console.log(`[whisper] Model: ${whisperModel}, Language: ${whisperLanguage}, File: ${tmpFile}`);
const { stdout, stderr } = await execFileAsync('whisper', [
tmpFile,
'--model', whisperModel,
'--language', whisperLanguage,
'--output_format', 'txt',
], { maxBuffer: 50 * 1024 * 1024 });
if (stderr) {
console.log(`[whisper] stderr: ${stderr}`);
}
const transcription = stdout.trim();
res.json({
transcription,
originalFilename: req.file.originalname,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Audio transcription failed: ${message}` });
} finally {
if (tmpFile) {
try {
await unlinkAsync(tmpFile);
} catch {
// ignore cleanup errors
}
}
}
});
export default router;

View File

@@ -0,0 +1,20 @@
import { Router } from 'express';
import { raspiService } from '../services/raspi.service.js';
const router = Router();
router.post('/', async (req, res) => {
const { task } = req.body;
if (!task) {
return res.status(400).json({ error: 'Task name is required' });
}
try {
const result = await raspiService.runTask({ task });
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Run task failed: ${message}` });
}
});
export default router;

View File

@@ -0,0 +1,53 @@
import { Router } from 'express';
import multer from 'multer';
import { raspiService } from '../services/raspi.service.js';
const router = Router();
// Multer config - store in memory for proxying to RasPi
const upload = multer({ storage: multer.memoryStorage() });
router.post('/step/forward', async (_req, res) => {
try {
const result = await raspiService.motorStepForward();
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Motor step forward failed: ${message}` });
}
});
router.post('/step/backward', async (_req, res) => {
try {
const result = await raspiService.motorStepBackward();
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Motor step backward failed: ${message}` });
}
});
router.post('/stop', async (_req, res) => {
try {
const result = await raspiService.motorStop();
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Motor stop failed: ${message}` });
}
});
router.post('/upload', upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No audio file provided' });
}
const result = await raspiService.uploadAudio(req.file.buffer, req.file.originalname);
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Audio upload failed: ${message}` });
}
});
export default router;

View File

@@ -0,0 +1,25 @@
import { Router } from 'express';
import { getConfig, setRaspberryHost, setRaspberryPort, setToken } from '../config.js';
const router = Router();
router.get('/', (_req, res) => {
const settings = getConfig();
res.json(settings);
});
router.put('/', (req, res) => {
const { raspberryPi, token } = req.body;
if (raspberryPi?.host !== undefined) {
setRaspberryHost(raspberryPi.host);
}
if (raspberryPi?.port !== undefined) {
setRaspberryPort(Number(raspberryPi.port));
}
if (token !== undefined) {
setToken(token);
}
res.json(getConfig());
});
export default router;

25
backend/src/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import express from 'express';
import cors from 'cors';
import router from './routes/router.js';
import { getAppPort, getConfig } from './config.js';
const app = express();
app.use(cors());
app.use(express.json());
// Handle multipart in motor controller separately
app.use('/audio', express.json());
app.use('/motor', express.json());
app.use('/commands', express.json());
app.use(router);
app.get('/health', (_req, res) => {
const settings = getConfig();
res.json({ status: 'ok', settings });
});
app.listen(getAppPort(), () => {
console.log(`QuiBot backend listening on port ${getAppPort()}`);
});

View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import motorController from '../controllers/motor.controller.js';
import audioController from '../controllers/audio.controller.js';
import commandController from '../controllers/command.controller.js';
import settingsController from '../controllers/settings.controller.js';
const router = Router();
router.use('/motor', motorController);
router.use('/audio', audioController);
router.use('/commands', commandController);
router.use('/settings', settingsController);
export default router;

View File

@@ -0,0 +1,77 @@
import axios from 'axios';
import { getRaspberryHost, getRaspberryPort, getToken } from '../config.js';
interface RunTaskParams {
task: string;
}
interface AudioLockParams {
filename: string;
}
export const raspiService = {
async runTask(params: RunTaskParams) {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/run`, null, {
params: { task: params.task, token: getToken() },
});
return res.data;
},
async motorStepForward() {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/motor/step/forward`, null, {
params: { token: getToken() },
});
return res.data;
},
async motorStepBackward() {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/motor/step/backwards`, null, {
params: { token: getToken() },
});
return res.data;
},
async motorStop() {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/motor/stop`, null, {
params: { token: getToken() },
});
return res.data;
},
async uploadAudio(buffer: Buffer, filename?: string) {
const fname = filename || 'audio.wav';
const ext = fname.split('.').pop()?.toLowerCase() || 'wav';
const formData = new FormData();
formData.append('file', new Blob([buffer]), fname);
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/upload`, formData, {
params: { format: ext },
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
async listIncomingAudio() {
const res = await axios.get(`${getRaspberryHost()}:${getRaspberryPort()}/audio/incoming`);
return res.data;
},
async lockAudio(params: AudioLockParams) {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/lock/${params.filename}`);
return res.data;
},
async unlockAudio(params: AudioLockParams) {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/unlock/${params.filename}`);
return res.data;
},
async cancelAudio(params: AudioLockParams) {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/cancel/${params.filename}`);
return res.data;
},
async processAudio(params: AudioLockParams) {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/process/${params.filename}`);
return res.data;
},
};