cleanup
This commit is contained in:
parent
9c2a56ee1c
commit
ca2d1cd716
20
.env
20
.env
@ -1,20 +0,0 @@
|
|||||||
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
21
.env.example
@ -1,21 +0,0 @@
|
|||||||
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
11
.gitignore
vendored
@ -1,11 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.npm-cache
|
|
||||||
dist
|
|
||||||
dist-server
|
|
||||||
tmp
|
|
||||||
certs/*.pem
|
|
||||||
.DS_Store
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
[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
16
index.html
@ -1,16 +0,0 @@
|
|||||||
<!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
4122
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
44
package.json
44
package.json
@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
183
server/index.ts
183
server/index.ts
@ -1,183 +0,0 @@
|
|||||||
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);
|
|
||||||
@ -1,356 +0,0 @@
|
|||||||
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
230
src/App.tsx
@ -1,230 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
@ -1,171 +0,0 @@
|
|||||||
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
207
src/index.css
@ -1,207 +0,0 @@
|
|||||||
@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
10
src/main.tsx
@ -1,10 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
export type RecorderPhase =
|
|
||||||
| 'idle'
|
|
||||||
| 'recording'
|
|
||||||
| 'uploading'
|
|
||||||
| 'transcribing'
|
|
||||||
| 'complete'
|
|
||||||
| 'error';
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
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