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:
1 parent
3abb5e9281
commit
9517bad3dc
10 files changed
+674
-73
No files matched your search
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user