first commit
This commit is contained in:
commit
9c2a56ee1c
20
.env
Normal file
20
.env
Normal file
@ -0,0 +1,20 @@
|
||||
PORT=5000
|
||||
HTTPS_KEY_PATH=certs/localhost-key.pem
|
||||
HTTPS_CERT_PATH=certs/localhost-cert.pem
|
||||
CLIENT_ORIGIN=https://127.0.0.1:5173,https://localhost:5173,https://172.16.11.27:5173
|
||||
|
||||
WHISPER_VM_HOST=172.16.10.51
|
||||
WHISPER_VM_PORT=22
|
||||
WHISPER_VM_USER=kevin
|
||||
WHISPER_VM_PASSWORD=1234
|
||||
WHISPER_VM_AUDIO_DIR=/home/kevin/mom_audio
|
||||
WHISPER_VM_TRANSCRIPT_DIR=/home/kevin/mom_transcripts
|
||||
WHISPER_MODEL=medium
|
||||
WHISPER_LANGUAGE=English
|
||||
WHISPER_ENV_ACTIVATE=/home/kevin/whisper-env/bin/activate
|
||||
WHISPER_ENV_NAME=whisper-env
|
||||
WHISPER_COMMAND=whisper
|
||||
FFMPEG_COMMAND=ffmpeg
|
||||
WHISPER_SSH_READY_TIMEOUT_MS=60000
|
||||
WHISPER_TIMEOUT_MS=1800000
|
||||
MAX_AUDIO_MB=250
|
||||
21
.env.example
Normal file
21
.env.example
Normal file
@ -0,0 +1,21 @@
|
||||
HOST=0.0.0.0
|
||||
PORT=5001
|
||||
HTTPS_KEY_PATH=certs/localhost-key.pem
|
||||
HTTPS_CERT_PATH=certs/localhost-cert.pem
|
||||
CLIENT_ORIGIN=https://127.0.0.1:5173,https://localhost:5173
|
||||
|
||||
WHISPER_VM_HOST=172.16.10.51
|
||||
WHISPER_VM_PORT=22
|
||||
WHISPER_VM_USER=kevin
|
||||
WHISPER_VM_PASSWORD=change-me
|
||||
WHISPER_VM_AUDIO_DIR=/home/kevin/mom_audio
|
||||
WHISPER_VM_TRANSCRIPT_DIR=/home/kevin/mom_transcripts
|
||||
WHISPER_MODEL=medium
|
||||
WHISPER_LANGUAGE=English
|
||||
WHISPER_ENV_ACTIVATE=/home/kevin/whisper-env/bin/activate
|
||||
WHISPER_ENV_NAME=whisper-env
|
||||
WHISPER_COMMAND=whisper
|
||||
FFMPEG_COMMAND=ffmpeg
|
||||
WHISPER_SSH_READY_TIMEOUT_MS=60000
|
||||
WHISPER_TIMEOUT_MS=1800000
|
||||
MAX_AUDIO_MB=250
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.npm-cache
|
||||
dist
|
||||
dist-server
|
||||
tmp
|
||||
certs/*.pem
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
18
certs/localhost-openssl.cnf
Normal file
18
certs/localhost-openssl.cnf
Normal file
@ -0,0 +1,18 @@
|
||||
[req]
|
||||
distinguished_name = dn
|
||||
x509_extensions = v3_req
|
||||
prompt = no
|
||||
|
||||
[dn]
|
||||
CN = localhost
|
||||
|
||||
[v3_req]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = digitalSignature, keyEncipherment
|
||||
extendedKeyUsage = serverAuth
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
IP.1 = 127.0.0.1
|
||||
IP.2 = 172.16.11.27
|
||||
16
index.html
Normal file
16
index.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Premium single-page meeting voice recorder with Whisper transcription."
|
||||
/>
|
||||
<title>Meeting Recorder</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4122
package-lock.json
generated
Normal file
4122
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
package.json
Normal file
44
package.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "mom-portal",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:dev:client\" \"npm:dev:server\"",
|
||||
"dev:client": "vite --host 0.0.0.0",
|
||||
"dev:server": "tsx watch server/index.ts",
|
||||
"server": "tsx server/index.ts",
|
||||
"server:build": "tsc -p tsconfig.server.json",
|
||||
"server:start": "node dist-server/server/index.js",
|
||||
"typecheck": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"multer": "^2.0.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"ssh2": "^1.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/ssh2": "^1.15.4",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.2",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
183
server/index.ts
Normal file
183
server/index.ts
Normal file
@ -0,0 +1,183 @@
|
||||
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);
|
||||
356
server/whisper.ts
Normal file
356
server/whisper.ts
Normal file
@ -0,0 +1,356 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { extname, posix } from 'node:path';
|
||||
import { Client } from 'ssh2';
|
||||
import type { ClientChannel, ConnectConfig, SFTPWrapper } from 'ssh2';
|
||||
|
||||
interface WhisperConfig {
|
||||
audioDir: string;
|
||||
command: string;
|
||||
envActivatePath: string;
|
||||
envName: string;
|
||||
ffmpegCommand: string;
|
||||
host: string;
|
||||
language: string;
|
||||
model: string;
|
||||
password: string;
|
||||
port: number;
|
||||
sshReadyTimeoutMs: number;
|
||||
timeoutMs: number;
|
||||
transcriptDir: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface TranscriptionInput {
|
||||
localFilePath: string;
|
||||
originalName: string;
|
||||
}
|
||||
|
||||
interface TranscriptionOutput {
|
||||
remoteAudioPath: string;
|
||||
remoteTranscriptPath: string;
|
||||
transcript: string;
|
||||
}
|
||||
|
||||
const requiredEnv = (name: string, fallback?: string) => {
|
||||
const value = process.env[name] ?? fallback;
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const getConfig = (): WhisperConfig => ({
|
||||
audioDir: requiredEnv('WHISPER_VM_AUDIO_DIR', '/home/kevin/mom_audio'),
|
||||
command: requiredEnv('WHISPER_COMMAND', 'whisper'),
|
||||
envActivatePath: requiredEnv(
|
||||
'WHISPER_ENV_ACTIVATE',
|
||||
'/home/kevin/whisper-env/bin/activate'
|
||||
),
|
||||
envName: requiredEnv('WHISPER_ENV_NAME', 'whisper-env'),
|
||||
ffmpegCommand: requiredEnv('FFMPEG_COMMAND', 'ffmpeg'),
|
||||
host: requiredEnv('WHISPER_VM_HOST', '172.16.10.51'),
|
||||
language: requiredEnv('WHISPER_LANGUAGE', 'English'),
|
||||
model: requiredEnv('WHISPER_MODEL', 'medium'),
|
||||
password: requiredEnv('WHISPER_VM_PASSWORD'),
|
||||
port: Number(process.env.WHISPER_VM_PORT ?? 22),
|
||||
sshReadyTimeoutMs: Number(process.env.WHISPER_SSH_READY_TIMEOUT_MS ?? 60_000),
|
||||
timeoutMs: Number(process.env.WHISPER_TIMEOUT_MS ?? 1_800_000),
|
||||
transcriptDir: requiredEnv(
|
||||
'WHISPER_VM_TRANSCRIPT_DIR',
|
||||
'/home/kevin/mom_transcripts'
|
||||
),
|
||||
username: requiredEnv('WHISPER_VM_USER', 'kevin')
|
||||
});
|
||||
|
||||
const trimRemoteDir = (dir: string) => dir.replace(/\/+$/, '');
|
||||
|
||||
const shellQuote = (value: string) => `'${value.replace(/'/g, "'\\''")}'`;
|
||||
|
||||
const runInsideWhisperEnv = (config: WhisperConfig, command: string) => {
|
||||
const script = [
|
||||
'set -e',
|
||||
`if [ -f ${shellQuote(config.envActivatePath)} ]; then`,
|
||||
` . ${shellQuote(config.envActivatePath)}`,
|
||||
'elif command -v conda >/dev/null 2>&1; then',
|
||||
' eval "$(conda shell.bash hook)"',
|
||||
` conda activate ${shellQuote(config.envName)}`,
|
||||
'else',
|
||||
` echo ${shellQuote(
|
||||
'Unable to activate the transcription environment. Check the remote activation path.'
|
||||
)} >&2`,
|
||||
' exit 127',
|
||||
'fi',
|
||||
command
|
||||
].join('\n');
|
||||
|
||||
return `bash -lc ${shellQuote(script)}`;
|
||||
};
|
||||
|
||||
const safeBaseName = (fileName: string) => {
|
||||
const withoutExtension = fileName.replace(/\.[^.]+$/, '');
|
||||
const safe = withoutExtension
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
|
||||
return safe || 'meeting-audio';
|
||||
};
|
||||
|
||||
const connectSsh = (config: WhisperConfig) =>
|
||||
new Promise<Client>((resolve, reject) => {
|
||||
const client = new Client();
|
||||
const connection: ConnectConfig = {
|
||||
host: config.host,
|
||||
keepaliveInterval: 15_000,
|
||||
password: config.password,
|
||||
port: config.port,
|
||||
readyTimeout: config.sshReadyTimeoutMs,
|
||||
username: config.username
|
||||
};
|
||||
|
||||
client
|
||||
.on('ready', () => resolve(client))
|
||||
.on('error', (error) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown SSH connection error.';
|
||||
|
||||
reject(
|
||||
new Error(
|
||||
`Unable to connect to the transcription service at ${config.host}:${config.port}. ` +
|
||||
`Check that the VM is powered on, reachable from this machine, and accepting SSH. Details: ${message}`
|
||||
)
|
||||
);
|
||||
})
|
||||
.connect(connection);
|
||||
});
|
||||
|
||||
const getSftp = (client: Client) =>
|
||||
new Promise<SFTPWrapper>((resolve, reject) => {
|
||||
client.sftp((error, sftp) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(sftp);
|
||||
});
|
||||
});
|
||||
|
||||
const execCommand = (client: Client, command: string, timeoutMs: number) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
let stream: ClientChannel | null = null;
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
const finish = (error?: Error, output?: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(output ?? '');
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
stream?.close();
|
||||
finish(new Error('Transcription timed out on the processing machine.'));
|
||||
}, timeoutMs);
|
||||
|
||||
client.exec(command, (error, commandStream) => {
|
||||
if (error) {
|
||||
finish(error);
|
||||
return;
|
||||
}
|
||||
|
||||
stream = commandStream;
|
||||
commandStream.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
commandStream.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
commandStream.on('close', (code: number | null) => {
|
||||
if (code && code !== 0) {
|
||||
finish(
|
||||
new Error(
|
||||
stderr.trim() ||
|
||||
stdout.trim() ||
|
||||
`Transcription failed with exit code ${code}.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
finish(undefined, `${stdout}${stderr}`.trim());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const uploadFile = (
|
||||
sftp: SFTPWrapper,
|
||||
localFilePath: string,
|
||||
remoteFilePath: string
|
||||
) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const source = createReadStream(localFilePath);
|
||||
const target = sftp.createWriteStream(remoteFilePath, {
|
||||
flags: 'w',
|
||||
mode: 0o640
|
||||
});
|
||||
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
source.on('error', finish);
|
||||
target.on('error', finish);
|
||||
target.on('close', () => finish());
|
||||
source.pipe(target);
|
||||
});
|
||||
|
||||
const readRemoteFile = (sftp: SFTPWrapper, remoteFilePath: string) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
let content = '';
|
||||
let settled = false;
|
||||
const source = sftp.createReadStream(remoteFilePath);
|
||||
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(content.trim());
|
||||
};
|
||||
|
||||
source.on('data', (chunk: Buffer | string) => {
|
||||
content += chunk.toString();
|
||||
});
|
||||
source.on('error', finish);
|
||||
source.on('close', () => finish());
|
||||
});
|
||||
|
||||
const deleteRemoteFile = (sftp: SFTPWrapper, remoteFilePath: string) =>
|
||||
new Promise<void>((resolve) => {
|
||||
sftp.unlink(remoteFilePath, () => resolve());
|
||||
});
|
||||
|
||||
export const transcribeOnWhisperVm = async ({
|
||||
localFilePath,
|
||||
originalName
|
||||
}: TranscriptionInput): Promise<TranscriptionOutput> => {
|
||||
const config = getConfig();
|
||||
const audioDir = trimRemoteDir(config.audioDir);
|
||||
const transcriptDir = trimRemoteDir(config.transcriptDir);
|
||||
const extension = extname(originalName).toLowerCase() || '.webm';
|
||||
const remoteBaseName = `${Date.now()}-${randomUUID()}-${safeBaseName(
|
||||
originalName
|
||||
)}`;
|
||||
const remoteSourcePath = posix.join(
|
||||
audioDir,
|
||||
`${remoteBaseName}.source${extension}`
|
||||
);
|
||||
const remoteAudioPath = posix.join(audioDir, `${remoteBaseName}.wav`);
|
||||
const remoteTranscriptPath = posix.join(transcriptDir, `${remoteBaseName}.txt`);
|
||||
const client = await connectSsh(config);
|
||||
let sftp: SFTPWrapper | null = null;
|
||||
|
||||
try {
|
||||
await execCommand(
|
||||
client,
|
||||
`mkdir -p ${shellQuote(audioDir)} ${shellQuote(transcriptDir)}`,
|
||||
30_000
|
||||
);
|
||||
|
||||
sftp = await getSftp(client);
|
||||
await uploadFile(sftp, localFilePath, remoteSourcePath);
|
||||
|
||||
// Browser recordings are commonly WebM/Opus; the remote job is more reliable with WAV.
|
||||
await execCommand(
|
||||
client,
|
||||
[
|
||||
config.ffmpegCommand,
|
||||
'-y',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-i',
|
||||
shellQuote(remoteSourcePath),
|
||||
'-vn',
|
||||
'-ac',
|
||||
'1',
|
||||
'-ar',
|
||||
'16000',
|
||||
'-c:a',
|
||||
'pcm_s16le',
|
||||
shellQuote(remoteAudioPath)
|
||||
].join(' '),
|
||||
config.timeoutMs
|
||||
);
|
||||
|
||||
// Transcription runs remotely so the Node process stays lightweight.
|
||||
await execCommand(
|
||||
client,
|
||||
runInsideWhisperEnv(
|
||||
config,
|
||||
[
|
||||
shellQuote(config.command),
|
||||
shellQuote(remoteAudioPath),
|
||||
'--model',
|
||||
shellQuote(config.model),
|
||||
'--language',
|
||||
shellQuote(config.language),
|
||||
'--task',
|
||||
'transcribe',
|
||||
'--output_dir',
|
||||
shellQuote(transcriptDir),
|
||||
'--output_format',
|
||||
'txt',
|
||||
'--fp16',
|
||||
'False'
|
||||
].join(' ')
|
||||
),
|
||||
config.timeoutMs
|
||||
);
|
||||
|
||||
const transcript = await readRemoteFile(sftp, remoteTranscriptPath);
|
||||
|
||||
if (!transcript) {
|
||||
throw new Error('The transcription service returned an empty transcript.');
|
||||
}
|
||||
|
||||
return {
|
||||
remoteAudioPath,
|
||||
remoteTranscriptPath,
|
||||
transcript
|
||||
};
|
||||
} finally {
|
||||
if (sftp) {
|
||||
await deleteRemoteFile(sftp, remoteSourcePath);
|
||||
sftp.end();
|
||||
}
|
||||
client.end();
|
||||
}
|
||||
};
|
||||
230
src/App.tsx
Normal file
230
src/App.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
import { AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { uploadAudioForTranscription } from './api/transcription';
|
||||
import { MicButton } from './components/MicButton';
|
||||
import { StatusPill } from './components/StatusPill';
|
||||
import { TranscriptCard } from './components/TranscriptCard';
|
||||
import { Waveform } from './components/Waveform';
|
||||
import { formatDuration, useAudioRecorder } from './hooks/useAudioRecorder';
|
||||
import type { RecorderPhase } from './types';
|
||||
|
||||
const getErrorMessage = (error: unknown) =>
|
||||
error instanceof Error ? error.message : 'Something went wrong.';
|
||||
|
||||
function App() {
|
||||
const { duration, error: recorderError, isRecording, startRecording, stopRecording } =
|
||||
useAudioRecorder();
|
||||
const [phase, setPhase] = useState<RecorderPhase>('idle');
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [recordingBlob, setRecordingBlob] = useState<Blob | null>(null);
|
||||
const [recordingUrl, setRecordingUrl] = useState<string | null>(null);
|
||||
const recordingUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (recorderError) {
|
||||
setError(recorderError);
|
||||
setPhase('error');
|
||||
}
|
||||
}, [recorderError]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (recordingUrlRef.current) {
|
||||
URL.revokeObjectURL(recordingUrlRef.current);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const replaceRecordingUrl = (audioBlob: Blob | null) => {
|
||||
if (recordingUrlRef.current) {
|
||||
URL.revokeObjectURL(recordingUrlRef.current);
|
||||
recordingUrlRef.current = null;
|
||||
}
|
||||
|
||||
if (!audioBlob) {
|
||||
setRecordingBlob(null);
|
||||
setRecordingUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUrl = URL.createObjectURL(audioBlob);
|
||||
setRecordingBlob(audioBlob);
|
||||
recordingUrlRef.current = nextUrl;
|
||||
setRecordingUrl(nextUrl);
|
||||
};
|
||||
|
||||
const transcribeRecording = async (audioBlob: Blob) => {
|
||||
setError(null);
|
||||
setUploadProgress(0);
|
||||
setPhase('uploading');
|
||||
|
||||
const result = await uploadAudioForTranscription(
|
||||
audioBlob,
|
||||
setUploadProgress,
|
||||
() => setPhase('transcribing')
|
||||
);
|
||||
|
||||
setTranscript(result);
|
||||
setPhase('complete');
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
setUploadProgress(0);
|
||||
replaceRecordingUrl(null);
|
||||
await startRecording();
|
||||
setPhase('recording');
|
||||
};
|
||||
|
||||
const stopAndUpload = async () => {
|
||||
setError(null);
|
||||
setUploadProgress(0);
|
||||
setPhase('uploading');
|
||||
|
||||
const audioBlob = await stopRecording();
|
||||
|
||||
if (audioBlob.size === 0) {
|
||||
throw new Error('No audio was captured. Please try recording again.');
|
||||
}
|
||||
|
||||
replaceRecordingUrl(audioBlob);
|
||||
await transcribeRecording(audioBlob);
|
||||
};
|
||||
|
||||
const retryTranscription = async () => {
|
||||
if (!recordingBlob) return;
|
||||
|
||||
try {
|
||||
await transcribeRecording(recordingBlob);
|
||||
} catch (retryError) {
|
||||
setError(getErrorMessage(retryError));
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrimaryAction = async () => {
|
||||
const busy = phase === 'uploading' || phase === 'transcribing';
|
||||
if (busy) return;
|
||||
|
||||
try {
|
||||
if (phase === 'recording' || isRecording) {
|
||||
await stopAndUpload();
|
||||
return;
|
||||
}
|
||||
|
||||
await start();
|
||||
} catch (actionError) {
|
||||
setError(getErrorMessage(actionError));
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
|
||||
const copyTranscript = async () => {
|
||||
if (!transcript.trim()) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(transcript);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch {
|
||||
setError('Clipboard access failed. Select and copy the transcript manually.');
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
|
||||
const clearTranscript = () => {
|
||||
setTranscript('');
|
||||
setCopied(false);
|
||||
setError(null);
|
||||
setUploadProgress(0);
|
||||
replaceRecordingUrl(null);
|
||||
setPhase('idle');
|
||||
};
|
||||
|
||||
const showProgress = phase === 'uploading';
|
||||
const showTranscribing = phase === 'transcribing';
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-screen items-center px-4 py-6 text-white sm:px-6 lg:px-8">
|
||||
<section className="mx-auto flex w-full max-w-5xl flex-col gap-5 sm:gap-6">
|
||||
<section className="glass-panel relative overflow-hidden px-5 py-8 text-center shadow-panel sm:px-8 sm:py-10">
|
||||
<div className="glow-line" />
|
||||
|
||||
<div className="relative z-10 mx-auto flex max-w-3xl flex-col items-center">
|
||||
<StatusPill phase={phase} uploadProgress={uploadProgress} />
|
||||
|
||||
<h1 className="mt-7 text-4xl font-semibold text-white sm:text-6xl">
|
||||
Meeting Recorder
|
||||
</h1>
|
||||
<p className="mt-3 max-w-xl text-base leading-7 text-slate-400 sm:text-lg">
|
||||
Record the conversation, turn it into minutes, and keep everything
|
||||
in one polished workspace.
|
||||
</p>
|
||||
|
||||
<div className="mt-9">
|
||||
<MicButton phase={phase} onClick={handlePrimaryAction} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex min-h-14 flex-col items-center justify-center gap-2">
|
||||
{phase === 'recording' ? (
|
||||
<div className="font-mono text-4xl font-semibold text-cyan-100 sm:text-5xl">
|
||||
{formatDuration(duration)}
|
||||
</div>
|
||||
) : showTranscribing ? (
|
||||
<div className="inline-flex items-center gap-2 text-sm text-cyan-100">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Preparing your transcript
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-4xl font-semibold text-slate-500 sm:text-5xl">
|
||||
00:00
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProgress ? (
|
||||
<div className="w-full max-w-xs">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Uploading audio</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-cyan-200 transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Waveform active={phase === 'recording' || showTranscribing} />
|
||||
|
||||
{error ? (
|
||||
<div className="mt-2 flex max-w-xl items-start gap-2 rounded-[8px] border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-left text-sm text-rose-100">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TranscriptCard
|
||||
copied={copied}
|
||||
onClear={clearTranscript}
|
||||
onCopy={copyTranscript}
|
||||
onTranscribe={retryTranscription}
|
||||
phase={phase}
|
||||
recordingUrl={recordingUrl}
|
||||
transcript={transcript}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
63
src/api/transcription.ts
Normal file
63
src/api/transcription.ts
Normal file
@ -0,0 +1,63 @@
|
||||
export interface TranscriptionResult {
|
||||
success: boolean;
|
||||
transcript?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const extensionForMimeType = (mimeType: string) => {
|
||||
if (mimeType.includes('mp4')) return 'mp4';
|
||||
if (mimeType.includes('mpeg')) return 'mp3';
|
||||
if (mimeType.includes('ogg')) return 'ogg';
|
||||
if (mimeType.includes('wav')) return 'wav';
|
||||
return 'webm';
|
||||
};
|
||||
|
||||
export const uploadAudioForTranscription = (
|
||||
audioBlob: Blob,
|
||||
onProgress: (progress: number) => void,
|
||||
onUploadComplete: () => void
|
||||
) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
const formData = new FormData();
|
||||
const extension = extensionForMimeType(audioBlob.type);
|
||||
const fileName = `meeting-recording-${new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, '-')}.${extension}`;
|
||||
|
||||
formData.append('audio', audioBlob, fileName);
|
||||
|
||||
request.open('POST', '/api/transcribe');
|
||||
request.responseType = 'json';
|
||||
|
||||
request.upload.onprogress = (event) => {
|
||||
if (!event.lengthComputable) return;
|
||||
onProgress(Math.min(100, Math.round((event.loaded / event.total) * 100)));
|
||||
};
|
||||
|
||||
request.upload.onload = () => {
|
||||
onProgress(100);
|
||||
onUploadComplete();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Network error while uploading audio.'));
|
||||
};
|
||||
|
||||
request.onload = () => {
|
||||
const response = request.response as TranscriptionResult | null;
|
||||
|
||||
if (request.status >= 200 && request.status < 300 && response?.success) {
|
||||
resolve(response.transcript ?? '');
|
||||
return;
|
||||
}
|
||||
|
||||
reject(
|
||||
new Error(
|
||||
response?.error ?? `Transcription failed with status ${request.status}.`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
request.send(formData);
|
||||
});
|
||||
60
src/components/MicButton.tsx
Normal file
60
src/components/MicButton.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { Loader2, Mic, Square } from 'lucide-react';
|
||||
import type { RecorderPhase } from '../types';
|
||||
|
||||
interface MicButtonProps {
|
||||
phase: RecorderPhase;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const buttonCopy: Record<RecorderPhase, string> = {
|
||||
idle: 'Start recording',
|
||||
recording: 'Stop recording',
|
||||
uploading: 'Uploading audio',
|
||||
transcribing: 'Transcribing audio',
|
||||
complete: 'Record again',
|
||||
error: 'Try again'
|
||||
};
|
||||
|
||||
export const MicButton = ({ phase, onClick }: MicButtonProps) => {
|
||||
const isRecording = phase === 'recording';
|
||||
const isBusy = phase === 'uploading' || phase === 'transcribing';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isBusy}
|
||||
aria-label={buttonCopy[phase]}
|
||||
className={[
|
||||
'mic-button group relative grid h-32 w-32 place-items-center rounded-full transition duration-500 ease-out sm:h-40 sm:w-40',
|
||||
isRecording
|
||||
? 'bg-rose-500/95 shadow-[0_0_52px_rgba(244,63,94,0.42)]'
|
||||
: 'bg-cyan-300 text-slate-950 shadow-glow',
|
||||
isBusy ? 'cursor-wait opacity-80' : 'hover:scale-[1.03] active:scale-[0.98]'
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'absolute inset-[-10px] rounded-full border transition duration-500',
|
||||
isRecording
|
||||
? 'border-rose-300/40 animate-soft-pulse'
|
||||
: 'border-cyan-200/30 group-hover:border-cyan-100/70'
|
||||
].join(' ')}
|
||||
/>
|
||||
<span className="absolute inset-4 rounded-full bg-white/16 blur-xl" />
|
||||
{isBusy ? (
|
||||
<Loader2 className="relative h-12 w-12 animate-spin sm:h-14 sm:w-14" />
|
||||
) : isRecording ? (
|
||||
<Square className="relative h-11 w-11 fill-white text-white sm:h-14 sm:w-14" />
|
||||
) : (
|
||||
<Mic className="relative h-12 w-12 sm:h-14 sm:w-14" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="min-h-6 text-sm font-medium text-slate-200">
|
||||
{buttonCopy[phase]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
139
src/components/RecordingPlayer.tsx
Normal file
139
src/components/RecordingPlayer.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { Pause, Play, RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface RecordingPlayerProps {
|
||||
src: string;
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
|
||||
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
|
||||
return `${minutes}:${remainingSeconds}`;
|
||||
};
|
||||
|
||||
export const RecordingPlayer = ({ src }: RecordingPlayerProps) => {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsPlaying(false);
|
||||
}, [src]);
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
const togglePlayback = async () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
await audio.play();
|
||||
};
|
||||
|
||||
const restart = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
audio.currentTime = 0;
|
||||
setCurrentTime(0);
|
||||
};
|
||||
|
||||
const seek = (nextTime: number) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
audio.currentTime = nextTime;
|
||||
setCurrentTime(nextTime);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-[8px] border border-white/10 bg-slate-950/60 p-3">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
className="hidden"
|
||||
preload="metadata"
|
||||
src={src}
|
||||
onDurationChange={(event) =>
|
||||
setDuration(event.currentTarget.duration || 0)
|
||||
}
|
||||
onLoadedMetadata={(event) =>
|
||||
setDuration(event.currentTarget.duration || 0)
|
||||
}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlayback}
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-cyan-200 text-slate-950 shadow-[0_0_24px_rgba(103,232,249,0.24)] transition hover:bg-cyan-100 active:scale-95"
|
||||
title={isPlaying ? 'Pause recording' : 'Play recording'}
|
||||
aria-label={isPlaying ? 'Pause recording' : 'Play recording'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-5 w-5 fill-slate-950" />
|
||||
) : (
|
||||
<Play className="ml-0.5 h-5 w-5 fill-slate-950" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-slate-400">
|
||||
<span className="truncate text-slate-200">Recorded audio</span>
|
||||
<span className="shrink-0 font-mono">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative h-5">
|
||||
<div className="absolute left-0 right-0 top-1/2 h-1 -translate-y-1/2 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-cyan-200"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step="0.01"
|
||||
value={Math.min(currentTime, duration || currentTime)}
|
||||
onChange={(event) => seek(Number(event.target.value))}
|
||||
className="recording-range absolute inset-0 h-5 w-full"
|
||||
aria-label="Seek recording"
|
||||
disabled={duration <= 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={restart}
|
||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-full border border-white/10 bg-white/[0.06] text-slate-200 transition hover:border-cyan-200/30 hover:bg-cyan-200/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Restart recording"
|
||||
aria-label="Restart recording"
|
||||
disabled={duration <= 0}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
36
src/components/StatusPill.tsx
Normal file
36
src/components/StatusPill.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { AlertCircle, CheckCircle2, Loader2, Radio } from 'lucide-react';
|
||||
import type { RecorderPhase } from '../types';
|
||||
|
||||
interface StatusPillProps {
|
||||
phase: RecorderPhase;
|
||||
uploadProgress: number;
|
||||
}
|
||||
|
||||
export const StatusPill = ({ phase, uploadProgress }: StatusPillProps) => {
|
||||
const label: Record<RecorderPhase, string> = {
|
||||
idle: 'Ready',
|
||||
recording: 'Recording live',
|
||||
uploading: `Uploading ${uploadProgress}%`,
|
||||
transcribing: 'Transcribing',
|
||||
complete: 'Transcript ready',
|
||||
error: 'Needs attention'
|
||||
};
|
||||
|
||||
const icon =
|
||||
phase === 'complete' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-300" />
|
||||
) : phase === 'error' ? (
|
||||
<AlertCircle className="h-4 w-4 text-rose-300" />
|
||||
) : phase === 'uploading' || phase === 'transcribing' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-cyan-200" />
|
||||
) : (
|
||||
<Radio className="h-4 w-4 text-cyan-200" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-sm text-slate-200 shadow-[0_0_26px_rgba(34,211,238,0.12)]">
|
||||
{icon}
|
||||
<span>{label[phase]}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
103
src/components/TranscriptCard.tsx
Normal file
103
src/components/TranscriptCard.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { Clipboard, FileText, Loader2, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import type { RecorderPhase } from '../types';
|
||||
import { RecordingPlayer } from './RecordingPlayer';
|
||||
|
||||
interface TranscriptCardProps {
|
||||
copied: boolean;
|
||||
onClear: () => void;
|
||||
onCopy: () => void;
|
||||
onTranscribe: () => void;
|
||||
phase: RecorderPhase;
|
||||
recordingUrl: string | null;
|
||||
transcript: string;
|
||||
}
|
||||
|
||||
export const TranscriptCard = ({
|
||||
copied,
|
||||
onClear,
|
||||
onCopy,
|
||||
onTranscribe,
|
||||
phase,
|
||||
recordingUrl,
|
||||
transcript
|
||||
}: TranscriptCardProps) => {
|
||||
const hasTranscript = transcript.trim().length > 0;
|
||||
const hasRecording = Boolean(recordingUrl);
|
||||
const hasContent = hasTranscript || hasRecording;
|
||||
const isTranscribing = phase === 'uploading' || phase === 'transcribing';
|
||||
const showTranscribeButton = hasRecording && !hasTranscript;
|
||||
|
||||
return (
|
||||
<section className="glass-panel w-full p-4 sm:p-6">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white sm:text-xl">
|
||||
Transcript
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Meeting minutes appear here as soon as processing finishes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{showTranscribeButton ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTranscribe}
|
||||
disabled={isTranscribing}
|
||||
className="inline-flex min-h-10 items-center gap-2 rounded-[8px] border border-emerald-200/20 bg-emerald-200/10 px-3 text-sm font-medium text-emerald-100 transition hover:border-emerald-100/40 hover:bg-emerald-200/15 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Transcribe recording"
|
||||
>
|
||||
{isTranscribing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)}
|
||||
Transcribe
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
disabled={!hasTranscript}
|
||||
className="inline-flex min-h-10 items-center gap-2 rounded-[8px] border border-cyan-200/20 bg-cyan-200/10 px-3 text-sm font-medium text-cyan-100 transition hover:border-cyan-100/40 hover:bg-cyan-200/15 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Copy transcript"
|
||||
>
|
||||
{copied ? (
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
) : (
|
||||
<Clipboard className="h-4 w-4" />
|
||||
)}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
disabled={!hasContent}
|
||||
className="inline-flex min-h-10 items-center gap-2 rounded-[8px] border border-white/10 bg-white/[0.06] px-3 text-sm font-medium text-slate-200 transition hover:border-rose-200/30 hover:bg-rose-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Clear recording and transcript"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recordingUrl ? (
|
||||
<div className="mb-4">
|
||||
<RecordingPlayer src={recordingUrl} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="transcript-scroll min-h-48 max-h-72 overflow-y-auto rounded-[8px] border border-white/10 bg-black/30 p-4 text-sm leading-7 text-slate-200 sm:min-h-56 sm:max-h-80 sm:text-base">
|
||||
{hasTranscript ? (
|
||||
<p className="whitespace-pre-wrap">{transcript}</p>
|
||||
) : (
|
||||
<p className="text-slate-500">
|
||||
Your meeting transcript will appear here after recording.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
33
src/components/Waveform.tsx
Normal file
33
src/components/Waveform.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
interface WaveformProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const heights = [20, 34, 48, 30, 56, 76, 42, 64, 88, 46, 74, 36, 58, 80, 44];
|
||||
|
||||
export const Waveform = ({ active }: WaveformProps) => (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
'flex h-28 items-center justify-center gap-2 transition duration-500',
|
||||
active ? 'opacity-100' : 'opacity-35'
|
||||
].join(' ')}
|
||||
>
|
||||
{heights.map((height, index) => (
|
||||
<span
|
||||
key={`${height}-${index}`}
|
||||
className={[
|
||||
'wave-bar w-1.5 rounded-full bg-cyan-200/85 shadow-[0_0_18px_rgba(103,232,249,0.55)] sm:w-2',
|
||||
active ? 'is-active' : ''
|
||||
].join(' ')}
|
||||
style={
|
||||
{
|
||||
'--bar-index': index,
|
||||
height
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
171
src/hooks/useAudioRecorder.ts
Normal file
171
src/hooks/useAudioRecorder.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const MIME_TYPES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/mp4'
|
||||
];
|
||||
|
||||
const getSupportedMimeType = () =>
|
||||
MIME_TYPES.find((mimeType) => MediaRecorder.isTypeSupported(mimeType)) ?? '';
|
||||
|
||||
const getPermissionErrorMessage = (error: unknown) => {
|
||||
if (error instanceof DOMException) {
|
||||
if (error.name === 'NotAllowedError' || error.name === 'SecurityError') {
|
||||
return 'Microphone permission was denied. Allow microphone access and try again.';
|
||||
}
|
||||
|
||||
if (error.name === 'NotFoundError') {
|
||||
return 'No microphone was found on this device.';
|
||||
}
|
||||
}
|
||||
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: 'Unable to start microphone recording.';
|
||||
};
|
||||
|
||||
export const formatDuration = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
|
||||
return `${minutes}:${remainingSeconds}`;
|
||||
};
|
||||
|
||||
export const useAudioRecorder = () => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const chunksRef = useRef<BlobPart[]>([]);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const startingRef = useRef(false);
|
||||
const mimeTypeRef = useRef('audio/webm');
|
||||
const startedAtRef = useRef(0);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cleanupStream = useCallback(() => {
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (isRecording || startingRef.current) return;
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
|
||||
throw new Error('This browser does not support audio recording.');
|
||||
}
|
||||
|
||||
try {
|
||||
startingRef.current = true;
|
||||
setError(null);
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
});
|
||||
const mimeType = getSupportedMimeType();
|
||||
const mediaRecorder = new MediaRecorder(
|
||||
stream,
|
||||
mimeType ? { mimeType } : undefined
|
||||
);
|
||||
|
||||
chunksRef.current = [];
|
||||
streamRef.current = stream;
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
mimeTypeRef.current = mimeType || 'audio/webm';
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onerror = () => {
|
||||
setError('Recording failed. Please try again.');
|
||||
clearTimer();
|
||||
cleanupStream();
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
mediaRecorder.start(250);
|
||||
startedAtRef.current = Date.now();
|
||||
setDuration(0);
|
||||
setIsRecording(true);
|
||||
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setDuration(Math.floor((Date.now() - startedAtRef.current) / 1000));
|
||||
}, 300);
|
||||
} catch (recordingError) {
|
||||
cleanupStream();
|
||||
const message = getPermissionErrorMessage(recordingError);
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
startingRef.current = false;
|
||||
}
|
||||
}, [clearTimer, cleanupStream, isRecording]);
|
||||
|
||||
const stopRecording = useCallback(
|
||||
() =>
|
||||
new Promise<Blob>((resolve, reject) => {
|
||||
const mediaRecorder = mediaRecorderRef.current;
|
||||
|
||||
if (!mediaRecorder || mediaRecorder.state === 'inactive') {
|
||||
reject(new Error('No active recording to stop.'));
|
||||
return;
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
clearTimer();
|
||||
cleanupStream();
|
||||
setIsRecording(false);
|
||||
mediaRecorderRef.current = null;
|
||||
|
||||
const audioBlob = new Blob(chunksRef.current, {
|
||||
type: mimeTypeRef.current
|
||||
});
|
||||
|
||||
chunksRef.current = [];
|
||||
resolve(audioBlob);
|
||||
};
|
||||
|
||||
mediaRecorder.stop();
|
||||
}),
|
||||
[clearTimer, cleanupStream]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimer();
|
||||
cleanupStream();
|
||||
if (mediaRecorderRef.current?.state === 'recording') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
},
|
||||
[clearTimer, cleanupStream]
|
||||
);
|
||||
|
||||
return {
|
||||
duration,
|
||||
error,
|
||||
isRecording,
|
||||
startRecording,
|
||||
stopRecording
|
||||
};
|
||||
};
|
||||
207
src/index.css
Normal file
207
src/index.css
Normal file
@ -0,0 +1,207 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color: #f8fafc;
|
||||
background: #020305;
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
background: #020305;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(10, 18, 22, 0.9) 0%, #020305 42%),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.025) 0,
|
||||
rgba(255, 255, 255, 0.025) 1px,
|
||||
transparent 1px,
|
||||
transparent 96px
|
||||
),
|
||||
#020305;
|
||||
}
|
||||
|
||||
body::before {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
background:
|
||||
linear-gradient(90deg, transparent 0%, rgba(34, 211, 238, 0.08) 50%, transparent 100%),
|
||||
linear-gradient(180deg, rgba(34, 211, 238, 0.1) 0%, transparent 34%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
color: #041014;
|
||||
background: #67e8f9;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.105), rgba(255, 255, 255, 0.035)),
|
||||
rgba(3, 8, 12, 0.78);
|
||||
backdrop-filter: blur(26px);
|
||||
box-shadow:
|
||||
0 24px 90px rgba(0, 0, 0, 0.55),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.glow-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 12%;
|
||||
width: 76%;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(103, 232, 249, 0.9),
|
||||
transparent
|
||||
);
|
||||
box-shadow: 0 0 34px rgba(34, 211, 238, 0.6);
|
||||
}
|
||||
|
||||
.mic-button {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.mic-button::after {
|
||||
position: absolute;
|
||||
inset: -28px;
|
||||
z-index: -1;
|
||||
content: '';
|
||||
border-radius: 9999px;
|
||||
background: radial-gradient(circle, rgba(34, 211, 238, 0.2), transparent 68%);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
transform-origin: center;
|
||||
transition: height 300ms ease, opacity 300ms ease;
|
||||
}
|
||||
|
||||
.wave-bar.is-active {
|
||||
animation: waveform 1.05s ease-in-out infinite;
|
||||
animation-delay: calc(var(--bar-index) * 62ms);
|
||||
}
|
||||
|
||||
.transcript-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(103, 232, 249, 0.55) rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.transcript-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.transcript-scroll::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.transcript-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(103, 232, 249, 0.55);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.recording-range {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.recording-range:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.recording-range::-webkit-slider-runnable-track {
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.recording-range::-webkit-slider-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: 3px;
|
||||
appearance: none;
|
||||
border: 2px solid #ecfeff;
|
||||
border-radius: 999px;
|
||||
background: #22d3ee;
|
||||
box-shadow: 0 0 18px rgba(34, 211, 238, 0.45);
|
||||
}
|
||||
|
||||
.recording-range:disabled::-webkit-slider-thumb {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.recording-range::-moz-range-track {
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.recording-range::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #ecfeff;
|
||||
border-radius: 999px;
|
||||
background: #22d3ee;
|
||||
box-shadow: 0 0 18px rgba(34, 211, 238, 0.45);
|
||||
}
|
||||
|
||||
.recording-range:disabled::-moz-range-thumb {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes waveform {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.34);
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
45% {
|
||||
transform: scaleY(1.08);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(10, 18, 22, 0.94) 0%, #020305 50%),
|
||||
#020305;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
7
src/types.ts
Normal file
7
src/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type RecorderPhase =
|
||||
| 'idle'
|
||||
| 'recording'
|
||||
| 'uploading'
|
||||
| 'transcribing'
|
||||
| 'complete'
|
||||
| 'error';
|
||||
39
tailwind.config.ts
Normal file
39
tailwind.config.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'Inter',
|
||||
'ui-sans-serif',
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'Segoe UI',
|
||||
'sans-serif'
|
||||
]
|
||||
},
|
||||
boxShadow: {
|
||||
glow: '0 0 48px rgba(34, 211, 238, 0.28)',
|
||||
panel: '0 24px 90px rgba(0, 0, 0, 0.55)'
|
||||
},
|
||||
animation: {
|
||||
'soft-pulse': 'soft-pulse 2.8s ease-in-out infinite',
|
||||
shimmer: 'shimmer 1.8s linear infinite'
|
||||
},
|
||||
keyframes: {
|
||||
'soft-pulse': {
|
||||
'0%, 100%': { transform: 'scale(1)', opacity: '0.82' },
|
||||
'50%': { transform: 'scale(1.03)', opacity: '1' }
|
||||
},
|
||||
shimmer: {
|
||||
'0%': { transform: 'translateX(-120%)' },
|
||||
'100%': { transform: 'translateX(120%)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
} satisfies Config;
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
16
tsconfig.server.json
Normal file
16
tsconfig.server.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"rootDir": ".",
|
||||
"outDir": "dist-server"
|
||||
},
|
||||
"include": ["server/**/*.ts"]
|
||||
}
|
||||
49
vite.config.ts
Normal file
49
vite.config.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import fs from 'node:fs';
|
||||
import type { ServerOptions as HttpsServerOptions } from 'node:https';
|
||||
import path from 'node:path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const apiPort = Number(env.PORT ?? 5000);
|
||||
const httpsOptions = getHttpsOptions(env);
|
||||
const apiProtocol = httpsOptions ? 'https' : 'http';
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
https: httpsOptions,
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: `${apiProtocol}://127.0.0.1:${apiPort}`,
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const getConfiguredPath = (value: string | undefined) =>
|
||||
value ? path.resolve(process.cwd(), value) : undefined;
|
||||
|
||||
const getHttpsOptions = (
|
||||
env: Record<string, string>
|
||||
): HttpsServerOptions | undefined => {
|
||||
const keyPath = getConfiguredPath(env.HTTPS_KEY_PATH);
|
||||
const certPath = getConfiguredPath(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)
|
||||
};
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user