feat: add WhisperX diarization and speaker transcript UI

- add WhisperX diarization support to the Whisper VM server
- normalize speaker timestamp segments from Whisper responses
- document Hugging Face/pyannote VM setup and health checks
- show diarized speaker transcript blocks in record and transcript views
- group consecutive segments from the same speaker
- remove duplicate paragraph transcript display when diarized segments exist
- let diarized transcript content expand without an inner scrollbar
This commit is contained in:
KevinB-T committed 2026-05-20 16:34:50 +05:30
1 parent 3abb5e9281
commit 9517bad3dc
10 files changed
+674 -73

No files matched your search

+51 -5
View File
@@ -6,6 +6,15 @@ import { env } from "./env.js";
import { pool, query } from "./database.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const requiredTables = [
"users",
"refresh_tokens",
"audio_assets",
"transcripts",
"transcript_shares",
"audio_metadata",
"transcription_jobs",
];
function identifier(value) {
return `\`${String(value).replaceAll("`", "``")}\``;
@@ -38,12 +47,24 @@ export async function ensureDatabase() {
}
export async function ensureSchema() {
await ensureDatabase();
const schema = await fs.readFile(path.join(__dirname, "schema.sql"), "utf8");
for (const statement of splitSql(schema)) {
await pool.query(statement);
try {
await ensureDatabase();
} catch (error) {
if (!isReadOnlyError(error)) throw error;
await ensureExistingSchema();
return;
}
const schema = await fs.readFile(path.join(__dirname, "schema.sql"), "utf8");
try {
for (const statement of splitSql(schema)) {
await pool.query(statement);
}
await runMigrations();
} catch (error) {
if (!isReadOnlyError(error)) throw error;
await ensureExistingSchema();
}
await runMigrations();
}
export async function runMigrations() {
@@ -85,6 +106,31 @@ export async function runMigrations() {
}
}
function isReadOnlyError(error) {
return (
error?.code === "ER_OPTION_PREVENTS_STATEMENT" ||
error?.errno === 1290 ||
String(error?.message ?? "").includes("--read-only")
);
}
async function ensureExistingSchema() {
const rows = await query(
`SELECT TABLE_NAME AS tableName
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN (${requiredTables.map((_, index) => `:table${index}`).join(", ")})`,
Object.fromEntries(requiredTables.map((table, index) => [`table${index}`, table])),
);
const present = new Set(rows.map((row) => row.tableName));
const missing = requiredTables.filter((table) => !present.has(table));
if (missing.length > 0) {
throw new Error(
`Database is read-only and schema is incomplete. Missing tables: ${missing.join(", ")}`,
);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
ensureSchema()
.then(() => {
+28 -1
View File
@@ -9,7 +9,7 @@ export function securityMiddleware(app) {
app.set("trust proxy", 1);
}
const origins = env.clientOrigin.split(",").map((origin) => origin.trim());
const origins = allowedOrigins();
app.use(
helmet({
crossOriginResourcePolicy: { policy: "cross-origin" },
@@ -35,6 +35,33 @@ export function securityMiddleware(app) {
);
}
function allowedOrigins() {
const configured = env.clientOrigin
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
if (env.isProduction) return configured;
const expanded = new Set(configured);
for (const origin of configured) {
try {
const url = new URL(origin);
if (url.protocol === "http:") {
url.protocol = "https:";
expanded.add(url.toString().replace(/\/$/, ""));
} else if (url.protocol === "https:") {
url.protocol = "http:";
expanded.add(url.toString().replace(/\/$/, ""));
}
} catch {
// Ignore invalid origin entries; the CORS check below will reject them.
}
}
return [...expanded];
}
export const authRateLimiter = rateLimit({
windowMs: env.rateLimit.windowMs,
limit: env.rateLimit.authMax,
+36 -2
View File
@@ -94,10 +94,19 @@ function normalizeTranscript(payload, elapsedMs) {
throw new AppError("Whisper VM returned an empty transcript", 502, "EMPTY_TRANSCRIPT");
}
const timestamps = normalizeSegments(
payload.timestamps ??
payload.segments ??
payload.speaker_segments ??
payload.result?.timestamps ??
payload.result?.segments ??
[],
);
return {
transcriptText,
language: payload.language ?? payload.result?.language ?? null,
timestamps: payload.timestamps ?? payload.segments ?? payload.result?.timestamps ?? [],
timestamps,
duration: numberOrNull(payload.duration ?? payload.result?.duration),
processingTime: Number((elapsedMs / 1000).toFixed(3)),
modelName: env.whisper.modelName,
@@ -108,13 +117,38 @@ function mockTranscript(filename) {
return {
transcriptText: `Mock transcript for ${filename}. Configure WHISPER_API_URL to use Faster-Whisper Large v3.`,
language: "en",
timestamps: [{ start: 0, end: 4, text: "Mock transcript generated for local development." }],
timestamps: [
{
speaker: "Speaker 1",
start: 0,
end: 4,
text: "Mock transcript generated for local development.",
},
],
duration: null,
processingTime: 0.15,
modelName: env.whisper.modelName,
};
}
function normalizeSegments(segments) {
if (!Array.isArray(segments)) return [];
return segments
.map((segment) => {
const text = String(segment?.text ?? "").trim();
const start = numberOrNull(segment?.start);
const end = numberOrNull(segment?.end);
if (!text || start === null || end === null) return null;
return {
...(segment.speaker ? { speaker: String(segment.speaker) } : {}),
start,
end,
text,
};
})
.filter(Boolean);
}
function numberOrNull(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;