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